]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Add css.TailwindCSS
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Sun, 23 Jun 2024 10:49:10 +0000 (12:49 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Tue, 25 Jun 2024 13:48:02 +0000 (15:48 +0200)
Closes #12618
Closes #12620

23 files changed:
commands/hugobuilder.go
common/hexec/exec.go
config/allconfig/load.go
config/security/securityConfig.go
config/security/securityConfig_test.go
deps/deps.go
hugolib/integrationtest_builder.go
hugolib/testhelpers_test.go
markup/asciidocext/convert_test.go
markup/pandoc/convert_test.go
markup/rst/convert_test.go
modules/client_test.go
resources/resource_transformers/cssjs/inline_imports.go [new file with mode: 0644]
resources/resource_transformers/cssjs/inline_imports_test.go [new file with mode: 0644]
resources/resource_transformers/cssjs/postcss.go [new file with mode: 0644]
resources/resource_transformers/cssjs/postcss_integration_test.go [new file with mode: 0644]
resources/resource_transformers/cssjs/tailwindcss.go [new file with mode: 0644]
resources/resource_transformers/cssjs/tailwindcss_integration_test.go [new file with mode: 0644]
resources/resource_transformers/postcss/postcss.go [deleted file]
resources/resource_transformers/postcss/postcss_integration_test.go [deleted file]
resources/resource_transformers/postcss/postcss_test.go [deleted file]
resources/transform.go
tpl/css/css.go

index 84608ca374c744d96e6a8e1b9b73e50ad25bc5ab..1a4a6126495824a683344c15755178f720f8c14c 100644 (file)
@@ -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 {
index 49291354dd0fb3a651a3ca0675ef84f771138ce3..4f23d20f505cb1774aef5c43dc146e354fd750a8 100644 (file)
@@ -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 <name> <args>.
+// 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 <name> <args.
 func (e *Exec) npx(name string, arg ...any) (Runner, error) {
-       arg = append(arg[:0], append([]any{"--no-install", name}, arg[0:]...)...)
-       return e.New("npx", arg...)
+       arg = append(arg[:0], append([]any{npxNoInstall, name}, arg[0:]...)...)
+       return e.New(npxBinary, arg...)
 }
 
 // Sec returns the security policies this Exec is configured with.
@@ -165,11 +200,12 @@ func (e *Exec) Sec() security.Config {
 }
 
 type NotFoundError struct {
-       name string
+       name   string
+       method string
 }
 
 func (e *NotFoundError) Error() string {
-       return fmt.Sprintf("binary with name %q not found", e.name)
+       return fmt.Sprintf("binary with name %q not found %s", e.name, e.method)
 }
 
 // Runner wraps a *os.Cmd.
@@ -192,8 +228,14 @@ func (c *cmdWrapper) Run() error {
        if err == nil {
                return nil
        }
+       name := c.name
+       method := "in PATH"
+       if name == npxBinary {
+               name = c.c.Args[2]
+               method = "using npx"
+       }
        if notFoundRe.MatchString(c.outerr.String()) {
-               return &NotFoundError{name: c.name}
+               return &NotFoundError{name: name, method: method}
        }
        return fmt.Errorf("failed to execute binary %q with args %v: %s", c.name, c.c.Args[1:], c.outerr.String())
 }
@@ -209,8 +251,9 @@ type commandeer struct {
        dir    string
        ctx    context.Context
 
-       name string
-       env  []string
+       name               string
+       fullyQualifiedName string
+       env                []string
 }
 
 func (c *commandeer) command(arg ...any) (*cmdWrapper, error) {
@@ -230,10 +273,17 @@ func (c *commandeer) command(arg ...any) (*cmdWrapper, error) {
                }
        }
 
-       bin, err := safeexec.LookPath(c.name)
-       if err != nil {
-               return nil, &NotFoundError{
-                       name: c.name,
+       var bin string
+       if c.fullyQualifiedName != "" {
+               bin = c.fullyQualifiedName
+       } else {
+               var err error
+               bin, err = safeexec.LookPath(c.name)
+               if err != nil {
+                       return nil, &NotFoundError{
+                               name:   c.name,
+                               method: "in PATH",
+                       }
                }
        }
 
index 117b8e89c95acf886b2002d40d00dbca94b57a3b..84419cb2ef5bdf3395c0316804e8d2fc457cbe4e 100644 (file)
@@ -467,7 +467,7 @@ func (l *configLoader) loadModules(configs *Configs) (modules.ModulesConfig, *mo
                ignoreVendor, _ = hglob.GetGlob(hglob.NormalizePath(s))
        }
 
-       ex := hexec.New(conf.Security)
+       ex := hexec.New(conf.Security, workingDir)
 
        hook := func(m *modules.ModulesConfig) error {
                for _, tc := range m.AllModules {
index be9e901f1ee37408e95e92419ac4476d7215be60..46a5d0a23082fa8d0e6f1457bde1e6a2e65a75cb 100644 (file)
@@ -39,6 +39,7 @@ var DefaultConfig = Config{
                        "^go$",                       // for Go Modules
                        "^npx$",                      // used by all Node tools (Babel, PostCSS).
                        "^postcss$",
+                       "^tailwindcss$",
                ),
                // These have been tested to work with Hugo's external programs
                // on Windows, Linux and MacOS.
index 57e615a482495e213a5129b26c9adca8db5ecd70..87a2430125977f2dac4fed9508362f33212ad308 100644 (file)
@@ -135,7 +135,7 @@ func TestToTOML(t *testing.T) {
        got := DefaultConfig.ToTOML()
 
        c.Assert(got, qt.Equals,
-               "[security]\n  enableInlineShortcodes = false\n\n  [security.exec]\n    allow = ['^(dart-)?sass(-embedded)?$', '^go$', '^npx$', '^postcss$']\n    osEnv = ['(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE)$']\n\n  [security.funcs]\n    getenv = ['^HUGO_', '^CI$']\n\n  [security.http]\n    methods = ['(?i)GET|POST']\n    urls = ['.*']",
+               "[security]\n  enableInlineShortcodes = false\n\n  [security.exec]\n    allow = ['^(dart-)?sass(-embedded)?$', '^go$', '^npx$', '^postcss$', '^tailwindcss$']\n    osEnv = ['(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE)$']\n\n  [security.funcs]\n    getenv = ['^HUGO_', '^CI$']\n\n  [security.http]\n    methods = ['(?i)GET|POST']\n    urls = ['.*']",
        )
 }
 
index 4805af1aafe2dd24386304b19cf1065f337c64d8..0d0f283c26a1f7ad3605aa67b33db9c2dbeae13b 100644 (file)
@@ -163,7 +163,7 @@ func (d *Deps) Init() error {
        }
 
        if d.ExecHelper == nil {
-               d.ExecHelper = hexec.New(d.Conf.GetConfigSection("security").(security.Config))
+               d.ExecHelper = hexec.New(d.Conf.GetConfigSection("security").(security.Config), d.Conf.WorkingDir())
        }
 
        if d.MemCache == nil {
index 758fc4ec93dcb3513247f9aeb5985ff0d3d046ab..551b807dba0d4018b0edda05644d5b5ae2494180 100644 (file)
@@ -659,7 +659,7 @@ func (s *IntegrationTestBuilder) initBuilder() error {
                        sc := security.DefaultConfig
                        sc.Exec.Allow, err = security.NewWhitelist("npm")
                        s.Assert(err, qt.IsNil)
-                       ex := hexec.New(sc)
+                       ex := hexec.New(sc, s.Cfg.WorkingDir)
                        command, err := ex.New("npm", "install")
                        s.Assert(err, qt.IsNil)
                        s.Assert(command.Run(), qt.IsNil)
index dab6936235cb7e1219346520ab59412beba27579..746f4c26ed1c8df05018468abfe180e68b8b511a 100644 (file)
@@ -834,7 +834,7 @@ func (s *sitesBuilder) NpmInstall() hexec.Runner {
        var err error
        sc.Exec.Allow, err = security.NewWhitelist("npm")
        s.Assert(err, qt.IsNil)
-       ex := hexec.New(sc)
+       ex := hexec.New(sc, s.workingDir)
        command, err := ex.New("npm", "install")
        s.Assert(err, qt.IsNil)
        return command
index 18c38a62189d2c45365b56fde2eae8942a4ecef0..b3f63b4d8a482409d41c8d7ad3f2863dc1a85f90 100644 (file)
@@ -313,7 +313,7 @@ allow = ['asciidoctor']
                converter.ProviderConfig{
                        Logger: loggers.NewDefault(),
                        Conf:   conf,
-                       Exec:   hexec.New(securityConfig),
+                       Exec:   hexec.New(securityConfig, ""),
                },
        )
        c.Assert(err, qt.IsNil)
index dec30c4102db7384d4edfb432642b5e00df1cb35..8beedc1150cf154644f425d996227137f28fc4aa 100644 (file)
@@ -34,7 +34,7 @@ func TestConvert(t *testing.T) {
        var err error
        sc.Exec.Allow, err = security.NewWhitelist("pandoc")
        c.Assert(err, qt.IsNil)
-       p, err := Provider.New(converter.ProviderConfig{Exec: hexec.New(sc), Logger: loggers.NewDefault()})
+       p, err := Provider.New(converter.ProviderConfig{Exec: hexec.New(sc, ""), Logger: loggers.NewDefault()})
        c.Assert(err, qt.IsNil)
        conv, err := p.New(converter.DocumentContext{})
        c.Assert(err, qt.IsNil)
index 1897e650f2649addfdea367285fc0e76388681ad..182858263b8317827dfa8c0b806c819aae0f9922 100644 (file)
@@ -36,7 +36,7 @@ func TestConvert(t *testing.T) {
        p, err := Provider.New(
                converter.ProviderConfig{
                        Logger: loggers.NewDefault(),
-                       Exec:   hexec.New(sc),
+                       Exec:   hexec.New(sc, ""),
                })
        c.Assert(err, qt.IsNil)
        conv, err := p.New(converter.DocumentContext{})
index d727c4586e9b44f3a4b0b4b109d2d4e97fdebcb5..0ee7e0dbc40cb10b4ab5c099913cb95571f0d518 100644 (file)
@@ -61,7 +61,7 @@ github.com/gohugoio/hugoTestModules1_darwin/modh2_2@v1.4.0 github.com/gohugoio/h
                        WorkingDir: workingDir,
                        ThemesDir:  themesDir,
                        PublishDir: publishDir,
-                       Exec:       hexec.New(security.DefaultConfig),
+                       Exec:       hexec.New(security.DefaultConfig, ""),
                }
 
                withConfig(&ccfg)
diff --git a/resources/resource_transformers/cssjs/inline_imports.go b/resources/resource_transformers/cssjs/inline_imports.go
new file mode 100644 (file)
index 0000000..98e3292
--- /dev/null
@@ -0,0 +1,247 @@
+// 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 (
+       "crypto/sha256"
+       "encoding/hex"
+       "errors"
+       "fmt"
+       "io"
+       "path"
+       "path/filepath"
+       "regexp"
+       "strconv"
+       "strings"
+
+       "github.com/gohugoio/hugo/common/herrors"
+       "github.com/gohugoio/hugo/common/loggers"
+       "github.com/gohugoio/hugo/common/text"
+       "github.com/gohugoio/hugo/hugofs"
+       "github.com/gohugoio/hugo/identity"
+       "github.com/spf13/afero"
+)
+
+const importIdentifier = "@import"
+
+var (
+       cssSyntaxErrorRe = regexp.MustCompile(`> (\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 (file)
index 0000000..9bcb7f9
--- /dev/null
@@ -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 (file)
index 0000000..1a9e011
--- /dev/null
@@ -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 (file)
index 0000000..e8f5232
--- /dev/null
@@ -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 (file)
index 0000000..f3195c5
--- /dev/null
@@ -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 (file)
index 0000000..ddd78b6
--- /dev/null
@@ -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 (file)
index 9015e12..0000000
+++ /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 (file)
index 957e694..0000000
+++ /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 (file)
index 1edaaaa..0000000
+++ /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)
-               }
-
-       }
-}
index 9d8b5e915b8458f5ec35ad3c5d6a676161cef9b3..b71c026b983e9cdced8afc9d02717c88647bb705 100644 (file)
@@ -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)
index 145cb3aadac0aa21c3aa1f4e981434b76fd71f31..48a526c1722d25fce7aa4bbaa8f7d373a2608f29 100644 (file)
@@ -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),
                }