Repository: gohugoio/hugo (language: go)

Issue:
Improve npx speed
Resolving binaries with `npx` seem to have about 200ms overhead, which isn't great ...

See https://github.com/tailwindlabs/tailwindcss/issues/13886


Discussion / hints:

Fix patch:
diff --git a/commands/hugobuilder.go b/commands/hugobuilder.go
index 84608ca374c..1a4a6126495 100644
--- a/commands/hugobuilder.go
+++ b/commands/hugobuilder.go
@@ -920,7 +920,7 @@ func (c *hugoBuilder) handleEvents(watcher *watcher.Batcher,
 					if len(otherChanges) > 0 {
 						livereload.ForceRefresh()
 						// Allow some time for the live reload script to get reconnected.
-						time.Sleep(100 * time.Millisecond)
+						time.Sleep(200 * time.Millisecond)
 					}
 
 					for _, ev := range cssChanges {
diff --git a/common/hexec/exec.go b/common/hexec/exec.go
index 49291354dd0..4f23d20f505 100644
--- a/common/hexec/exec.go
+++ b/common/hexec/exec.go
@@ -21,8 +21,10 @@ import (
 	"io"
 	"os"
 	"os/exec"
+	"path/filepath"
 	"regexp"
 	"strings"
+	"sync"
 
 	"github.com/cli/safeexec"
 	"github.com/gohugoio/hugo/config"
@@ -84,7 +86,7 @@ var WithEnviron = func(env []string) func(c *commandeer) {
 }
 
 // New creates a new Exec using the provided security config.
-func New(cfg security.Config) *Exec {
+func New(cfg security.Config, workingDir string) *Exec {
 	var baseEnviron []string
 	for _, v := range os.Environ() {
 		k, _ := config.SplitEnvVar(v)
@@ -95,6 +97,7 @@ func New(cfg security.Config) *Exec {
 
 	return &Exec{
 		sc:          cfg,
+		workingDir:  workingDir,
 		baseEnviron: baseEnviron,
 	}
 }
@@ -119,15 +122,23 @@ func SafeCommand(name string, arg ...string) (*exec.Cmd, error) {
 
 // Exec enforces a security policy for commands run via os/exec.
 type Exec struct {
-	sc security.Config
+	sc         security.Config
+	workingDir string
 
 	// os.Environ filtered by the Exec.OsEnviron whitelist filter.
 	baseEnviron []string
+
+	npxInit      sync.Once
+	npxAvailable bool
+}
+
+func (e *Exec) New(name string, arg ...any) (Runner, error) {
+	return e.new(name, "", arg...)
 }
 
 // New will fail if name is not allowed according to the configured security policy.
 // Else a configured Runner will be returned ready to be Run.
-func (e *Exec) New(name string, arg ...any) (Runner, error) {
+func (e *Exec) new(name string, fullyQualifiedName string, arg ...any) (Runner, error) {
 	if err := e.sc.CheckAllowedExec(name); err != nil {
 		return nil, err
 	}
@@ -136,27 +147,51 @@ func (e *Exec) New(name string, arg ...any) (Runner, error) {
 	copy(env, e.baseEnviron)
 
 	cm := &commandeer{
-		name: name,
-		env:  env,
+		name:               name,
+		fullyQualifiedName: fullyQualifiedName,
+		env:                env,
 	}
 
 	return cm.command(arg...)
 }
 
-// Npx will try to run npx, and if that fails, it will
-// try to run the binary directly.
+// Npx will in order:
+// 1. Try fo find the binary in the WORKINGDIR/node_modules/.bin directory.
+// 2. If not found, and npx is available, run npx --no-install <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",
+			}
 		}
 	}
 
diff --git a/config/allconfig/load.go b/config/allconfig/load.go
index 117b8e89c95..84419cb2ef5 100644
--- 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 {
diff --git a/config/security/securityConfig.go b/config/security/securityConfig.go
index be9e901f1ee..46a5d0a2308 100644
--- 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.
diff --git a/deps/deps.go b/deps/deps.go
index 4805af1aafe..0d0f283c26a 100644
--- 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 {
diff --git a/resources/resource_transformers/cssjs/inline_imports.go b/resources/resource_transformers/cssjs/inline_imports.go
new file mode 100644
index 00000000000..98e3292cd91
--- /dev/null
+++ 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)
+}
diff --git a/resources/resource_transformers/postcss/postcss.go b/resources/resource_transformers/cssjs/postcss.go
similarity index 50%
rename from resources/resource_transformers/postcss/postcss.go
rename to resources/resource_transformers/cssjs/postcss.go
index 9015e120d1f..1a9e01142e1 100644
--- a/resources/resource_transformers/postcss/postcss.go
+++ b/resources/resource_transformers/cssjs/postcss.go
@@ -11,32 +11,23 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package postcss
+// Package cssjs provides resource transformations backed by some popular JS based frameworks.
+package cssjs
 
 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"
@@ -46,19 +37,12 @@ import (
 	"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}
+// NewPostCSSClient creates a new PostCSSClient with the given specification.
+func NewPostCSSClient(rs *resources.Spec) *PostCSSClient {
+	return &PostCSSClient{rs: rs}
 }
 
-func decodeOptions(m map[string]any) (opts Options, err error) {
+func decodePostCSSOptions(m map[string]any) (opts PostCSSOptions, err error) {
 	if m == nil {
 		return
 	}
@@ -74,23 +58,18 @@ func decodeOptions(m map[string]any) (opts Options, err error) {
 	return
 }
 
-// Client is the client used to do PostCSS transformations.
-type Client struct {
+// 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 *Client) Process(res resources.ResourceTransformer, options map[string]any) (resource.Resource, error) {
+func (c *PostCSSClient) 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
-
+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
@@ -104,6 +83,16 @@ type Options struct {
 	// 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
@@ -112,7 +101,7 @@ type Options struct {
 	Syntax      string // Custom postcss syntax
 }
 
-func (opts Options) toArgs() []string {
+func (opts PostCSSOptions) toArgs() []string {
 	var args []string
 	if opts.NoMap {
 		args = append(args, "--no-map")
@@ -156,13 +145,9 @@ func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationC
 
 	var configFile string
 
-	var options Options
-	if t.optionsm != nil {
-		var err error
-		options, err = decodeOptions(t.optionsm)
-		if err != nil {
-			return err
-		}
+	options, err := decodePostCSSOptions(t.optionsm)
+	if err != nil {
+		return err
 	}
 
 	if options.Config != "" {
@@ -219,11 +204,11 @@ func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationC
 	imp := newImportResolver(
 		ctx.From,
 		ctx.InPath,
-		options,
+		options.InlineImports,
 		t.rs.Assets.Fs, t.rs.Logger, ctx.DependencyManager,
 	)
 
-	if options.InlineImports {
+	if options.InlineImports.InlineImports {
 		var err error
 		src, err = imp.resolve()
 		if err != nil {
@@ -248,196 +233,3 @@ func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationC
 
 	return nil
 }
-
-type fileOffset struct {
-	Filename string
-	Offset   int
-}
-
-type importResolver struct {
-	r      io.Reader
-	inPath string
-	opts   Options
-
-	contentSeen       map[string]bool
-	dependencyManager identity.Manager
-	linemap           map[int]fileOffset
-	fs                afero.Fs
-	logger            loggers.Logger
-}
-
-func newImportResolver(r io.Reader, inPath string, opts Options, fs afero.Fs, logger loggers.Logger, dependencyManager identity.Manager) *importResolver {
-	return &importResolver{
-		r:                 r,
-		dependencyManager: dependencyManager,
-		inPath:            inPath,
-		fs:                fs, logger: logger,
-		linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool),
-		opts: opts,
-	}
-}
-
-func (imp *importResolver) contentHash(filename string) ([]byte, string) {
-	b, err := afero.ReadFile(imp.fs, filename)
-	if err != nil {
-		return nil, ""
-	}
-	h := sha256.New()
-	h.Write(b)
-	return b, hex.EncodeToString(h.Sum(nil))
-}
-
-func (imp *importResolver) importRecursive(
-	lineNum int,
-	content string,
-	inPath string,
-) (int, string, error) {
-	basePath := path.Dir(inPath)
-
-	var replacements []string
-	lines := strings.Split(content, "\n")
-
-	trackLine := func(i, offset int, line string) {
-		// TODO(bep) this is not very efficient.
-		imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset}
-	}
-
-	i := 0
-	for offset, line := range lines {
-		i++
-		lineTrimmed := strings.TrimSpace(line)
-		column := strings.Index(line, lineTrimmed)
-		line = lineTrimmed
-
-		if !imp.shouldImport(line) {
-			trackLine(i, offset, line)
-		} else {
-			path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';")
-			filename := filepath.Join(basePath, path)
-			imp.dependencyManager.AddIdentity(identity.CleanStringIdentity(filename))
-			importContent, hash := imp.contentHash(filename)
-
-			if importContent == nil {
-				if imp.opts.SkipInlineImportsNotFound {
-					trackLine(i, offset, line)
-					continue
-				}
-				pos := text.Position{
-					Filename:     inPath,
-					LineNumber:   offset + 1,
-					ColumnNumber: column + 1,
-				}
-				return 0, "", herrors.NewFileErrorFromFileInPos(fmt.Errorf("failed to resolve CSS @import \"%s\"", filename), pos, imp.fs, nil)
-			}
-
-			i--
-
-			if imp.contentSeen[hash] {
-				i++
-				// Just replace the line with an empty string.
-				replacements = append(replacements, []string{line, ""}...)
-				trackLine(i, offset, "IMPORT")
-				continue
-			}
-
-			imp.contentSeen[hash] = true
-
-			// Handle recursive imports.
-			l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename))
-			if err != nil {
-				return 0, "", err
-			}
-
-			trackLine(i, offset, line)
-
-			i += l
-
-			importContent = []byte(nested)
-
-			replacements = append(replacements, []string{line, string(importContent)}...)
-		}
-	}
-
-	if len(replacements) > 0 {
-		repl := strings.NewReplacer(replacements...)
-		content = repl.Replace(content)
-	}
-
-	return i, content, nil
-}
-
-func (imp *importResolver) resolve() (io.Reader, error) {
-	content, err := io.ReadAll(imp.r)
-	if err != nil {
-		return nil, err
-	}
-
-	contents := string(content)
-
-	_, newContent, err := imp.importRecursive(0, contents, imp.inPath)
-	if err != nil {
-		return nil, err
-	}
-
-	return strings.NewReader(newContent), nil
-}
-
-// See https://www.w3schools.com/cssref/pr_import_rule.asp
-// We currently only support simple file imports, no urls, no media queries.
-// So this is OK:
-//
-//	@import "navigation.css";
-//
-// This is not:
-//
-//	@import url("navigation.css");
-//	@import "mobstyle.css" screen and (max-width: 768px);
-func (imp *importResolver) shouldImport(s string) bool {
-	if !strings.HasPrefix(s, importIdentifier) {
-		return false
-	}
-	if strings.Contains(s, "url(") {
-		return false
-	}
-
-	return shouldImportRe.MatchString(s)
-}
-
-func (imp *importResolver) toFileError(output string) error {
-	inErr := errors.New(output)
-
-	match := cssSyntaxErrorRe.FindStringSubmatch(output)
-	if match == nil {
-		return inErr
-	}
-
-	lineNum, err := strconv.Atoi(match[1])
-	if err != nil {
-		return inErr
-	}
-
-	file, ok := imp.linemap[lineNum]
-	if !ok {
-		return inErr
-	}
-
-	fi, err := imp.fs.Stat(file.Filename)
-	if err != nil {
-		return inErr
-	}
-
-	meta := fi.(hugofs.FileMetaInfo).Meta()
-	realFilename := meta.Filename
-	f, err := meta.Open()
-	if err != nil {
-		return inErr
-	}
-	defer f.Close()
-
-	ferr := herrors.NewFileErrorFromName(inErr, realFilename)
-	pos := ferr.Position()
-	pos.LineNumber = file.Offset + 1
-	return ferr.UpdatePosition(pos).UpdateContent(f, nil)
-
-	// return herrors.NewFileErrorFromFile(inErr, file.Filename, realFilename, hugofs.Os, herrors.SimpleLineMatcher)
-}
diff --git a/resources/resource_transformers/cssjs/tailwindcss.go b/resources/resource_transformers/cssjs/tailwindcss.go
new file mode 100644
index 00000000000..f3195c5960a
--- /dev/null
+++ b/resources/resource_transformers/cssjs/tailwindcss.go
@@ -0,0 +1,167 @@
+// Copyright 2024 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cssjs
+
+import (
+	"bytes"
+	"io"
+	"regexp"
+	"strings"
+
+	"github.com/gohugoio/hugo/common/herrors"
+	"github.com/gohugoio/hugo/common/hexec"
+	"github.com/gohugoio/hugo/common/hugo"
+	"github.com/gohugoio/hugo/common/loggers"
+	"github.com/gohugoio/hugo/resources"
+	"github.com/gohugoio/hugo/resources/internal"
+	"github.com/gohugoio/hugo/resources/resource"
+	"github.com/mitchellh/mapstructure"
+)
+
+var (
+	tailwindcssImportRe   = regexp.MustCompile(`^tailwindcss/?`)
+	tailwindImportExclude = func(s string) bool {
+		return tailwindcssImportRe.MatchString(s) && !strings.Contains(s, ".")
+	}
+)
+
+// NewTailwindCSSClient creates a new TailwindCSSClient with the given specification.
+func NewTailwindCSSClient(rs *resources.Spec) *TailwindCSSClient {
+	return &TailwindCSSClient{rs: rs}
+}
+
+// Client is the client used to do TailwindCSS transformations.
+type TailwindCSSClient struct {
+	rs *resources.Spec
+}
+
+// Process transforms the given Resource with the TailwindCSS processor.
+func (c *TailwindCSSClient) Process(res resources.ResourceTransformer, options map[string]any) (resource.Resource, error) {
+	return res.Transform(&tailwindcssTransformation{rs: c.rs, optionsm: options})
+}
+
+type tailwindcssTransformation struct {
+	optionsm map[string]any
+	rs       *resources.Spec
+}
+
+func (t *tailwindcssTransformation) Key() internal.ResourceTransformationKey {
+	return internal.NewResourceTransformationKey("tailwindcss", t.optionsm)
+}
+
+type TailwindCSSOptions struct {
+	Minify        bool // Optimize and minify the output
+	Optimize      bool //  Optimize the output without minifying
+	InlineImports `mapstructure:",squash"`
+}
+
+func (opts TailwindCSSOptions) toArgs() []any {
+	var args []any
+	if opts.Minify {
+		args = append(args, "--minify")
+	}
+	if opts.Optimize {
+		args = append(args, "--optimize")
+	}
+	return args
+}
+
+func (t *tailwindcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
+	const binaryName = "tailwindcss"
+
+	options, err := decodeTailwindCSSOptions(t.optionsm)
+	if err != nil {
+		return err
+	}
+
+	infol := t.rs.Logger.InfoCommand(binaryName)
+	infow := loggers.LevelLoggerToWriter(infol)
+
+	ex := t.rs.ExecHelper
+
+	workingDir := t.rs.Cfg.BaseConfig().WorkingDir
+
+	var cmdArgs []any = []any{
+		"--input=-", // Read from stdin.
+		"--cwd", workingDir,
+	}
+
+	cmdArgs = append(cmdArgs, options.toArgs()...)
+
+	// TODO1
+	//   npm i tailwindcss @tailwindcss/cli
+	// npm i tailwindcss@next @tailwindcss/cli@next
+	// npx tailwindcss -h
+
+	var errBuf bytes.Buffer
+
+	stderr := io.MultiWriter(infow, &errBuf)
+	cmdArgs = append(cmdArgs, hexec.WithStderr(stderr))
+	cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To))
+	cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(workingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs)))
+
+	cmd, err := ex.Npx(binaryName, cmdArgs...)
+	if err != nil {
+		if hexec.IsNotFound(err) {
+			// This may be on a CI server etc. Will fall back to pre-built assets.
+			return &herrors.FeatureNotAvailableError{Cause: err}
+		}
+		return err
+	}
+
+	stdin, err := cmd.StdinPipe()
+	if err != nil {
+		return err
+	}
+
+	src := ctx.From
+
+	imp := newImportResolver(
+		ctx.From,
+		ctx.InPath,
+		options.InlineImports,
+		t.rs.Assets.Fs, t.rs.Logger, ctx.DependencyManager,
+	)
+
+	// TODO1 option {
+	src, err = imp.resolve()
+	if err != nil {
+		return err
+	}
+
+	go func() {
+		defer stdin.Close()
+		io.Copy(stdin, src)
+	}()
+
+	err = cmd.Run()
+	if err != nil {
+		if hexec.IsNotFound(err) {
+			return &herrors.FeatureNotAvailableError{
+				Cause: err,
+			}
+		}
+		return imp.toFileError(errBuf.String())
+	}
+
+	return nil
+}
+
+func decodeTailwindCSSOptions(m map[string]any) (opts TailwindCSSOptions, err error) {
+	if m == nil {
+		return
+	}
+	err = mapstructure.WeakDecode(m, &opts)
+	return
+}
diff --git a/resources/transform.go b/resources/transform.go
index 9d8b5e915b8..b71c026b983 100644
--- a/resources/transform.go
+++ b/resources/transform.go
@@ -486,16 +486,20 @@ func (r *resourceAdapter) transform(key string, publish, setContent bool) (*reso
 
 			if herrors.IsFeatureNotAvailableError(err) {
 				var errMsg string
-				if tr.Key().Name == "postcss" {
+				switch strings.ToLower(tr.Key().Name) {
+				case "postcss":
 					// This transformation is not available in this
 					// Most likely because PostCSS is not installed.
-					errMsg = ". Check your PostCSS installation; install with \"npm install postcss-cli\". See https://gohugo.io/hugo-pipes/postcss/"
-				} else if tr.Key().Name == "tocss" {
+					errMsg = ". You need to install PostCSS. See https://gohugo.io/functions/css/postcss/"
+				case "tailwindcss":
+					errMsg = ". You need to install TailwindCSS CLI. See https://gohugo.io/functions/css/tailwindcss/"
+				case "tocss":
 					errMsg = ". Check your Hugo installation; you need the extended version to build SCSS/SASS with transpiler set to 'libsass'."
-				} else if tr.Key().Name == "tocss-dart" {
-					errMsg = ". You need to install Dart Sass, see https://gohugo.io/functions/resources/tocss/#dart-sass"
-				} else if tr.Key().Name == "babel" {
-					errMsg = ". You need to install Babel, see https://gohugo.io/hugo-pipes/babel/"
+				case "tocss-dart":
+					errMsg = ". You need to install Dart Sass, see https://gohugo.io//functions/css/sass/#dart-sass"
+				case "babel":
+					errMsg = ". You need to install Babel, see https://gohugo.io/functions/js/babel/"
+
 				}
 
 				return fmt.Errorf(msg+errMsg+": %w", err)
diff --git a/tpl/css/css.go b/tpl/css/css.go
index 51fa1d51854..48a526c1722 100644
--- a/tpl/css/css.go
+++ b/tpl/css/css.go
@@ -2,17 +2,41 @@ package css
 
 import (
 	"context"
+	"errors"
+	"fmt"
+	"sync"
 
+	"github.com/gohugoio/hugo/common/maps"
+	"github.com/gohugoio/hugo/common/paths"
 	"github.com/gohugoio/hugo/common/types/css"
 	"github.com/gohugoio/hugo/deps"
+	"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/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"
+	"github.com/gohugoio/hugo/tpl/internal/resourcehelpers"
 	"github.com/spf13/cast"
 )
 
 const name = "css"
 
 // Namespace provides template functions for the "css" namespace.
-type Namespace struct{}
+type Namespace struct {
+	d                 *deps.Deps
+	scssClientLibSass *scss.Client
+	postcssClient     *cssjs.PostCSSClient
+	tailwindcssClient *cssjs.TailwindCSSClient
+	babelClient       *babel.Client
+
+	// The Dart Client requires a os/exec process, so  only
+	// create it if we really need it.
+	// This is mostly to avoid creating one per site build test.
+	scssClientDartSassInit sync.Once
+	scssClientDartSass     *dartsass.Client
+}
 
 // Quoted returns a string that needs to be quoted in CSS.
 func (ns *Namespace) Quoted(v any) css.QuotedString {
@@ -26,17 +50,150 @@ func (ns *Namespace) Unquoted(v any) css.UnquotedString {
 	return css.UnquotedString(s)
 }
 
+// PostCSS processes the given Resource with PostCSS.
+func (ns *Namespace) PostCSS(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.postcssClient.Process(r, m)
+}
+
+// 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")
+	}
+
+	const (
+		// Transpiler implementation can be controlled from the client by
+		// setting the 'transpiler' option.
+		// Default is currently 'libsass', but that may change.
+		transpilerDart    = "dartsass"
+		transpilerLibSass = "libsass"
+	)
+
+	var (
+		r          resources.ResourceTransformer
+		m          map[string]any
+		targetPath string
+		err        error
+		ok         bool
+		transpiler = transpilerLibSass
+	)
+
+	r, targetPath, ok = resourcehelpers.ResolveIfFirstArgIsString(args)
+
+	if !ok {
+		r, m, err = resourcehelpers.ResolveArgs(args)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	if m != nil {
+		if t, _, found := maps.LookupEqualFold(m, "transpiler"); found {
+			switch t {
+			case transpilerDart, transpilerLibSass:
+				transpiler = cast.ToString(t)
+			default:
+				return nil, fmt.Errorf("unsupported transpiler %q; valid values are %q or %q", t, transpilerLibSass, transpilerDart)
+			}
+		}
+	}
+
+	if transpiler == transpilerLibSass {
+		var options scss.Options
+		if targetPath != "" {
+			options.TargetPath = paths.ToSlashTrimLeading(targetPath)
+		} else if m != nil {
+			options, err = scss.DecodeOptions(m)
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		return ns.scssClientLibSass.ToCSS(r, options)
+	}
+
+	if m == nil {
+		m = make(map[string]any)
+	}
+	if targetPath != "" {
+		m["targetPath"] = targetPath
+	}
+
+	client, err := ns.getscssClientDartSass()
+	if err != nil {
+		return nil, err
+	}
+
+	return client.ToCSS(r, m)
+}
+
 func init() {
 	f := func(d *deps.Deps) *internal.TemplateFuncsNamespace {
-		ctx := &Namespace{}
+		scssClient, err := scss.New(d.BaseFs.Assets, d.ResourceSpec)
+		if err != nil {
+			panic(err)
+		}
+		ctx := &Namespace{
+			d:                 d,
+			scssClientLibSass: scssClient,
+			postcssClient:     cssjs.NewPostCSSClient(d.ResourceSpec),
+			tailwindcssClient: cssjs.NewTailwindCSSClient(d.ResourceSpec),
+			babelClient:       babel.New(d.ResourceSpec),
+		}
 
 		ns := &internal.TemplateFuncsNamespace{
 			Name:    name,
 			Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil },
 		}
 
+		ns.AddMethodMapping(ctx.Sass,
+			[]string{"toCSS"},
+			[][2]string{},
+		)
+
+		ns.AddMethodMapping(ctx.PostCSS,
+			[]string{"postCSS"},
+			[][2]string{},
+		)
+
 		return ns
 	}
 
 	internal.AddTemplateFuncsNamespace(f)
 }
+
+func (ns *Namespace) getscssClientDartSass() (*dartsass.Client, error) {
+	var err error
+	ns.scssClientDartSassInit.Do(func() {
+		ns.scssClientDartSass, err = dartsass.New(ns.d.BaseFs.Assets, ns.d.ResourceSpec)
+		if err != nil {
+			return
+		}
+		ns.d.BuildClosers.Add(ns.scssClientDartSass)
+	})
+
+	return ns.scssClientDartSass, err
+}
diff --git a/tpl/internal/templatefuncsRegistry.go b/tpl/internal/templatefuncsRegistry.go
index fc02a6ef902..1b74bf443a1 100644
--- a/tpl/internal/templatefuncsRegistry.go
+++ b/tpl/internal/templatefuncsRegistry.go
@@ -51,6 +51,9 @@ type TemplateFuncsNamespace struct {
 	// This is the method receiver.
 	Context func(ctx context.Context, v ...any) (any, error)
 
+	// OnCreated is called when all the namespaces are ready.
+	OnCreated func(namespaces map[string]any)
+
 	// Additional info, aliases and examples, per method name.
 	MethodMappings map[string]TemplateFuncMethodMapping
 }
diff --git a/tpl/js/init.go b/tpl/js/init.go
index 16e6c7efad1..69d7c7275d6 100644
--- a/tpl/js/init.go
+++ b/tpl/js/init.go
@@ -31,6 +31,11 @@ func init() {
 			Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil },
 		}
 
+		ns.AddMethodMapping(ctx.Babel,
+			[]string{"babel"},
+			[][2]string{},
+		)
+
 		return ns
 	}
 
diff --git a/tpl/js/js.go b/tpl/js/js.go
index 63a6765328f..c68e0af9272 100644
--- a/tpl/js/js.go
+++ b/tpl/js/js.go
@@ -15,9 +15,12 @@
 package js
 
 import (
+	"errors"
+
 	"github.com/gohugoio/hugo/deps"
 	"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/js"
 	"github.com/gohugoio/hugo/tpl/internal/resourcehelpers"
 )
@@ -28,13 +31,15 @@ func New(deps *deps.Deps) *Namespace {
 		return &Namespace{}
 	}
 	return &Namespace{
-		client: js.New(deps.BaseFs.Assets, deps.ResourceSpec),
+		client:      js.New(deps.BaseFs.Assets, deps.ResourceSpec),
+		babelClient: babel.New(deps.ResourceSpec),
 	}
 }
 
 // Namespace provides template functions for the "js" namespace.
 type Namespace struct {
-	client *js.Client
+	client      *js.Client
+	babelClient *babel.Client
 }
 
 // Build processes the given Resource with ESBuild.
@@ -62,3 +67,24 @@ func (ns *Namespace) Build(args ...any) (resource.Resource, error) {
 
 	return ns.client.Process(r, m)
 }
+
+// Babel processes the given Resource with Babel.
+func (ns *Namespace) Babel(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
+	}
+	var options babel.Options
+	if m != nil {
+		options, err = babel.DecodeOptions(m)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return ns.babelClient.Process(r, options)
+}
diff --git a/tpl/resources/init.go b/tpl/resources/init.go
index db51b0287e5..5cea482acc3 100644
--- a/tpl/resources/init.go
+++ b/tpl/resources/init.go
@@ -17,7 +17,9 @@ import (
 	"context"
 
 	"github.com/gohugoio/hugo/deps"
+	"github.com/gohugoio/hugo/tpl/css"
 	"github.com/gohugoio/hugo/tpl/internal"
+	"github.com/gohugoio/hugo/tpl/js"
 )
 
 const name = "resources"
@@ -33,6 +35,22 @@ func init() {
 		ns := &internal.TemplateFuncsNamespace{
 			Name:    name,
 			Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil },
+			OnCreated: func(m map[string]any) {
+				for _, v := range m {
+					switch v := v.(type) {
+					case *css.Namespace:
+						ctx.cssNs = v
+					case *js.Namespace:
+						ctx.jsNs = v
+					}
+				}
+				if ctx.cssNs == nil {
+					panic("css namespace not found")
+				}
+				if ctx.jsNs == nil {
+					panic("js namespace not found")
+				}
+			},
 		}
 
 		ns.AddMethodMapping(ctx.Get,
@@ -57,21 +75,6 @@ func init() {
 			[][2]string{},
 		)
 
-		ns.AddMethodMapping(ctx.ToCSS,
-			[]string{"toCSS"},
-			[][2]string{},
-		)
-
-		ns.AddMethodMapping(ctx.PostCSS,
-			[]string{"postCSS"},
-			[][2]string{},
-		)
-
-		ns.AddMethodMapping(ctx.Babel,
-			[]string{"babel"},
-			[][2]string{},
-		)
-
 		return ns
 	}
 
diff --git a/tpl/resources/resources.go b/tpl/resources/resources.go
index 34b4464be27..78340b1ff2b 100644
--- a/tpl/resources/resources.go
+++ b/tpl/resources/resources.go
@@ -18,12 +18,12 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"sync"
 
+	"github.com/gohugoio/hugo/common/hugo"
 	"github.com/gohugoio/hugo/common/maps"
-	"github.com/gohugoio/hugo/common/paths"
 
-	"github.com/gohugoio/hugo/tpl/internal/resourcehelpers"
+	"github.com/gohugoio/hugo/tpl/css"
+	"github.com/gohugoio/hugo/tpl/js"
 
 	"github.com/gohugoio/hugo/resources/postpub"
 
@@ -33,13 +33,9 @@ import (
 
 	"github.com/gohugoio/hugo/resources/resource_factories/bundler"
 	"github.com/gohugoio/hugo/resources/resource_factories/create"
-	"github.com/gohugoio/hugo/resources/resource_transformers/babel"
 	"github.com/gohugoio/hugo/resources/resource_transformers/integrity"
 	"github.com/gohugoio/hugo/resources/resource_transformers/minifier"
-	"github.com/gohugoio/hugo/resources/resource_transformers/postcss"
 	"github.com/gohugoio/hugo/resources/resource_transformers/templates"
-	"github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass"
-	"github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss"
 
 	"github.com/spf13/cast"
 )
@@ -50,26 +46,18 @@ func New(deps *deps.Deps) (*Namespace, error) {
 		return &Namespace{}, nil
 	}
 
-	scssClient, err := scss.New(deps.BaseFs.Assets, deps.ResourceSpec)
-	if err != nil {
-		return nil, err
-	}
-
 	minifyClient, err := minifier.New(deps.ResourceSpec)
 	if err != nil {
 		return nil, err
 	}
 
 	return &Namespace{
-		deps:              deps,
-		scssClientLibSass: scssClient,
-		createClient:      create.New(deps.ResourceSpec),
-		bundlerClient:     bundler.New(deps.ResourceSpec),
-		integrityClient:   integrity.New(deps.ResourceSpec),
-		minifyClient:      minifyClient,
-		postcssClient:     postcss.New(deps.ResourceSpec),
-		templatesClient:   templates.New(deps.ResourceSpec, deps),
-		babelClient:       babel.New(deps.ResourceSpec),
+		deps:            deps,
+		createClient:    create.New(deps.ResourceSpec),
+		bundlerClient:   bundler.New(deps.ResourceSpec),
+		integrityClient: integrity.New(deps.ResourceSpec),
+		minifyClient:    minifyClient,
+		templatesClient: templates.New(deps.ResourceSpec, deps),
 	}, nil
 }
 
@@ -79,33 +67,16 @@ var _ resource.ResourceFinder = (*Namespace)(nil)
 type Namespace struct {
 	deps *deps.Deps
 
-	createClient      *create.Client
-	bundlerClient     *bundler.Client
-	scssClientLibSass *scss.Client
-	integrityClient   *integrity.Client
-	minifyClient      *minifier.Client
-	postcssClient     *postcss.Client
-	babelClient       *babel.Client
-	templatesClient   *templates.Client
-
-	// The Dart Client requires a os/exec process, so  only
-	// create it if we really need it.
-	// This is mostly to avoid creating one per site build test.
-	scssClientDartSassInit sync.Once
-	scssClientDartSass     *dartsass.Client
-}
-
-func (ns *Namespace) getscssClientDartSass() (*dartsass.Client, error) {
-	var err error
-	ns.scssClientDartSassInit.Do(func() {
-		ns.scssClientDartSass, err = dartsass.New(ns.deps.BaseFs.Assets, ns.deps.ResourceSpec)
-		if err != nil {
-			return
-		}
-		ns.deps.BuildClosers.Add(ns.scssClientDartSass)
-	})
+	createClient    *create.Client
+	bundlerClient   *bundler.Client
+	integrityClient *integrity.Client
+	minifyClient    *minifier.Client
+	templatesClient *templates.Client
 
-	return ns.scssClientDartSass, err
+	// We moved some CSS and JS related functions to the css and js package in Hugo 0.128.0.
+	// Keep this here until the deprecation period is over.
+	cssNs *css.Namespace
+	jsNs  *js.Namespace
 }
 
 // Copy copies r to the new targetPath in s.
@@ -337,89 +308,17 @@ func (ns *Namespace) Minify(r resources.ResourceTransformer) (resource.Resource,
 // ToCSS converts the given Resource to CSS. You can optional provide an Options object
 // as second argument. As an option, you can e.g. specify e.g. the target path (string)
 // for the converted CSS resource.
+// Deprecated: Moved to the css namespace in Hugo 0.128.0.
 func (ns *Namespace) ToCSS(args ...any) (resource.Resource, error) {
-	if len(args) > 2 {
-		return nil, errors.New("must not provide more arguments than resource object and options")
-	}
-
-	const (
-		// Transpiler implementation can be controlled from the client by
-		// setting the 'transpiler' option.
-		// Default is currently 'libsass', but that may change.
-		transpilerDart    = "dartsass"
-		transpilerLibSass = "libsass"
-	)
-
-	var (
-		r          resources.ResourceTransformer
-		m          map[string]any
-		targetPath string
-		err        error
-		ok         bool
-		transpiler = transpilerLibSass
-	)
-
-	r, targetPath, ok = resourcehelpers.ResolveIfFirstArgIsString(args)
-
-	if !ok {
-		r, m, err = resourcehelpers.ResolveArgs(args)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	if m != nil {
-		if t, _, found := maps.LookupEqualFold(m, "transpiler"); found {
-			switch t {
-			case transpilerDart, transpilerLibSass:
-				transpiler = cast.ToString(t)
-			default:
-				return nil, fmt.Errorf("unsupported transpiler %q; valid values are %q or %q", t, transpilerLibSass, transpilerDart)
-			}
-		}
-	}
-
-	if transpiler == transpilerLibSass {
-		var options scss.Options
-		if targetPath != "" {
-			options.TargetPath = paths.ToSlashTrimLeading(targetPath)
-		} else if m != nil {
-			options, err = scss.DecodeOptions(m)
-			if err != nil {
-				return nil, err
-			}
-		}
-
-		return ns.scssClientLibSass.ToCSS(r, options)
-	}
-
-	if m == nil {
-		m = make(map[string]any)
-	}
-	if targetPath != "" {
-		m["targetPath"] = targetPath
-	}
-
-	client, err := ns.getscssClientDartSass()
-	if err != nil {
-		return nil, err
-	}
-
-	return client.ToCSS(r, m)
+	hugo.Deprecate("resources.ToCSS", "Use css.SASS.", "v0.128.0")
+	return ns.cssNs.Sass(args...)
 }
 
-// PostCSS processes the given Resource with PostCSS
+// PostCSS processes the given Resource with PostCSS.
+// Deprecated: Moved to the css namespace in Hugo 0.128.0.
 func (ns *Namespace) PostCSS(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.postcssClient.Process(r, m)
+	hugo.Deprecate("resources.PostCSS", "Use css.PostCSS.", "v0.128.0")
+	return ns.cssNs.PostCSS(args...)
 }
 
 // PostProcess processes r after the build.
@@ -428,22 +327,8 @@ func (ns *Namespace) PostProcess(r resource.Resource) (postpub.PostPublishedReso
 }
 
 // Babel processes the given Resource with Babel.
+// Deprecated: Moved to the js namespace in Hugo 0.128.0.
 func (ns *Namespace) Babel(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
-	}
-	var options babel.Options
-	if m != nil {
-		options, err = babel.DecodeOptions(m)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	return ns.babelClient.Process(r, options)
+	hugo.Deprecate("resources.Babel", "Use js.Babel.", "v0.128.0")
+	return ns.jsNs.Babel(args...)
 }
diff --git a/tpl/tplimpl/template_funcs.go b/tpl/tplimpl/template_funcs.go
index e5ee4d54f80..81527063149 100644
--- a/tpl/tplimpl/template_funcs.go
+++ b/tpl/tplimpl/template_funcs.go
@@ -252,6 +252,9 @@ func newTemplateExecuter(d *deps.Deps) (texttemplate.Executer, map[string]reflec
 func createFuncMap(d *deps.Deps) map[string]any {
 	funcMap := template.FuncMap{}
 
+	nsMap := make(map[string]any)
+	var onCreated []func(namespaces map[string]any)
+
 	// Merge the namespace funcs
 	for _, nsf := range internal.TemplateFuncsNamespaceRegistry {
 		ns := nsf(d)
@@ -259,6 +262,11 @@ func createFuncMap(d *deps.Deps) map[string]any {
 			panic(ns.Name + " is a duplicate template func")
 		}
 		funcMap[ns.Name] = ns.Context
+		contextV, err := ns.Context(context.Background())
+		if err != nil {
+			panic(err)
+		}
+		nsMap[ns.Name] = contextV
 		for _, mm := range ns.MethodMappings {
 			for _, alias := range mm.Aliases {
 				if _, exists := funcMap[alias]; exists {
@@ -267,6 +275,14 @@ func createFuncMap(d *deps.Deps) map[string]any {
 				funcMap[alias] = mm.Method
 			}
 		}
+
+		if ns.OnCreated != nil {
+			onCreated = append(onCreated, ns.OnCreated)
+		}
+	}
+
+	for _, f := range onCreated {
+		f(nsMap)
 	}
 
 	if d.OverloadedTemplateFuncs != nil {


Test patch:
diff --git a/config/security/securityConfig_test.go b/config/security/securityConfig_test.go
index 57e615a4824..87a24301259 100644
--- 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 = ['.*']",
 	)
 }
 
diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go
index 758fc4ec93d..551b807dba0 100644
--- 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)
diff --git a/hugolib/testhelpers_test.go b/hugolib/testhelpers_test.go
index dab6936235c..746f4c26ed1 100644
--- 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
diff --git a/markup/asciidocext/convert_test.go b/markup/asciidocext/convert_test.go
index 18c38a62189..b3f63b4d8a4 100644
--- 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)
diff --git a/markup/pandoc/convert_test.go b/markup/pandoc/convert_test.go
index dec30c4102d..8beedc1150c 100644
--- 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)
diff --git a/markup/rst/convert_test.go b/markup/rst/convert_test.go
index 1897e650f26..182858263b8 100644
--- 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{})
diff --git a/modules/client_test.go b/modules/client_test.go
index d727c4586e9..0ee7e0dbc40 100644
--- 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)
diff --git a/resources/resource_transformers/postcss/postcss_test.go b/resources/resource_transformers/cssjs/inline_imports_test.go
similarity index 85%
rename from resources/resource_transformers/postcss/postcss_test.go
rename to resources/resource_transformers/cssjs/inline_imports_test.go
index 1edaaaaf52f..9bcb7f9a311 100644
--- a/resources/resource_transformers/postcss/postcss_test.go
+++ b/resources/resource_transformers/cssjs/inline_imports_test.go
@@ -1,4 +1,4 @@
-// Copyright 2020 The Hugo Authors. All rights reserved.
+// 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.
@@ -11,7 +11,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package postcss
+package cssjs
 
 import (
 	"regexp"
@@ -32,14 +32,14 @@ import (
 // Issue 6166
 func TestDecodeOptions(t *testing.T) {
 	c := qt.New(t)
-	opts1, err := decodeOptions(map[string]any{
+	opts1, err := decodePostCSSOptions(map[string]any{
 		"no-map": true,
 	})
 
 	c.Assert(err, qt.IsNil)
 	c.Assert(opts1.NoMap, qt.Equals, true)
 
-	opts2, err := decodeOptions(map[string]any{
+	opts2, err := decodePostCSSOptions(map[string]any{
 		"noMap": true,
 	})
 
@@ -67,6 +67,16 @@ func TestShouldImport(t *testing.T) {
 	}
 }
 
+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()
@@ -95,7 +105,7 @@ LOCAL_STYLE
 	imp := newImportResolver(
 		mainStyles,
 		"styles.css",
-		Options{},
+		InlineImports{},
 		fs, loggers.NewDefault(),
 		identity.NopManager,
 	)
@@ -153,7 +163,7 @@ LOCAL_STYLE
 		imp := newImportResolver(
 			strings.NewReader(mainStyles),
 			"styles.css",
-			Options{},
+			InlineImports{},
 			fs, logger,
 			identity.NopManager,
 		)
diff --git a/resources/resource_transformers/postcss/postcss_integration_test.go b/resources/resource_transformers/cssjs/postcss_integration_test.go
similarity index 97%
rename from resources/resource_transformers/postcss/postcss_integration_test.go
rename to resources/resource_transformers/cssjs/postcss_integration_test.go
index 957e694038e..e8f52326cf3 100644
--- a/resources/resource_transformers/postcss/postcss_integration_test.go
+++ b/resources/resource_transformers/cssjs/postcss_integration_test.go
@@ -1,4 +1,4 @@
-// Copyright 2021 The Hugo Authors. All rights reserved.
+// 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.
@@ -11,7 +11,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package postcss_test
+package cssjs_test
 
 import (
 	"fmt"
@@ -181,7 +181,7 @@ func TestTransformPostCSSNotInstalledError(t *testing.T) {
 		}).BuildE()
 
 	s.AssertIsFileError(err)
-	c.Assert(err.Error(), qt.Contains, `binary with name "npx" not found`)
+	c.Assert(err.Error(), qt.Contains, `binary with name "postcss" not found using npx`)
 }
 
 // #9895
diff --git a/resources/resource_transformers/cssjs/tailwindcss_integration_test.go b/resources/resource_transformers/cssjs/tailwindcss_integration_test.go
new file mode 100644
index 00000000000..ddd78b62f4b
--- /dev/null
+++ b/resources/resource_transformers/cssjs/tailwindcss_integration_test.go
@@ -0,0 +1,72 @@
+// Copyright 2024 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cssjs_test
+
+import (
+	"testing"
+
+	"github.com/bep/logg"
+	"github.com/gohugoio/hugo/htesting"
+	"github.com/gohugoio/hugo/hugolib"
+)
+
+func TestTailwindV4Basic(t *testing.T) {
+	if !htesting.IsCI() {
+		t.Skip("Skip long running test when running locally")
+	}
+
+	files := `
+-- hugo.toml --
+-- package.json --
+{
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/bep/hugo-starter-tailwind-basic.git"
+  },
+  "devDependencies": {
+    "@tailwindcss/cli": "^4.0.0-alpha.16",
+    "tailwindcss": "^4.0.0-alpha.16"
+  },
+  "name": "hugo-starter-tailwind-basic",
+  "version": "0.1.0"
+}
+-- assets/css/styles.css --
+@import "tailwindcss";
+
+@theme {
+  --font-family-display: "Satoshi", "sans-serif";
+
+  --breakpoint-3xl: 1920px;
+
+  --color-neon-pink: oklch(71.7% 0.25 360);
+  --color-neon-lime: oklch(91.5% 0.258 129);
+  --color-neon-cyan: oklch(91.3% 0.139 195.8);
+}
+-- layouts/index.html --
+{{ $css := resources.Get "css/styles.css" | css.TailwindCSS }}
+CSS: {{ $css.Content | safeCSS }}|
+`
+
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:               t,
+			TxtarString:     files,
+			NeedsOsFS:       true,
+			NeedsNpmInstall: true,
+			LogLevel:        logg.LevelInfo,
+		}).Build()
+
+	b.AssertFileContent("public/index.html", "/*! tailwindcss v4.0.0")
+}