From e1317dd32281dc5ce670e34165dc7780c8f5892b Mon Sep 17 00:00:00 2001 From: Bjørn Erik Pedersen Date: Sun, 23 Jun 2024 12:49:10 +0200 Subject: Add css.TailwindCSS Closes #12618 Closes #12620 --- commands/hugobuilder.go | 2 +- common/hexec/exec.go | 90 ++++- config/allconfig/load.go | 2 +- config/security/securityConfig.go | 1 + config/security/securityConfig_test.go | 2 +- deps/deps.go | 2 +- hugolib/integrationtest_builder.go | 2 +- hugolib/testhelpers_test.go | 2 +- markup/asciidocext/convert_test.go | 2 +- markup/pandoc/convert_test.go | 2 +- markup/rst/convert_test.go | 2 +- modules/client_test.go | 2 +- .../resource_transformers/cssjs/inline_imports.go | 247 ++++++++++++ .../cssjs/inline_imports_test.go | 179 +++++++++ resources/resource_transformers/cssjs/postcss.go | 235 +++++++++++ .../cssjs/postcss_integration_test.go | 265 ++++++++++++ .../resource_transformers/cssjs/tailwindcss.go | 167 ++++++++ .../cssjs/tailwindcss_integration_test.go | 72 ++++ resources/resource_transformers/postcss/postcss.go | 443 --------------------- .../postcss/postcss_integration_test.go | 265 ------------ .../resource_transformers/postcss/postcss_test.go | 169 -------- resources/transform.go | 18 +- tpl/css/css.go | 24 +- 23 files changed, 1277 insertions(+), 918 deletions(-) create mode 100644 resources/resource_transformers/cssjs/inline_imports.go create mode 100644 resources/resource_transformers/cssjs/inline_imports_test.go create mode 100644 resources/resource_transformers/cssjs/postcss.go create mode 100644 resources/resource_transformers/cssjs/postcss_integration_test.go create mode 100644 resources/resource_transformers/cssjs/tailwindcss.go create mode 100644 resources/resource_transformers/cssjs/tailwindcss_integration_test.go delete mode 100644 resources/resource_transformers/postcss/postcss.go delete mode 100644 resources/resource_transformers/postcss/postcss_integration_test.go delete mode 100644 resources/resource_transformers/postcss/postcss_test.go diff --git a/commands/hugobuilder.go b/commands/hugobuilder.go index 84608ca37..1a4a61264 100644 --- a/commands/hugobuilder.go +++ b/commands/hugobuilder.go @@ -920,7 +920,7 @@ func (c *hugoBuilder) handleEvents(watcher *watcher.Batcher, if len(otherChanges) > 0 { livereload.ForceRefresh() // Allow some time for the live reload script to get reconnected. - time.Sleep(100 * time.Millisecond) + time.Sleep(200 * time.Millisecond) } for _, ev := range cssChanges { diff --git a/common/hexec/exec.go b/common/hexec/exec.go index 49291354d..4f23d20f5 100644 --- a/common/hexec/exec.go +++ b/common/hexec/exec.go @@ -21,8 +21,10 @@ import ( "io" "os" "os/exec" + "path/filepath" "regexp" "strings" + "sync" "github.com/cli/safeexec" "github.com/gohugoio/hugo/config" @@ -84,7 +86,7 @@ var WithEnviron = func(env []string) func(c *commandeer) { } // New creates a new Exec using the provided security config. -func New(cfg security.Config) *Exec { +func New(cfg security.Config, workingDir string) *Exec { var baseEnviron []string for _, v := range os.Environ() { k, _ := config.SplitEnvVar(v) @@ -95,6 +97,7 @@ func New(cfg security.Config) *Exec { return &Exec{ sc: cfg, + workingDir: workingDir, baseEnviron: baseEnviron, } } @@ -119,15 +122,23 @@ func SafeCommand(name string, arg ...string) (*exec.Cmd, error) { // Exec enforces a security policy for commands run via os/exec. type Exec struct { - sc security.Config + sc security.Config + workingDir string // os.Environ filtered by the Exec.OsEnviron whitelist filter. baseEnviron []string + + npxInit sync.Once + npxAvailable bool +} + +func (e *Exec) New(name string, arg ...any) (Runner, error) { + return e.new(name, "", arg...) } // New will fail if name is not allowed according to the configured security policy. // Else a configured Runner will be returned ready to be Run. -func (e *Exec) New(name string, arg ...any) (Runner, error) { +func (e *Exec) new(name string, fullyQualifiedName string, arg ...any) (Runner, error) { if err := e.sc.CheckAllowedExec(name); err != nil { return nil, err } @@ -136,27 +147,51 @@ func (e *Exec) New(name string, arg ...any) (Runner, error) { copy(env, e.baseEnviron) cm := &commandeer{ - name: name, - env: env, + name: name, + fullyQualifiedName: fullyQualifiedName, + env: env, } return cm.command(arg...) } -// Npx will try to run npx, and if that fails, it will -// try to run the binary directly. +// Npx will in order: +// 1. Try fo find the binary in the WORKINGDIR/node_modules/.bin directory. +// 2. If not found, and npx is available, run npx --no-install . +// 3. Fall back to the PATH. func (e *Exec) Npx(name string, arg ...any) (Runner, error) { - r, err := e.npx(name, arg...) + // npx is slow, so first try the common case. + nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name) + _, err := safeexec.LookPath(nodeBinFilename) if err == nil { - return r, nil + return e.new(name, nodeBinFilename, arg...) + } + e.checkNpx() + if e.npxAvailable { + r, err := e.npx(name, arg...) + if err == nil { + return r, nil + } } return e.New(name, arg...) } +const ( + npxNoInstall = "--no-install" + npxBinary = "npx" + nodeModulesBinPath = "node_modules/.bin" +) + +func (e *Exec) checkNpx() { + e.npxInit.Do(func() { + e.npxAvailable = InPath(npxBinary) + }) +} + // npx is a convenience method to create a Runner running npx --no-install (\d+) \|`) + shouldImportRe = regexp.MustCompile(`^@import ["'](.*?)["'];?\s*(/\*.*\*/)?$`) +) + +type fileOffset struct { + Filename string + Offset int +} + +type importResolver struct { + r io.Reader + inPath string + opts InlineImports + + contentSeen map[string]bool + dependencyManager identity.Manager + linemap map[int]fileOffset + fs afero.Fs + logger loggers.Logger +} + +func newImportResolver(r io.Reader, inPath string, opts InlineImports, fs afero.Fs, logger loggers.Logger, dependencyManager identity.Manager) *importResolver { + return &importResolver{ + r: r, + dependencyManager: dependencyManager, + inPath: inPath, + fs: fs, logger: logger, + linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool), + opts: opts, + } +} + +func (imp *importResolver) contentHash(filename string) ([]byte, string) { + b, err := afero.ReadFile(imp.fs, filename) + if err != nil { + return nil, "" + } + h := sha256.New() + h.Write(b) + return b, hex.EncodeToString(h.Sum(nil)) +} + +func (imp *importResolver) importRecursive( + lineNum int, + content string, + inPath string, +) (int, string, error) { + basePath := path.Dir(inPath) + + var replacements []string + lines := strings.Split(content, "\n") + + trackLine := func(i, offset int, line string) { + // TODO(bep) this is not very efficient. + imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset} + } + + i := 0 + for offset, line := range lines { + i++ + lineTrimmed := strings.TrimSpace(line) + column := strings.Index(line, lineTrimmed) + line = lineTrimmed + + if !imp.shouldImport(line) { + trackLine(i, offset, line) + } else { + path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';") + filename := filepath.Join(basePath, path) + imp.dependencyManager.AddIdentity(identity.CleanStringIdentity(filename)) + importContent, hash := imp.contentHash(filename) + + if importContent == nil { + if imp.opts.SkipInlineImportsNotFound { + trackLine(i, offset, line) + continue + } + pos := text.Position{ + Filename: inPath, + LineNumber: offset + 1, + ColumnNumber: column + 1, + } + return 0, "", herrors.NewFileErrorFromFileInPos(fmt.Errorf("failed to resolve CSS @import \"%s\"", filename), pos, imp.fs, nil) + } + + i-- + + if imp.contentSeen[hash] { + i++ + // Just replace the line with an empty string. + replacements = append(replacements, []string{line, ""}...) + trackLine(i, offset, "IMPORT") + continue + } + + imp.contentSeen[hash] = true + + // Handle recursive imports. + l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename)) + if err != nil { + return 0, "", err + } + + trackLine(i, offset, line) + + i += l + + importContent = []byte(nested) + + replacements = append(replacements, []string{line, string(importContent)}...) + } + } + + if len(replacements) > 0 { + repl := strings.NewReplacer(replacements...) + content = repl.Replace(content) + } + + return i, content, nil +} + +func (imp *importResolver) resolve() (io.Reader, error) { + content, err := io.ReadAll(imp.r) + if err != nil { + return nil, err + } + + contents := string(content) + + _, newContent, err := imp.importRecursive(0, contents, imp.inPath) + if err != nil { + return nil, err + } + + return strings.NewReader(newContent), nil +} + +// 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 (imp *importResolver) shouldImport(s string) bool { + if !strings.HasPrefix(s, importIdentifier) { + return false + } + if strings.Contains(s, "url(") { + return false + } + + m := shouldImportRe.FindStringSubmatch(s) + if m == nil { + return false + } + + if len(m) != 3 { + return false + } + + if tailwindImportExclude(m[1]) { + return false + } + + return true +} + +func (imp *importResolver) toFileError(output string) error { + inErr := errors.New(output) + + match := cssSyntaxErrorRe.FindStringSubmatch(output) + if match == nil { + return inErr + } + + lineNum, err := strconv.Atoi(match[1]) + if err != nil { + return inErr + } + + file, ok := imp.linemap[lineNum] + if !ok { + return inErr + } + + fi, err := imp.fs.Stat(file.Filename) + if err != nil { + return inErr + } + + meta := fi.(hugofs.FileMetaInfo).Meta() + realFilename := meta.Filename + f, err := meta.Open() + if err != nil { + return inErr + } + defer f.Close() + + ferr := herrors.NewFileErrorFromName(inErr, realFilename) + pos := ferr.Position() + pos.LineNumber = file.Offset + 1 + return ferr.UpdatePosition(pos).UpdateContent(f, nil) + + // return herrors.NewFileErrorFromFile(inErr, file.Filename, realFilename, hugofs.Os, herrors.SimpleLineMatcher) +} diff --git a/resources/resource_transformers/cssjs/inline_imports_test.go b/resources/resource_transformers/cssjs/inline_imports_test.go new file mode 100644 index 000000000..9bcb7f9a3 --- /dev/null +++ b/resources/resource_transformers/cssjs/inline_imports_test.go @@ -0,0 +1,179 @@ +// Copyright 2024 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cssjs + +import ( + "regexp" + "strings" + "testing" + + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/htesting/hqt" + "github.com/gohugoio/hugo/identity" + + "github.com/gohugoio/hugo/helpers" + + "github.com/spf13/afero" + + qt "github.com/frankban/quicktest" +) + +// Issue 6166 +func TestDecodeOptions(t *testing.T) { + c := qt.New(t) + opts1, err := decodePostCSSOptions(map[string]any{ + "no-map": true, + }) + + c.Assert(err, qt.IsNil) + c.Assert(opts1.NoMap, qt.Equals, true) + + opts2, err := decodePostCSSOptions(map[string]any{ + "noMap": true, + }) + + c.Assert(err, qt.IsNil) + c.Assert(opts2.NoMap, qt.Equals, true) +} + +func TestShouldImport(t *testing.T) { + c := qt.New(t) + var imp *importResolver + + 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}, + {input: `@import "printstyle.css" print;`, expect: false}, + } { + c.Assert(imp.shouldImport(test.input), qt.Equals, test.expect) + } +} + +func TestShouldImportExcludes(t *testing.T) { + c := qt.New(t) + var imp *importResolver + + c.Assert(imp.shouldImport(`@import "navigation.css";`), qt.Equals, true) + c.Assert(imp.shouldImport(`@import "tailwindcss";`), qt.Equals, false) + c.Assert(imp.shouldImport(`@import "tailwindcss.css";`), qt.Equals, true) + c.Assert(imp.shouldImport(`@import "tailwindcss/preflight";`), qt.Equals, false) +} + +func TestImportResolver(t *testing.T) { + c := qt.New(t) + fs := afero.NewMemMapFs() + + writeFile := func(name, content string) { + c.Assert(afero.WriteFile(fs, name, []byte(content), 0o777), qt.IsNil) + } + + writeFile("a.css", `@import "b.css"; +@import "c.css"; +A_STYLE1 +A_STYLE2 +`) + + writeFile("b.css", `B_STYLE`) + writeFile("c.css", "@import \"d.css\"\nC_STYLE") + writeFile("d.css", "@import \"a.css\"\n\nD_STYLE") + writeFile("e.css", "E_STYLE") + + mainStyles := strings.NewReader(`@import "a.css"; +@import "b.css"; +LOCAL_STYLE +@import "c.css"; +@import "e.css";`) + + imp := newImportResolver( + mainStyles, + "styles.css", + InlineImports{}, + fs, loggers.NewDefault(), + identity.NopManager, + ) + + r, err := imp.resolve() + c.Assert(err, qt.IsNil) + rs := helpers.ReaderToString(r) + result := regexp.MustCompile(`\n+`).ReplaceAllString(rs, "\n") + + c.Assert(result, hqt.IsSameString, `B_STYLE +D_STYLE +C_STYLE +A_STYLE1 +A_STYLE2 +LOCAL_STYLE +E_STYLE`) + + dline := imp.linemap[3] + c.Assert(dline, qt.DeepEquals, fileOffset{ + Offset: 1, + Filename: "d.css", + }) +} + +func BenchmarkImportResolver(b *testing.B) { + c := qt.New(b) + fs := afero.NewMemMapFs() + + writeFile := func(name, content string) { + c.Assert(afero.WriteFile(fs, name, []byte(content), 0o777), qt.IsNil) + } + + writeFile("a.css", `@import "b.css"; +@import "c.css"; +A_STYLE1 +A_STYLE2 +`) + + writeFile("b.css", `B_STYLE`) + writeFile("c.css", "@import \"d.css\"\nC_STYLE"+strings.Repeat("\nSTYLE", 12)) + writeFile("d.css", "@import \"a.css\"\n\nD_STYLE"+strings.Repeat("\nSTYLE", 55)) + writeFile("e.css", "E_STYLE") + + mainStyles := `@import "a.css"; +@import "b.css"; +LOCAL_STYLE +@import "c.css"; +@import "e.css"; +@import "missing.css";` + + logger := loggers.NewDefault() + + for i := 0; i < b.N; i++ { + b.StopTimer() + imp := newImportResolver( + strings.NewReader(mainStyles), + "styles.css", + InlineImports{}, + fs, logger, + identity.NopManager, + ) + + b.StartTimer() + + _, err := imp.resolve() + if err != nil { + b.Fatal(err) + } + + } +} diff --git a/resources/resource_transformers/cssjs/postcss.go b/resources/resource_transformers/cssjs/postcss.go new file mode 100644 index 000000000..1a9e01142 --- /dev/null +++ b/resources/resource_transformers/cssjs/postcss.go @@ -0,0 +1,235 @@ +// Copyright 2024 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package cssjs provides resource transformations backed by some popular JS based frameworks. +package cssjs + +import ( + "bytes" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/gohugoio/hugo/common/collections" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/loggers" + + "github.com/gohugoio/hugo/common/hugo" + + "github.com/gohugoio/hugo/resources/internal" + "github.com/spf13/cast" + + "github.com/mitchellh/mapstructure" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/resource" +) + +// NewPostCSSClient creates a new PostCSSClient with the given specification. +func NewPostCSSClient(rs *resources.Spec) *PostCSSClient { + return &PostCSSClient{rs: rs} +} + +func decodePostCSSOptions(m map[string]any) (opts PostCSSOptions, err error) { + if m == nil { + return + } + err = mapstructure.WeakDecode(m, &opts) + + if !opts.NoMap { + // There was for a long time a discrepancy between documentation and + // implementation for the noMap property, so we need to support both + // camel and snake case. + opts.NoMap = cast.ToBool(m["no-map"]) + } + + return +} + +// PostCSSClient is the client used to do PostCSS transformations. +type PostCSSClient struct { + rs *resources.Spec +} + +// Process transforms the given Resource with the PostCSS processor. +func (c *PostCSSClient) Process(res resources.ResourceTransformer, options map[string]any) (resource.Resource, error) { + return res.Transform(&postcssTransformation{rs: c.rs, optionsm: options}) +} + +type InlineImports struct { + // Service `mapstructure:",squash"` + // 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 + + // When InlineImports is enabled, we fail the build if an import cannot be resolved. + // You can enable this to allow the build to continue and leave the import statement in place. + // Note that the inline importer does not process url location or imports with media queries, + // so those will be left as-is even without enabling this option. + SkipInlineImportsNotFound bool +} + +// Some of the options from https://github.com/postcss/postcss-cli +type PostCSSOptions struct { + // Set a custom path to look for a config file. + Config string + + NoMap bool // Disable the default inline sourcemaps + + InlineImports `mapstructure:",squash"` + + // Options for when not using a config file + Use string // List of postcss plugins to use + Parser string // Custom postcss parser + Stringifier string // Custom postcss stringifier + Syntax string // Custom postcss syntax +} + +func (opts PostCSSOptions) toArgs() []string { + var args []string + if opts.NoMap { + args = append(args, "--no-map") + } + if opts.Use != "" { + args = append(args, "--use") + args = append(args, strings.Fields(opts.Use)...) + } + if opts.Parser != "" { + args = append(args, "--parser", opts.Parser) + } + if opts.Stringifier != "" { + args = append(args, "--stringifier", opts.Stringifier) + } + if opts.Syntax != "" { + args = append(args, "--syntax", opts.Syntax) + } + return args +} + +type postcssTransformation struct { + optionsm map[string]any + rs *resources.Spec +} + +func (t *postcssTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("postcss", t.optionsm) +} + +// Transform shells out to postcss-cli to do the heavy lifting. +// For this to work, you need some additional tools. To install them globally: +// npm install -g postcss-cli +// npm install -g autoprefixer +func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { + const binaryName = "postcss" + + infol := t.rs.Logger.InfoCommand(binaryName) + infow := loggers.LevelLoggerToWriter(infol) + + ex := t.rs.ExecHelper + + var configFile string + + options, err := decodePostCSSOptions(t.optionsm) + if err != nil { + return err + } + + if options.Config != "" { + configFile = options.Config + } else { + configFile = "postcss.config.js" + } + + configFile = filepath.Clean(configFile) + + // We need an absolute filename to the config file. + if !filepath.IsAbs(configFile) { + configFile = t.rs.BaseFs.ResolveJSConfigFile(configFile) + if configFile == "" && options.Config != "" { + // Only fail if the user specified config file is not found. + return fmt.Errorf("postcss config %q not found", options.Config) + } + } + + var cmdArgs []any + + if configFile != "" { + infol.Logf("use config file %q", configFile) + cmdArgs = []any{"--config", configFile} + } + + if optArgs := options.toArgs(); len(optArgs) > 0 { + cmdArgs = append(cmdArgs, collections.StringSliceToInterfaceSlice(optArgs)...) + } + + var errBuf bytes.Buffer + + stderr := io.MultiWriter(infow, &errBuf) + cmdArgs = append(cmdArgs, hexec.WithStderr(stderr)) + cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To)) + cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(t.rs.Cfg.BaseConfig().WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs))) + + cmd, err := ex.Npx(binaryName, cmdArgs...) + if err != nil { + if hexec.IsNotFound(err) { + // This may be on a CI server etc. Will fall back to pre-built assets. + return &herrors.FeatureNotAvailableError{Cause: err} + } + return err + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + + src := ctx.From + + imp := newImportResolver( + ctx.From, + ctx.InPath, + options.InlineImports, + t.rs.Assets.Fs, t.rs.Logger, ctx.DependencyManager, + ) + + if options.InlineImports.InlineImports { + var err error + src, err = imp.resolve() + if err != nil { + return err + } + } + + go func() { + defer stdin.Close() + io.Copy(stdin, src) + }() + + err = cmd.Run() + if err != nil { + if hexec.IsNotFound(err) { + return &herrors.FeatureNotAvailableError{ + Cause: err, + } + } + return imp.toFileError(errBuf.String()) + } + + return nil +} diff --git a/resources/resource_transformers/cssjs/postcss_integration_test.go b/resources/resource_transformers/cssjs/postcss_integration_test.go new file mode 100644 index 000000000..e8f52326c --- /dev/null +++ b/resources/resource_transformers/cssjs/postcss_integration_test.go @@ -0,0 +1,265 @@ +// Copyright 2024 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cssjs_test + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/bep/logg" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib" +) + +const postCSSIntegrationTestFiles = ` +-- assets/css/components/a.css -- +/* A comment. */ +/* Another comment. */ +class-in-a { + color: blue; +} + +-- assets/css/components/all.css -- +@import "a.css"; +@import "b.css"; +-- assets/css/components/b.css -- +@import "a.css"; + +class-in-b { + color: blue; +} + +-- assets/css/styles.css -- +@tailwind base; +@tailwind components; +@tailwind utilities; + @import "components/all.css"; +h1 { + @apply text-2xl font-bold; +} + +-- config.toml -- +disablekinds = ['taxonomy', 'term', 'page'] +baseURL = "https://example.com" +[build] +useResourceCacheWhen = 'never' +-- content/p1.md -- +-- data/hugo.toml -- +slogan = "Hugo Rocks!" +-- i18n/en.yaml -- +hello: + other: "Hello" +-- i18n/fr.yaml -- +hello: + other: "Bonjour" +-- layouts/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 }}| +-- package.json -- +{ + "scripts": {}, + + "devDependencies": { + "postcss-cli": "7.1.0", + "tailwindcss": "1.2.0" + } +} +-- postcss.config.js -- +console.error("Hugo Environment:", process.env.HUGO_ENVIRONMENT ); +console.error("Hugo PublishDir:", process.env.HUGO_PUBLISHDIR ); +// https://github.com/gohugoio/hugo/issues/7656 +console.error("package.json:", process.env.HUGO_FILE_PACKAGE_JSON ); +console.error("PostCSS Config File:", process.env.HUGO_FILE_POSTCSS_CONFIG_JS ); + +module.exports = { + plugins: [ + require('tailwindcss') + ] +} + +` + +func TestTransformPostCSS(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + c := qt.New(t) + tempDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test") + c.Assert(err, qt.IsNil) + c.Cleanup(clean) + + for _, s := range []string{"never", "always"} { + + repl := strings.NewReplacer( + "https://example.com", + "https://example.com/foo", + "useResourceCacheWhen = 'never'", + fmt.Sprintf("useResourceCacheWhen = '%s'", s), + ) + + files := repl.Replace(postCSSIntegrationTestFiles) + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + WorkingDir: tempDir, + TxtarString: files, + }).Build() + + b.AssertFileContent("public/index.html", ` +Styles RelPermalink: /foo/css/styles.css +Styles Content: Len: 770917| +`) + + if s == "never" { + b.AssertLogContains("Hugo Environment: production") + b.AssertLogContains("Hugo PublishDir: " + filepath.Join(tempDir, "public")) + } + } +} + +// 9880 +func TestTransformPostCSSError(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + if runtime.GOOS == "windows" { + // TODO(bep) This has started to fail on Windows with Go 1.19 on GitHub Actions for some mysterious reason. + t.Skip("Skip on Windows") + } + + c := qt.New(t) + + s, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + NeedsNpmInstall: true, + TxtarString: strings.ReplaceAll(postCSSIntegrationTestFiles, "color: blue;", "@apply foo;"), // Syntax error + }).BuildE() + + s.AssertIsFileError(err) + c.Assert(err.Error(), qt.Contains, "a.css:4:2") +} + +func TestTransformPostCSSNotInstalledError(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + c := qt.New(t) + + s, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + TxtarString: postCSSIntegrationTestFiles, + }).BuildE() + + s.AssertIsFileError(err) + c.Assert(err.Error(), qt.Contains, `binary with name "postcss" not found using npx`) +} + +// #9895 +func TestTransformPostCSSImportError(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + c := qt.New(t) + + s, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + TxtarString: strings.ReplaceAll(postCSSIntegrationTestFiles, `@import "components/all.css";`, `@import "components/doesnotexist.css";`), + }).BuildE() + + s.AssertIsFileError(err) + c.Assert(err.Error(), qt.Contains, "styles.css:4:3") + c.Assert(err.Error(), qt.Contains, filepath.FromSlash(`failed to resolve CSS @import "/css/components/doesnotexist.css"`)) +} + +func TestTransformPostCSSImporSkipInlineImportsNotFound(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + c := qt.New(t) + + files := strings.ReplaceAll(postCSSIntegrationTestFiles, `@import "components/all.css";`, `@import "components/doesnotexist.css";`) + files = strings.ReplaceAll(files, `{{ $options := dict "inlineImports" true }}`, `{{ $options := dict "inlineImports" true "skipInlineImportsNotFound" true }}`) + + s := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + TxtarString: files, + }).Build() + + s.AssertFileContent("public/css/styles.css", `@import "components/doesnotexist.css";`) +} + +// Issue 9787 +func TestTransformPostCSSResourceCacheWithPathInBaseURL(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + c := qt.New(t) + tempDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test") + c.Assert(err, qt.IsNil) + c.Cleanup(clean) + + for i := 0; i < 2; i++ { + files := postCSSIntegrationTestFiles + + if i == 1 { + files = strings.ReplaceAll(files, "https://example.com", "https://example.com/foo") + files = strings.ReplaceAll(files, "useResourceCacheWhen = 'never'", " useResourceCacheWhen = 'always'") + } + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + TxtarString: files, + WorkingDir: tempDir, + }).Build() + + b.AssertFileContent("public/index.html", ` +Styles Content: Len: 770917 +`) + + } +} diff --git a/resources/resource_transformers/cssjs/tailwindcss.go b/resources/resource_transformers/cssjs/tailwindcss.go new file mode 100644 index 000000000..f3195c596 --- /dev/null +++ b/resources/resource_transformers/cssjs/tailwindcss.go @@ -0,0 +1,167 @@ +// Copyright 2024 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cssjs + +import ( + "bytes" + "io" + "regexp" + "strings" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/internal" + "github.com/gohugoio/hugo/resources/resource" + "github.com/mitchellh/mapstructure" +) + +var ( + tailwindcssImportRe = regexp.MustCompile(`^tailwindcss/?`) + tailwindImportExclude = func(s string) bool { + return tailwindcssImportRe.MatchString(s) && !strings.Contains(s, ".") + } +) + +// NewTailwindCSSClient creates a new TailwindCSSClient with the given specification. +func NewTailwindCSSClient(rs *resources.Spec) *TailwindCSSClient { + return &TailwindCSSClient{rs: rs} +} + +// Client is the client used to do TailwindCSS transformations. +type TailwindCSSClient struct { + rs *resources.Spec +} + +// Process transforms the given Resource with the TailwindCSS processor. +func (c *TailwindCSSClient) Process(res resources.ResourceTransformer, options map[string]any) (resource.Resource, error) { + return res.Transform(&tailwindcssTransformation{rs: c.rs, optionsm: options}) +} + +type tailwindcssTransformation struct { + optionsm map[string]any + rs *resources.Spec +} + +func (t *tailwindcssTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("tailwindcss", t.optionsm) +} + +type TailwindCSSOptions struct { + Minify bool // Optimize and minify the output + Optimize bool // Optimize the output without minifying + InlineImports `mapstructure:",squash"` +} + +func (opts TailwindCSSOptions) toArgs() []any { + var args []any + if opts.Minify { + args = append(args, "--minify") + } + if opts.Optimize { + args = append(args, "--optimize") + } + return args +} + +func (t *tailwindcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { + const binaryName = "tailwindcss" + + options, err := decodeTailwindCSSOptions(t.optionsm) + if err != nil { + return err + } + + infol := t.rs.Logger.InfoCommand(binaryName) + infow := loggers.LevelLoggerToWriter(infol) + + ex := t.rs.ExecHelper + + workingDir := t.rs.Cfg.BaseConfig().WorkingDir + + var cmdArgs []any = []any{ + "--input=-", // Read from stdin. + "--cwd", workingDir, + } + + cmdArgs = append(cmdArgs, options.toArgs()...) + + // TODO1 + // npm i tailwindcss @tailwindcss/cli + // npm i tailwindcss@next @tailwindcss/cli@next + // npx tailwindcss -h + + var errBuf bytes.Buffer + + stderr := io.MultiWriter(infow, &errBuf) + cmdArgs = append(cmdArgs, hexec.WithStderr(stderr)) + cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To)) + cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(workingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs))) + + cmd, err := ex.Npx(binaryName, cmdArgs...) + if err != nil { + if hexec.IsNotFound(err) { + // This may be on a CI server etc. Will fall back to pre-built assets. + return &herrors.FeatureNotAvailableError{Cause: err} + } + return err + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + + src := ctx.From + + imp := newImportResolver( + ctx.From, + ctx.InPath, + options.InlineImports, + t.rs.Assets.Fs, t.rs.Logger, ctx.DependencyManager, + ) + + // TODO1 option { + src, err = imp.resolve() + if err != nil { + return err + } + + go func() { + defer stdin.Close() + io.Copy(stdin, src) + }() + + err = cmd.Run() + if err != nil { + if hexec.IsNotFound(err) { + return &herrors.FeatureNotAvailableError{ + Cause: err, + } + } + return imp.toFileError(errBuf.String()) + } + + return nil +} + +func decodeTailwindCSSOptions(m map[string]any) (opts TailwindCSSOptions, err error) { + if m == nil { + return + } + err = mapstructure.WeakDecode(m, &opts) + return +} diff --git a/resources/resource_transformers/cssjs/tailwindcss_integration_test.go b/resources/resource_transformers/cssjs/tailwindcss_integration_test.go new file mode 100644 index 000000000..ddd78b62f --- /dev/null +++ b/resources/resource_transformers/cssjs/tailwindcss_integration_test.go @@ -0,0 +1,72 @@ +// Copyright 2024 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cssjs_test + +import ( + "testing" + + "github.com/bep/logg" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugolib" +) + +func TestTailwindV4Basic(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + files := ` +-- hugo.toml -- +-- package.json -- +{ + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/bep/hugo-starter-tailwind-basic.git" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.0.0-alpha.16", + "tailwindcss": "^4.0.0-alpha.16" + }, + "name": "hugo-starter-tailwind-basic", + "version": "0.1.0" +} +-- assets/css/styles.css -- +@import "tailwindcss"; + +@theme { + --font-family-display: "Satoshi", "sans-serif"; + + --breakpoint-3xl: 1920px; + + --color-neon-pink: oklch(71.7% 0.25 360); + --color-neon-lime: oklch(91.5% 0.258 129); + --color-neon-cyan: oklch(91.3% 0.139 195.8); +} +-- layouts/index.html -- +{{ $css := resources.Get "css/styles.css" | css.TailwindCSS }} +CSS: {{ $css.Content | safeCSS }}| +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + }).Build() + + b.AssertFileContent("public/index.html", "/*! tailwindcss v4.0.0") +} diff --git a/resources/resource_transformers/postcss/postcss.go b/resources/resource_transformers/postcss/postcss.go deleted file mode 100644 index 9015e120d..000000000 --- a/resources/resource_transformers/postcss/postcss.go +++ /dev/null @@ -1,443 +0,0 @@ -// Copyright 2024 The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package postcss - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "path" - "path/filepath" - "regexp" - "strconv" - "strings" - - "github.com/gohugoio/hugo/common/collections" - "github.com/gohugoio/hugo/common/hexec" - "github.com/gohugoio/hugo/common/loggers" - "github.com/gohugoio/hugo/common/text" - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/identity" - - "github.com/gohugoio/hugo/common/hugo" - - "github.com/gohugoio/hugo/resources/internal" - "github.com/spf13/afero" - "github.com/spf13/cast" - - "github.com/mitchellh/mapstructure" - - "github.com/gohugoio/hugo/common/herrors" - "github.com/gohugoio/hugo/resources" - "github.com/gohugoio/hugo/resources/resource" -) - -const importIdentifier = "@import" - -var ( - cssSyntaxErrorRe = regexp.MustCompile(`> (\d+) \|`) - shouldImportRe = regexp.MustCompile(`^@import ["'].*["'];?\s*(/\*.*\*/)?$`) -) - -// New creates a new Client with the given specification. -func New(rs *resources.Spec) *Client { - return &Client{rs: rs} -} - -func decodeOptions(m map[string]any) (opts Options, err error) { - if m == nil { - return - } - err = mapstructure.WeakDecode(m, &opts) - - if !opts.NoMap { - // There was for a long time a discrepancy between documentation and - // implementation for the noMap property, so we need to support both - // camel and snake case. - opts.NoMap = cast.ToBool(m["no-map"]) - } - - return -} - -// Client is the client used to do PostCSS transformations. -type Client struct { - rs *resources.Spec -} - -// Process transforms the given Resource with the PostCSS processor. -func (c *Client) Process(res resources.ResourceTransformer, options map[string]any) (resource.Resource, error) { - return res.Transform(&postcssTransformation{rs: c.rs, optionsm: options}) -} - -// Some of the options from https://github.com/postcss/postcss-cli -type Options struct { - // Set a custom path to look for a config file. - Config string - - 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 - - // When InlineImports is enabled, we fail the build if an import cannot be resolved. - // You can enable this to allow the build to continue and leave the import statement in place. - // Note that the inline importer does not process url location or imports with media queries, - // so those will be left as-is even without enabling this option. - SkipInlineImportsNotFound bool - - // Options for when not using a config file - Use string // List of postcss plugins to use - Parser string // Custom postcss parser - Stringifier string // Custom postcss stringifier - Syntax string // Custom postcss syntax -} - -func (opts Options) toArgs() []string { - var args []string - if opts.NoMap { - args = append(args, "--no-map") - } - if opts.Use != "" { - args = append(args, "--use") - args = append(args, strings.Fields(opts.Use)...) - } - if opts.Parser != "" { - args = append(args, "--parser", opts.Parser) - } - if opts.Stringifier != "" { - args = append(args, "--stringifier", opts.Stringifier) - } - if opts.Syntax != "" { - args = append(args, "--syntax", opts.Syntax) - } - return args -} - -type postcssTransformation struct { - optionsm map[string]any - rs *resources.Spec -} - -func (t *postcssTransformation) Key() internal.ResourceTransformationKey { - return internal.NewResourceTransformationKey("postcss", t.optionsm) -} - -// Transform shells out to postcss-cli to do the heavy lifting. -// For this to work, you need some additional tools. To install them globally: -// npm install -g postcss-cli -// npm install -g autoprefixer -func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { - const binaryName = "postcss" - - infol := t.rs.Logger.InfoCommand(binaryName) - infow := loggers.LevelLoggerToWriter(infol) - - ex := t.rs.ExecHelper - - var configFile string - - var options Options - if t.optionsm != nil { - var err error - options, err = decodeOptions(t.optionsm) - if err != nil { - return err - } - } - - if options.Config != "" { - configFile = options.Config - } else { - configFile = "postcss.config.js" - } - - configFile = filepath.Clean(configFile) - - // We need an absolute filename to the config file. - if !filepath.IsAbs(configFile) { - configFile = t.rs.BaseFs.ResolveJSConfigFile(configFile) - if configFile == "" && options.Config != "" { - // Only fail if the user specified config file is not found. - return fmt.Errorf("postcss config %q not found", options.Config) - } - } - - var cmdArgs []any - - if configFile != "" { - infol.Logf("use config file %q", configFile) - cmdArgs = []any{"--config", configFile} - } - - if optArgs := options.toArgs(); len(optArgs) > 0 { - cmdArgs = append(cmdArgs, collections.StringSliceToInterfaceSlice(optArgs)...) - } - - var errBuf bytes.Buffer - - stderr := io.MultiWriter(infow, &errBuf) - cmdArgs = append(cmdArgs, hexec.WithStderr(stderr)) - cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To)) - cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(t.rs.Cfg.BaseConfig().WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs))) - - cmd, err := ex.Npx(binaryName, cmdArgs...) - if err != nil { - if hexec.IsNotFound(err) { - // This may be on a CI server etc. Will fall back to pre-built assets. - return &herrors.FeatureNotAvailableError{Cause: err} - } - return err - } - - stdin, err := cmd.StdinPipe() - if err != nil { - return err - } - - src := ctx.From - - imp := newImportResolver( - ctx.From, - ctx.InPath, - options, - t.rs.Assets.Fs, t.rs.Logger, ctx.DependencyManager, - ) - - if options.InlineImports { - var err error - src, err = imp.resolve() - if err != nil { - return err - } - } - - go func() { - defer stdin.Close() - io.Copy(stdin, src) - }() - - err = cmd.Run() - if err != nil { - if hexec.IsNotFound(err) { - return &herrors.FeatureNotAvailableError{ - Cause: err, - } - } - return imp.toFileError(errBuf.String()) - } - - return nil -} - -type fileOffset struct { - Filename string - Offset int -} - -type importResolver struct { - r io.Reader - inPath string - opts Options - - contentSeen map[string]bool - dependencyManager identity.Manager - linemap map[int]fileOffset - fs afero.Fs - logger loggers.Logger -} - -func newImportResolver(r io.Reader, inPath string, opts Options, fs afero.Fs, logger loggers.Logger, dependencyManager identity.Manager) *importResolver { - return &importResolver{ - r: r, - dependencyManager: dependencyManager, - inPath: inPath, - fs: fs, logger: logger, - linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool), - opts: opts, - } -} - -func (imp *importResolver) contentHash(filename string) ([]byte, string) { - b, err := afero.ReadFile(imp.fs, filename) - if err != nil { - return nil, "" - } - h := sha256.New() - h.Write(b) - return b, hex.EncodeToString(h.Sum(nil)) -} - -func (imp *importResolver) importRecursive( - lineNum int, - content string, - inPath string, -) (int, string, error) { - basePath := path.Dir(inPath) - - var replacements []string - lines := strings.Split(content, "\n") - - trackLine := func(i, offset int, line string) { - // TODO(bep) this is not very efficient. - imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset} - } - - i := 0 - for offset, line := range lines { - i++ - lineTrimmed := strings.TrimSpace(line) - column := strings.Index(line, lineTrimmed) - line = lineTrimmed - - if !imp.shouldImport(line) { - trackLine(i, offset, line) - } else { - path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';") - filename := filepath.Join(basePath, path) - imp.dependencyManager.AddIdentity(identity.CleanStringIdentity(filename)) - importContent, hash := imp.contentHash(filename) - - if importContent == nil { - if imp.opts.SkipInlineImportsNotFound { - trackLine(i, offset, line) - continue - } - pos := text.Position{ - Filename: inPath, - LineNumber: offset + 1, - ColumnNumber: column + 1, - } - return 0, "", herrors.NewFileErrorFromFileInPos(fmt.Errorf("failed to resolve CSS @import \"%s\"", filename), pos, imp.fs, nil) - } - - i-- - - if imp.contentSeen[hash] { - i++ - // Just replace the line with an empty string. - replacements = append(replacements, []string{line, ""}...) - trackLine(i, offset, "IMPORT") - continue - } - - imp.contentSeen[hash] = true - - // Handle recursive imports. - l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename)) - if err != nil { - return 0, "", err - } - - trackLine(i, offset, line) - - i += l - - importContent = []byte(nested) - - replacements = append(replacements, []string{line, string(importContent)}...) - } - } - - if len(replacements) > 0 { - repl := strings.NewReplacer(replacements...) - content = repl.Replace(content) - } - - return i, content, nil -} - -func (imp *importResolver) resolve() (io.Reader, error) { - content, err := io.ReadAll(imp.r) - if err != nil { - return nil, err - } - - contents := string(content) - - _, newContent, err := imp.importRecursive(0, contents, imp.inPath) - if err != nil { - return nil, err - } - - return strings.NewReader(newContent), nil -} - -// 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 (imp *importResolver) shouldImport(s string) bool { - if !strings.HasPrefix(s, importIdentifier) { - return false - } - if strings.Contains(s, "url(") { - return false - } - - return shouldImportRe.MatchString(s) -} - -func (imp *importResolver) toFileError(output string) error { - inErr := errors.New(output) - - match := cssSyntaxErrorRe.FindStringSubmatch(output) - if match == nil { - return inErr - } - - lineNum, err := strconv.Atoi(match[1]) - if err != nil { - return inErr - } - - file, ok := imp.linemap[lineNum] - if !ok { - return inErr - } - - fi, err := imp.fs.Stat(file.Filename) - if err != nil { - return inErr - } - - meta := fi.(hugofs.FileMetaInfo).Meta() - realFilename := meta.Filename - f, err := meta.Open() - if err != nil { - return inErr - } - defer f.Close() - - ferr := herrors.NewFileErrorFromName(inErr, realFilename) - pos := ferr.Position() - pos.LineNumber = file.Offset + 1 - return ferr.UpdatePosition(pos).UpdateContent(f, nil) - - // return herrors.NewFileErrorFromFile(inErr, file.Filename, realFilename, hugofs.Os, herrors.SimpleLineMatcher) -} diff --git a/resources/resource_transformers/postcss/postcss_integration_test.go b/resources/resource_transformers/postcss/postcss_integration_test.go deleted file mode 100644 index 957e69403..000000000 --- a/resources/resource_transformers/postcss/postcss_integration_test.go +++ /dev/null @@ -1,265 +0,0 @@ -// Copyright 2021 The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package postcss_test - -import ( - "fmt" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/bep/logg" - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/htesting" - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/hugolib" -) - -const postCSSIntegrationTestFiles = ` --- assets/css/components/a.css -- -/* A comment. */ -/* Another comment. */ -class-in-a { - color: blue; -} - --- assets/css/components/all.css -- -@import "a.css"; -@import "b.css"; --- assets/css/components/b.css -- -@import "a.css"; - -class-in-b { - color: blue; -} - --- assets/css/styles.css -- -@tailwind base; -@tailwind components; -@tailwind utilities; - @import "components/all.css"; -h1 { - @apply text-2xl font-bold; -} - --- config.toml -- -disablekinds = ['taxonomy', 'term', 'page'] -baseURL = "https://example.com" -[build] -useResourceCacheWhen = 'never' --- content/p1.md -- --- data/hugo.toml -- -slogan = "Hugo Rocks!" --- i18n/en.yaml -- -hello: - other: "Hello" --- i18n/fr.yaml -- -hello: - other: "Bonjour" --- layouts/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 }}| --- package.json -- -{ - "scripts": {}, - - "devDependencies": { - "postcss-cli": "7.1.0", - "tailwindcss": "1.2.0" - } -} --- postcss.config.js -- -console.error("Hugo Environment:", process.env.HUGO_ENVIRONMENT ); -console.error("Hugo PublishDir:", process.env.HUGO_PUBLISHDIR ); -// https://github.com/gohugoio/hugo/issues/7656 -console.error("package.json:", process.env.HUGO_FILE_PACKAGE_JSON ); -console.error("PostCSS Config File:", process.env.HUGO_FILE_POSTCSS_CONFIG_JS ); - -module.exports = { - plugins: [ - require('tailwindcss') - ] -} - -` - -func TestTransformPostCSS(t *testing.T) { - if !htesting.IsCI() { - t.Skip("Skip long running test when running locally") - } - - c := qt.New(t) - tempDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test") - c.Assert(err, qt.IsNil) - c.Cleanup(clean) - - for _, s := range []string{"never", "always"} { - - repl := strings.NewReplacer( - "https://example.com", - "https://example.com/foo", - "useResourceCacheWhen = 'never'", - fmt.Sprintf("useResourceCacheWhen = '%s'", s), - ) - - files := repl.Replace(postCSSIntegrationTestFiles) - - b := hugolib.NewIntegrationTestBuilder( - hugolib.IntegrationTestConfig{ - T: c, - NeedsOsFS: true, - NeedsNpmInstall: true, - LogLevel: logg.LevelInfo, - WorkingDir: tempDir, - TxtarString: files, - }).Build() - - b.AssertFileContent("public/index.html", ` -Styles RelPermalink: /foo/css/styles.css -Styles Content: Len: 770917| -`) - - if s == "never" { - b.AssertLogContains("Hugo Environment: production") - b.AssertLogContains("Hugo PublishDir: " + filepath.Join(tempDir, "public")) - } - } -} - -// 9880 -func TestTransformPostCSSError(t *testing.T) { - if !htesting.IsCI() { - t.Skip("Skip long running test when running locally") - } - - if runtime.GOOS == "windows" { - // TODO(bep) This has started to fail on Windows with Go 1.19 on GitHub Actions for some mysterious reason. - t.Skip("Skip on Windows") - } - - c := qt.New(t) - - s, err := hugolib.NewIntegrationTestBuilder( - hugolib.IntegrationTestConfig{ - T: c, - NeedsOsFS: true, - NeedsNpmInstall: true, - TxtarString: strings.ReplaceAll(postCSSIntegrationTestFiles, "color: blue;", "@apply foo;"), // Syntax error - }).BuildE() - - s.AssertIsFileError(err) - c.Assert(err.Error(), qt.Contains, "a.css:4:2") -} - -func TestTransformPostCSSNotInstalledError(t *testing.T) { - if !htesting.IsCI() { - t.Skip("Skip long running test when running locally") - } - - c := qt.New(t) - - s, err := hugolib.NewIntegrationTestBuilder( - hugolib.IntegrationTestConfig{ - T: c, - NeedsOsFS: true, - TxtarString: postCSSIntegrationTestFiles, - }).BuildE() - - s.AssertIsFileError(err) - c.Assert(err.Error(), qt.Contains, `binary with name "npx" not found`) -} - -// #9895 -func TestTransformPostCSSImportError(t *testing.T) { - if !htesting.IsCI() { - t.Skip("Skip long running test when running locally") - } - - c := qt.New(t) - - s, err := hugolib.NewIntegrationTestBuilder( - hugolib.IntegrationTestConfig{ - T: c, - NeedsOsFS: true, - NeedsNpmInstall: true, - LogLevel: logg.LevelInfo, - TxtarString: strings.ReplaceAll(postCSSIntegrationTestFiles, `@import "components/all.css";`, `@import "components/doesnotexist.css";`), - }).BuildE() - - s.AssertIsFileError(err) - c.Assert(err.Error(), qt.Contains, "styles.css:4:3") - c.Assert(err.Error(), qt.Contains, filepath.FromSlash(`failed to resolve CSS @import "/css/components/doesnotexist.css"`)) -} - -func TestTransformPostCSSImporSkipInlineImportsNotFound(t *testing.T) { - if !htesting.IsCI() { - t.Skip("Skip long running test when running locally") - } - - c := qt.New(t) - - files := strings.ReplaceAll(postCSSIntegrationTestFiles, `@import "components/all.css";`, `@import "components/doesnotexist.css";`) - files = strings.ReplaceAll(files, `{{ $options := dict "inlineImports" true }}`, `{{ $options := dict "inlineImports" true "skipInlineImportsNotFound" true }}`) - - s := hugolib.NewIntegrationTestBuilder( - hugolib.IntegrationTestConfig{ - T: c, - NeedsOsFS: true, - NeedsNpmInstall: true, - LogLevel: logg.LevelInfo, - TxtarString: files, - }).Build() - - s.AssertFileContent("public/css/styles.css", `@import "components/doesnotexist.css";`) -} - -// Issue 9787 -func TestTransformPostCSSResourceCacheWithPathInBaseURL(t *testing.T) { - if !htesting.IsCI() { - t.Skip("Skip long running test when running locally") - } - - c := qt.New(t) - tempDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test") - c.Assert(err, qt.IsNil) - c.Cleanup(clean) - - for i := 0; i < 2; i++ { - files := postCSSIntegrationTestFiles - - if i == 1 { - files = strings.ReplaceAll(files, "https://example.com", "https://example.com/foo") - files = strings.ReplaceAll(files, "useResourceCacheWhen = 'never'", " useResourceCacheWhen = 'always'") - } - - b := hugolib.NewIntegrationTestBuilder( - hugolib.IntegrationTestConfig{ - T: c, - NeedsOsFS: true, - NeedsNpmInstall: true, - LogLevel: logg.LevelInfo, - TxtarString: files, - WorkingDir: tempDir, - }).Build() - - b.AssertFileContent("public/index.html", ` -Styles Content: Len: 770917 -`) - - } -} diff --git a/resources/resource_transformers/postcss/postcss_test.go b/resources/resource_transformers/postcss/postcss_test.go deleted file mode 100644 index 1edaaaaf5..000000000 --- a/resources/resource_transformers/postcss/postcss_test.go +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright 2020 The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package postcss - -import ( - "regexp" - "strings" - "testing" - - "github.com/gohugoio/hugo/common/loggers" - "github.com/gohugoio/hugo/htesting/hqt" - "github.com/gohugoio/hugo/identity" - - "github.com/gohugoio/hugo/helpers" - - "github.com/spf13/afero" - - qt "github.com/frankban/quicktest" -) - -// Issue 6166 -func TestDecodeOptions(t *testing.T) { - c := qt.New(t) - opts1, err := decodeOptions(map[string]any{ - "no-map": true, - }) - - c.Assert(err, qt.IsNil) - c.Assert(opts1.NoMap, qt.Equals, true) - - opts2, err := decodeOptions(map[string]any{ - "noMap": true, - }) - - c.Assert(err, qt.IsNil) - c.Assert(opts2.NoMap, qt.Equals, true) -} - -func TestShouldImport(t *testing.T) { - c := qt.New(t) - var imp *importResolver - - 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}, - {input: `@import "printstyle.css" print;`, expect: false}, - } { - c.Assert(imp.shouldImport(test.input), qt.Equals, test.expect) - } -} - -func TestImportResolver(t *testing.T) { - c := qt.New(t) - fs := afero.NewMemMapFs() - - writeFile := func(name, content string) { - c.Assert(afero.WriteFile(fs, name, []byte(content), 0o777), qt.IsNil) - } - - writeFile("a.css", `@import "b.css"; -@import "c.css"; -A_STYLE1 -A_STYLE2 -`) - - writeFile("b.css", `B_STYLE`) - writeFile("c.css", "@import \"d.css\"\nC_STYLE") - writeFile("d.css", "@import \"a.css\"\n\nD_STYLE") - writeFile("e.css", "E_STYLE") - - mainStyles := strings.NewReader(`@import "a.css"; -@import "b.css"; -LOCAL_STYLE -@import "c.css"; -@import "e.css";`) - - imp := newImportResolver( - mainStyles, - "styles.css", - Options{}, - fs, loggers.NewDefault(), - identity.NopManager, - ) - - r, err := imp.resolve() - c.Assert(err, qt.IsNil) - rs := helpers.ReaderToString(r) - result := regexp.MustCompile(`\n+`).ReplaceAllString(rs, "\n") - - c.Assert(result, hqt.IsSameString, `B_STYLE -D_STYLE -C_STYLE -A_STYLE1 -A_STYLE2 -LOCAL_STYLE -E_STYLE`) - - dline := imp.linemap[3] - c.Assert(dline, qt.DeepEquals, fileOffset{ - Offset: 1, - Filename: "d.css", - }) -} - -func BenchmarkImportResolver(b *testing.B) { - c := qt.New(b) - fs := afero.NewMemMapFs() - - writeFile := func(name, content string) { - c.Assert(afero.WriteFile(fs, name, []byte(content), 0o777), qt.IsNil) - } - - writeFile("a.css", `@import "b.css"; -@import "c.css"; -A_STYLE1 -A_STYLE2 -`) - - writeFile("b.css", `B_STYLE`) - writeFile("c.css", "@import \"d.css\"\nC_STYLE"+strings.Repeat("\nSTYLE", 12)) - writeFile("d.css", "@import \"a.css\"\n\nD_STYLE"+strings.Repeat("\nSTYLE", 55)) - writeFile("e.css", "E_STYLE") - - mainStyles := `@import "a.css"; -@import "b.css"; -LOCAL_STYLE -@import "c.css"; -@import "e.css"; -@import "missing.css";` - - logger := loggers.NewDefault() - - for i := 0; i < b.N; i++ { - b.StopTimer() - imp := newImportResolver( - strings.NewReader(mainStyles), - "styles.css", - Options{}, - fs, logger, - identity.NopManager, - ) - - b.StartTimer() - - _, err := imp.resolve() - if err != nil { - b.Fatal(err) - } - - } -} diff --git a/resources/transform.go b/resources/transform.go index 9d8b5e915..b71c026b9 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -486,16 +486,20 @@ func (r *resourceAdapter) transform(key string, publish, setContent bool) (*reso if herrors.IsFeatureNotAvailableError(err) { var errMsg string - if tr.Key().Name == "postcss" { + switch strings.ToLower(tr.Key().Name) { + case "postcss": // This transformation is not available in this // Most likely because PostCSS is not installed. - errMsg = ". Check your PostCSS installation; install with \"npm install postcss-cli\". See https://gohugo.io/hugo-pipes/postcss/" - } else if tr.Key().Name == "tocss" { + errMsg = ". You need to install PostCSS. See https://gohugo.io/functions/css/postcss/" + case "tailwindcss": + errMsg = ". You need to install TailwindCSS CLI. See https://gohugo.io/functions/css/tailwindcss/" + case "tocss": errMsg = ". Check your Hugo installation; you need the extended version to build SCSS/SASS with transpiler set to 'libsass'." - } else if tr.Key().Name == "tocss-dart" { - errMsg = ". You need to install Dart Sass, see https://gohugo.io/functions/resources/tocss/#dart-sass" - } else if tr.Key().Name == "babel" { - errMsg = ". You need to install Babel, see https://gohugo.io/hugo-pipes/babel/" + case "tocss-dart": + errMsg = ". You need to install Dart Sass, see https://gohugo.io//functions/css/sass/#dart-sass" + case "babel": + errMsg = ". You need to install Babel, see https://gohugo.io/functions/js/babel/" + } return fmt.Errorf(msg+errMsg+": %w", err) diff --git a/tpl/css/css.go b/tpl/css/css.go index 145cb3aad..48a526c17 100644 --- a/tpl/css/css.go +++ b/tpl/css/css.go @@ -13,7 +13,7 @@ import ( "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/resources/resource_transformers/babel" - "github.com/gohugoio/hugo/resources/resource_transformers/postcss" + "github.com/gohugoio/hugo/resources/resource_transformers/cssjs" "github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass" "github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss" "github.com/gohugoio/hugo/tpl/internal" @@ -27,7 +27,8 @@ const name = "css" type Namespace struct { d *deps.Deps scssClientLibSass *scss.Client - postcssClient *postcss.Client + postcssClient *cssjs.PostCSSClient + tailwindcssClient *cssjs.TailwindCSSClient babelClient *babel.Client // The Dart Client requires a os/exec process, so only @@ -63,7 +64,21 @@ func (ns *Namespace) PostCSS(args ...any) (resource.Resource, error) { return ns.postcssClient.Process(r, m) } -// Sass processes the given Resource with Sass. +// TailwindCSS processes the given Resource with tailwindcss. +func (ns *Namespace) TailwindCSS(args ...any) (resource.Resource, error) { + if len(args) > 2 { + return nil, errors.New("must not provide more arguments than resource object and options") + } + + r, m, err := resourcehelpers.ResolveArgs(args) + if err != nil { + return nil, err + } + + return ns.tailwindcssClient.Process(r, m) +} + +// Sass processes the given Resource with SASS. func (ns *Namespace) Sass(args ...any) (resource.Resource, error) { if len(args) > 2 { return nil, errors.New("must not provide more arguments than resource object and options") @@ -144,7 +159,8 @@ func init() { ctx := &Namespace{ d: d, scssClientLibSass: scssClient, - postcssClient: postcss.New(d.ResourceSpec), + postcssClient: cssjs.NewPostCSSClient(d.ResourceSpec), + tailwindcssClient: cssjs.NewTailwindCSSClient(d.ResourceSpec), babelClient: babel.New(d.ResourceSpec), } -- cgit v1.2.3