From b66d38c41939252649365822d9edb10cf5990617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Wed, 26 Feb 2020 10:06:04 +0100 Subject: [PATCH] resources: Add basic @import support to resources.PostCSS This commit also makes the HUGO_ENVIRONMENT environment variable available to Node. Fixes #6957 Fixes #6961 --- docs/content/en/hugo-pipes/postcss.md | 23 +++ hugolib/resource_chain_test.go | 122 ++++++++++++++++ hugolib/testhelpers_test.go | 15 ++ .../resource_transformers/postcss/postcss.go | 136 +++++++++++++++++- .../postcss/postcss_test.go | 18 +++ 5 files changed, 313 insertions(+), 1 deletion(-) diff --git a/docs/content/en/hugo-pipes/postcss.md b/docs/content/en/hugo-pipes/postcss.md index a0a673798..a7ba097fa 100755 --- a/docs/content/en/hugo-pipes/postcss.md +++ b/docs/content/en/hugo-pipes/postcss.md @@ -39,6 +39,12 @@ config [string] noMap [bool] : Default is `true`. Disable the default inline sourcemaps +inlineImports [bool] {{< new-in "0.66.0" >}} +: Default is `false`. Enable inlining of @import statements. It does so recursively, but will only import a file once. +URL imports (e.g. `@import url('https://fonts.googleapis.com/css?family=Open+Sans&display=swap');`) and imports with media queries will be ignored. +Note that this import routine does not care about the CSS spec, so you can have @import anywhere in the file. +Hugo will look for imports relative to the module mount and will respect theme overrides. + _If no configuration file is used:_ use [string] @@ -55,4 +61,21 @@ syntax [string] ```go-html-template {{ $style := resources.Get "css/main.css" | resources.PostCSS (dict "config" "customPostCSS.js" "noMap" true) }} +``` + +## Check Hugo Environment from postcss.config.js + +{{< new-in "0.66.0" >}} + +The current Hugo environment name (set by `--environment` or in config or OS environment) is available in the Node context, which allows constructs like this: + +```js +module.exports = { + plugins: [ + require('autoprefixer'), + ...process.env.HUGO_ENVIRONMENT === 'production' + ? [purgecss] + : [] + ] +} ``` \ No newline at end of file diff --git a/hugolib/resource_chain_test.go b/hugolib/resource_chain_test.go index 9590fc5de..71f57f699 100644 --- a/hugolib/resource_chain_test.go +++ b/hugolib/resource_chain_test.go @@ -16,7 +16,10 @@ package hugolib import ( "io" "os" + "os/exec" "path/filepath" + "runtime" + "strings" "testing" "github.com/gohugoio/hugo/htesting" @@ -694,3 +697,122 @@ Hello2: Bonjour `) } + +func TestResourceChainPostCSS(t *testing.T) { + if !isCI() { + t.Skip("skip (relative) long running modules test when running locally") + } + + if runtime.GOOS == "windows" { + // TODO(bep) + t.Skip("skip npm test on Windows") + } + + wd, _ := os.Getwd() + defer func() { + os.Chdir(wd) + }() + + c := qt.New(t) + + packageJSON := `{ + "scripts": {}, + "dependencies": { + "tailwindcss": "^1.2" + }, + "devDependencies": { + "postcss-cli": "^7.1.0" + } +} +` + + postcssConfig := ` +console.error("Hugo Environment:", process.env.HUGO_ENVIRONMENT ); + +module.exports = { + plugins: [ + require('tailwindcss') + ] +} +` + + tailwindCss := ` +@tailwind base; +@tailwind components; +@tailwind utilities; + +@import "components/all.css"; + +h1 { + @apply text-2xl font-bold; +} + +` + + workDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-test-postcss") + c.Assert(err, qt.IsNil) + defer clean() + + v := viper.New() + v.Set("workingDir", workDir) + v.Set("disableKinds", []string{"taxonomyTerm", "taxonomy", "page"}) + b := newTestSitesBuilder(t).WithLogger(loggers.NewWarningLogger()) + // Need to use OS fs for this. + b.Fs = hugofs.NewDefault(v) + b.WithWorkingDir(workDir) + b.WithViper(v) + + cssDir := filepath.Join(workDir, "assets", "css", "components") + b.Assert(os.MkdirAll(cssDir, 0777), qt.IsNil) + + b.WithContent("p1.md", "") + b.WithTemplates("index.html", ` +{{ $options := dict "inlineImports" true }} +{{ $styles := resources.Get "css/styles.css" | resources.PostCSS $options }} +Styles RelPermalink: {{ $styles.RelPermalink }} +{{ $cssContent := $styles.Content }} +Styles Content: Len: {{ len $styles.Content }}| + +`) + b.WithSourceFile("assets/css/styles.css", tailwindCss) + b.WithSourceFile("assets/css/components/all.css", ` +@import "a.css"; +@import "b.css"; +`, "assets/css/components/a.css", ` +class-in-a { + color: blue; +} +`, "assets/css/components/b.css", ` +@import "a.css"; + +class-in-b { + color: blue; +} +`) + + b.WithSourceFile("package.json", packageJSON) + b.WithSourceFile("postcss.config.js", postcssConfig) + + b.Assert(os.Chdir(workDir), qt.IsNil) + _, err = exec.Command("npm", "install").CombinedOutput() + b.Assert(err, qt.IsNil) + + out, _ := captureStderr(func() error { + b.Build(BuildCfg{}) + return nil + }) + + // Make sure Node sees this. + b.Assert(out, qt.Contains, "Hugo Environment: production") + + b.AssertFileContent("public/index.html", ` +Styles RelPermalink: /css/styles.css +Styles Content: Len: 770878| +`) + + content := b.FileContent("public/css/styles.css") + + b.Assert(strings.Contains(content, "class-in-a"), qt.Equals, true) + b.Assert(strings.Contains(content, "class-in-b"), qt.Equals, true) + +} diff --git a/hugolib/testhelpers_test.go b/hugolib/testhelpers_test.go index ac6fe4348..fe6f3b7e3 100644 --- a/hugolib/testhelpers_test.go +++ b/hugolib/testhelpers_test.go @@ -1039,3 +1039,18 @@ func skipSymlink(t *testing.T) { } } + +func captureStderr(f func() error) (string, error) { + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + err := f() + + w.Close() + os.Stderr = old + + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String(), err +} diff --git a/resources/resource_transformers/postcss/postcss.go b/resources/resource_transformers/postcss/postcss.go index f262a5c91..5085670c7 100644 --- a/resources/resource_transformers/postcss/postcss.go +++ b/resources/resource_transformers/postcss/postcss.go @@ -14,8 +14,18 @@ package postcss import ( + "crypto/sha256" + "encoding/hex" "io" + "io/ioutil" + "path" "path/filepath" + "regexp" + "strings" + + "github.com/gohugoio/hugo/config" + + "github.com/spf13/afero" "github.com/gohugoio/hugo/resources/internal" "github.com/spf13/cast" @@ -33,6 +43,8 @@ import ( "github.com/gohugoio/hugo/resources/resource" ) +const importIdentifier = "@import" + // Some of the options from https://github.com/postcss/postcss-cli type Options struct { @@ -41,6 +53,14 @@ type Options struct { NoMap bool // Disable the default inline sourcemaps + // Enable inlining of @import statements. + // Does so recursively, but currently once only per file; + // that is, it's not possible to import the same file in + // different scopes (root, media query...) + // Note that this import routine does not care about the CSS spec, + // so you can have @import anywhere in the file. + InlineImports bool + // Options for when not using a config file Use string // List of postcss plugins to use Parser string // Custom postcss parser @@ -168,15 +188,28 @@ func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationC cmd.Stdout = ctx.To cmd.Stderr = os.Stderr + // TODO(bep) somehow generalize this to other external helpers that may need this. + env := os.Environ() + config.SetEnvVars(&env, "HUGO_ENVIRONMENT", t.rs.Cfg.GetString("environment")) + cmd.Env = env stdin, err := cmd.StdinPipe() if err != nil { return err } + src := ctx.From + if t.options.InlineImports { + var err error + src, err = t.inlineImports(ctx) + if err != nil { + return err + } + } + go func() { defer stdin.Close() - io.Copy(stdin, ctx.From) + io.Copy(stdin, src) }() err = cmd.Run() @@ -187,7 +220,108 @@ func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationC return nil } +func (t *postcssTransformation) inlineImports(ctx *resources.ResourceTransformationCtx) (io.Reader, error) { + + const importIdentifier = "@import" + + // Set of content hashes. + contentSeen := make(map[string]bool) + + content, err := ioutil.ReadAll(ctx.From) + if err != nil { + return nil, err + } + + contents := string(content) + + newContent, err := t.importRecursive(contentSeen, contents, ctx.InPath) + if err != nil { + return nil, err + } + + return strings.NewReader(newContent), nil + +} + +func (t *postcssTransformation) importRecursive( + contentSeen map[string]bool, + content string, + inPath string) (string, error) { + + basePath := path.Dir(inPath) + + var replacements []string + lines := strings.Split(content, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if shouldImport(line) { + path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';") + filename := filepath.Join(basePath, path) + importContent, hash := t.contentHash(filename) + if importContent == nil { + t.rs.Logger.WARN.Printf("postcss: Failed to resolve CSS @import in %q for path %q", inPath, filename) + continue + } + + if contentSeen[hash] { + // Just replace the line with an empty string. + replacements = append(replacements, []string{line, ""}...) + continue + } + + contentSeen[hash] = true + + // Handle recursive imports. + nested, err := t.importRecursive(contentSeen, string(importContent), filepath.ToSlash(filename)) + if err != nil { + return "", err + } + importContent = []byte(nested) + + replacements = append(replacements, []string{line, string(importContent)}...) + } + } + + if len(replacements) > 0 { + repl := strings.NewReplacer(replacements...) + content = repl.Replace(content) + } + + return content, nil +} + +func (t *postcssTransformation) contentHash(filename string) ([]byte, string) { + b, err := afero.ReadFile(t.rs.Assets.Fs, filename) + if err != nil { + return nil, "" + } + h := sha256.New() + h.Write(b) + return b, hex.EncodeToString(h.Sum(nil)) +} + // Process transforms the given Resource with the PostCSS processor. func (c *Client) Process(res resources.ResourceTransformer, options Options) (resource.Resource, error) { return res.Transform(&postcssTransformation{rs: c.rs, options: options}) } + +var shouldImportRe = regexp.MustCompile(`^@import ["'].*["'];?\s*(/\*.*\*/)?$`) + +// See https://www.w3schools.com/cssref/pr_import_rule.asp +// We currently only support simple file imports, no urls, no media queries. +// So this is OK: +// @import "navigation.css"; +// This is not: +// @import url("navigation.css"); +// @import "mobstyle.css" screen and (max-width: 768px); +func shouldImport(s string) bool { + if !strings.HasPrefix(s, importIdentifier) { + return false + } + if strings.Contains(s, "url(") { + return false + } + + return shouldImportRe.MatchString(s) +} diff --git a/resources/resource_transformers/postcss/postcss_test.go b/resources/resource_transformers/postcss/postcss_test.go index 39936d6b4..02c0ecb55 100644 --- a/resources/resource_transformers/postcss/postcss_test.go +++ b/resources/resource_transformers/postcss/postcss_test.go @@ -37,3 +37,21 @@ func TestDecodeOptions(t *testing.T) { c.Assert(opts2.NoMap, qt.Equals, true) } + +func TestShouldImport(t *testing.T) { + c := qt.New(t) + + for _, test := range []struct { + input string + expect bool + }{ + {input: `@import "navigation.css";`, expect: true}, + {input: `@import "navigation.css"; /* Using a string */`, expect: true}, + {input: `@import "navigation.css"`, expect: true}, + {input: `@import 'navigation.css';`, expect: true}, + {input: `@import url("navigation.css");`, expect: false}, + {input: `@import url('https://fonts.googleapis.com/css?family=Open+Sans:400,400i,800,800i&display=swap');`, expect: false}, + } { + c.Assert(shouldImport(test.input), qt.Equals, test.expect) + } +}