URI: 
       Add css.TailwindCSS - hugo - [fork] hugo port for 9front
  HTML git clone git@git.drkhsh.at/hugo.git
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
   DIR README
   DIR LICENSE
       ---
   DIR commit e1317dd32281dc5ce670e34165dc7780c8f5892b
   DIR parent eddcd2bac6bfd3cc0ac1a3b38bf8c4ae452ea23b
  HTML Author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
       Date:   Sun, 23 Jun 2024 12:49:10 +0200
       
       Add css.TailwindCSS
       
       Closes #12618
       Closes #12620
       
       Diffstat:
         M commands/hugobuilder.go             |       2 +-
         M common/hexec/exec.go                |      90 ++++++++++++++++++++++++-------
         M config/allconfig/load.go            |       2 +-
         M config/security/securityConfig.go   |       1 +
         M config/security/securityConfig_tes… |       2 +-
         M deps/deps.go                        |       2 +-
         M hugolib/integrationtest_builder.go  |       2 +-
         M hugolib/testhelpers_test.go         |       2 +-
         M markup/asciidocext/convert_test.go  |       2 +-
         M markup/pandoc/convert_test.go       |       2 +-
         M markup/rst/convert_test.go          |       2 +-
         M modules/client_test.go              |       2 +-
         A resources/resource_transformers/cs… |     247 +++++++++++++++++++++++++++++++
         A resources/resource_transformers/cs… |     179 +++++++++++++++++++++++++++++++
         A resources/resource_transformers/cs… |     235 +++++++++++++++++++++++++++++++
         A resources/resource_transformers/cs… |     265 +++++++++++++++++++++++++++++++
         A resources/resource_transformers/cs… |     167 +++++++++++++++++++++++++++++++
         A resources/resource_transformers/cs… |      72 +++++++++++++++++++++++++++++++
         D resources/resource_transformers/po… |     443 -------------------------------
         D resources/resource_transformers/po… |     265 -------------------------------
         D resources/resource_transformers/po… |     169 -------------------------------
         M resources/transform.go              |      18 +++++++++++-------
         M tpl/css/css.go                      |      24 ++++++++++++++++++++----
       
       23 files changed, 1277 insertions(+), 918 deletions(-)
       ---
   DIR diff --git 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 {
   DIR diff --git 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 <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",
       +                        }
                        }
                }
        
   DIR diff --git a/config/allconfig/load.go b/config/allconfig/load.go
       @@ -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 {
   DIR diff --git a/config/security/securityConfig.go b/config/security/securityConfig.go
       @@ -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.
   DIR diff --git a/config/security/securityConfig_test.go b/config/security/securityConfig_test.go
       @@ -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 = ['.*']",
                )
        }
        
   DIR diff --git a/deps/deps.go b/deps/deps.go
       @@ -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 {
   DIR diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go
       @@ -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)
   DIR diff --git a/hugolib/testhelpers_test.go b/hugolib/testhelpers_test.go
       @@ -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
   DIR diff --git a/markup/asciidocext/convert_test.go b/markup/asciidocext/convert_test.go
       @@ -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)
   DIR diff --git a/markup/pandoc/convert_test.go b/markup/pandoc/convert_test.go
       @@ -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)
   DIR diff --git a/markup/rst/convert_test.go b/markup/rst/convert_test.go
       @@ -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{})
   DIR diff --git a/modules/client_test.go b/modules/client_test.go
       @@ -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)
   DIR diff --git a/resources/resource_transformers/cssjs/inline_imports.go b/resources/resource_transformers/cssjs/inline_imports.go
       @@ -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)
       +}
   DIR diff --git a/resources/resource_transformers/cssjs/inline_imports_test.go 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)
       +                }
       +
       +        }
       +}
   DIR diff --git a/resources/resource_transformers/cssjs/postcss.go 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
       +}
   DIR diff --git a/resources/resource_transformers/cssjs/postcss_integration_test.go 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
       +`)
       +
       +        }
       +}
   DIR diff --git a/resources/resource_transformers/cssjs/tailwindcss.go 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
       +}
   DIR diff --git a/resources/resource_transformers/cssjs/tailwindcss_integration_test.go 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")
       +}
   DIR diff --git a/resources/resource_transformers/postcss/postcss.go b/resources/resource_transformers/postcss/postcss.go
       @@ -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)
       -}
   DIR diff --git a/resources/resource_transformers/postcss/postcss_integration_test.go b/resources/resource_transformers/postcss/postcss_integration_test.go
       @@ -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
       -`)
       -
       -        }
       -}
   DIR diff --git a/resources/resource_transformers/postcss/postcss_test.go b/resources/resource_transformers/postcss/postcss_test.go
       @@ -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)
       -                }
       -
       -        }
       -}
   DIR diff --git 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)
   DIR diff --git 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),
                        }