]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Add css.Build
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Mon, 9 Mar 2026 10:16:36 +0000 (11:16 +0100)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Thu, 12 Mar 2026 12:32:40 +0000 (13:32 +0100)
Fixes #14609
Fixes #14613

16 files changed:
hugolib/integrationtest_builder.go
internal/js/esbuild/batch.go
internal/js/esbuild/build.go
internal/js/esbuild/options.go
internal/js/esbuild/options_test.go
internal/js/esbuild/resolve.go
internal/js/esbuild/resolve_test.go
resources/resource_transformers/babel/babel.go
resources/resource_transformers/js/build.go
resources/resource_transformers/js/transform.go
resources/resource_transformers/tocss/dartsass/transform.go
resources/resource_transformers/tocss/scss/tocss.go
resources/transform.go
tpl/css/build_integration_test.go [new file with mode: 0644]
tpl/css/css.go
tpl/js/js.go

index c08a86c472125d645f775b638dfd8b942c70450e..d9f05990306e61baace87f52d2d4c3b546211aac 100644 (file)
@@ -106,6 +106,13 @@ func TestOptOsFs() TestOpt {
        }
 }
 
+// TestOptWithNpmInstall will enable npm install in integration tests.
+func TestOptWithNpmInstall() TestOpt {
+       return func(c *IntegrationTestConfig) {
+               c.NeedsNpmInstall = true
+       }
+}
+
 // TestOptWithNFDOnDarwin will normalize the Unicode filenames to NFD on Darwin.
 func TestOptWithNFDOnDarwin() TestOpt {
        return func(c *IntegrationTestConfig) {
index dd5eb194e97be169ca00df42ff3e86d03652962a..28fc11ffa93745a340ac183c781fcda46b1bb727 100644 (file)
@@ -54,7 +54,6 @@ const (
        NsBatch = "_hugo-js-batch"
 
        propsKeyImportContext = "importContext"
-       propsResoure          = "resource"
 )
 
 //go:embed batch-esm-runner.gotmpl
@@ -72,7 +71,7 @@ var (
 func NewBatcherClient(deps *deps.Deps) (js.BatcherClient, error) {
        c := &BatcherClient{
                d:            deps,
-               buildClient:  NewBuildClient(deps.BaseFs.Assets, deps.ResourceSpec),
+               buildClient:  NewBuildClient(deps.BaseFs.Assets, deps.ResourceSpec, false),
                createClient: create.New(deps.ResourceSpec),
                batcherStore: hmaps.NewCache[string, js.Batcher](),
                bundlesStore: hmaps.NewCache[string, js.BatchPackage](),
index 33b91eafc9465080e559b9660b5e669452f2c857..cf88276c34b436a744c83e9384cec16f08c3e876 100644 (file)
@@ -32,17 +32,19 @@ import (
 )
 
 // NewBuildClient creates a new BuildClient.
-func NewBuildClient(fs *filesystems.SourceFilesystem, rs *resources.Spec) *BuildClient {
+func NewBuildClient(fs *filesystems.SourceFilesystem, rs *resources.Spec, cssMode bool) *BuildClient {
        return &BuildClient{
-               rs:  rs,
-               sfs: fs,
+               rs:      rs,
+               sfs:     fs,
+               CssMode: cssMode,
        }
 }
 
 // BuildClient is a client for building JavaScript resources using esbuild.
 type BuildClient struct {
-       rs  *resources.Spec
-       sfs *filesystems.SourceFilesystem
+       rs      *resources.Spec
+       sfs     *filesystems.SourceFilesystem
+       CssMode bool
 }
 
 // Build builds the given JavaScript resources using esbuild with the given options.
@@ -80,7 +82,7 @@ func (c *BuildClient) Build(opts Options) (api.BuildResult, error) {
                                return api.BuildResult{}, fmt.Errorf("inject: absolute paths not supported, must be relative to /assets")
                        }
 
-                       m := assetsResolver.resolveComponent(impPath)
+                       m := assetsResolver.resolveComponent(impPath, false)
 
                        if m == nil {
                                return api.BuildResult{}, fmt.Errorf("inject: file %q not found", ext)
@@ -190,7 +192,7 @@ func (c *BuildClient) Build(opts Options) (api.BuildResult, error) {
                        }
                }
 
-               if m := assetsResolver.resolveComponent(s); m != nil {
+               if m := assetsResolver.resolveComponent(s, false); m != nil {
                        return m.Filename
                }
 
index 310fa305dee1b1435bf4a2d0c7b7f4f680f93f4e..55ef4bf39644c1eac79134cff399811d7766c8c3 100644 (file)
@@ -17,6 +17,7 @@ import (
        "encoding/json"
        "fmt"
        "path/filepath"
+       "sort"
        "strings"
 
        "github.com/gohugoio/hugo/common/hmaps"
@@ -48,6 +49,22 @@ var (
                "es2024": api.ES2024,
        }
 
+       engineName = map[string]api.EngineName{
+               "chrome":  api.EngineChrome,
+               "deno":    api.EngineDeno,
+               "edge":    api.EngineEdge,
+               "firefox": api.EngineFirefox,
+               "hermes":  api.EngineHermes,
+               "ie":      api.EngineIE,
+               "ios":     api.EngineIOS,
+               "node":    api.EngineNode,
+               "opera":   api.EngineOpera,
+               "rhino":   api.EngineRhino,
+               "safari":  api.EngineSafari,
+       }
+
+       engineKeysSorted []string
+
        // source names: https://github.com/evanw/esbuild/blob/9eca46464ed5615cb36a3beb3f7a7b9a8ffbe7cf/internal/config/config.go#L208
        nameLoader = map[string]api.Loader{
                "none":       api.LoaderNone,
@@ -70,6 +87,16 @@ var (
        }
 )
 
+func init() {
+       for k := range engineName {
+               engineKeysSorted = append(engineKeysSorted, k)
+       }
+       // Sort by length descending to match longest prefix first.
+       sort.Slice(engineKeysSorted, func(i, j int) bool {
+               return len(engineKeysSorted[i]) > len(engineKeysSorted[j])
+       })
+}
+
 // DecodeExternalOptions decodes the given map into ExternalOptions.
 func DecodeExternalOptions(m map[string]any) (ExternalOptions, error) {
        opts := ExternalOptions{
@@ -84,7 +111,9 @@ func DecodeExternalOptions(m map[string]any) (ExternalOptions, error) {
                opts.TargetPath = paths.ToSlashTrimLeading(opts.TargetPath)
        }
 
-       opts.Target = strings.ToLower(opts.Target)
+       for i, t := range opts.Target {
+               opts.Target[i] = strings.ToLower(t)
+       }
        opts.Format = strings.ToLower(opts.Format)
 
        return opts, nil
@@ -112,10 +141,11 @@ type ExternalOptions struct {
 
        SourcesContent bool
 
-       // The language target.
-       // One of: es2015, es2016, es2017, es2018, es2019, es2020 or esnext.
-       // Default is esnext.
-       Target string
+       // This sets the target environment for the generated JavaScript and/or CSS code.
+       // It can specify the JavaScript language version to target (es2015, es2016, es2017, es2018, es2019, es2020 or esnext; default is esnext),
+       // in addition to a set of environments to support, as slice of environment name followed by a version number. e.g. "chrome80", "firefox73", "safari13" or "edge80".
+       // See https://esbuild.github.io/api/#target
+       Target []string
 
        // The output format.
        // One of: iife, cjs, esm
@@ -127,6 +157,12 @@ type ExternalOptions struct {
        // See https://esbuild.github.io/api/#platform
        Platform string
 
+       // When you import a package in node, the main field in that package's package.json file
+       // determines which file is imported (along with a lot of other rules).
+       // The default main fields is controlled by ESBuild and depend on the current platform setting.
+       // See https://esbuild.github.io/api/#main-fields
+       MainFields []string
+
        // External dependencies, e.g. "react".
        Externals []string
 
@@ -200,6 +236,7 @@ type InternalOptions struct {
 
        Stdin                   bool // Set to true to pass in the entry point as a byte slice.
        Splitting               bool
+       IsCSS                   bool // Entry point is CSS.
        TsConfig                string
        EntryPoints             []string
        ImportOnResolveFunc     func(string, api.OnResolveArgs) string
@@ -218,10 +255,37 @@ type Options struct {
 }
 
 func (opts *Options) compile() (err error) {
-       target, found := nameTarget[opts.Target]
-       if !found {
-               err = fmt.Errorf("invalid target: %q", opts.Target)
-               return
+       var target api.Target
+       for _, value := range opts.Target {
+               if v, found := nameTarget[value]; found {
+                       if v > target {
+                               target = v
+                       }
+               }
+       }
+
+       var engines []api.Engine
+
+OUTER:
+       for _, value := range opts.Target {
+               for _, engine := range engineKeysSorted {
+                       if strings.HasPrefix(value, engine) {
+                               version := value[len(engine):]
+                               if version == "" {
+                                       return fmt.Errorf("invalid engine version: %q", value)
+                               }
+                               engines = append(engines, api.Engine{Name: engineName[engine], Version: version})
+                               continue OUTER
+                       }
+               }
+       }
+
+       if target == 0 && len(engines) == 0 && len(opts.Target) > 0 {
+               return fmt.Errorf("unsupported target: %v", opts.Target)
+       }
+
+       if target == 0 {
+               target = api.ESNext
        }
 
        var loaders map[string]api.Loader
@@ -252,6 +316,8 @@ func (opts *Options) compile() (err error) {
                loader = api.LoaderTSX
        case media.Builtin.JSXType.SubType:
                loader = api.LoaderJSX
+       case media.Builtin.CSSType.SubType:
+               loader = api.LoaderCSS
        default:
                err = fmt.Errorf("unsupported Media Type: %q", opts.MediaType)
                return
@@ -343,7 +409,9 @@ func (opts *Options) compile() (err error) {
                AbsWorkingDir: opts.AbsWorkingDir,
 
                Target:         target,
+               Engines:        engines,
                Format:         format,
+               MainFields:     opts.MainFields,
                Platform:       platform,
                Sourcemap:      sourceMap,
                SourcesContent: sourcesContent,
@@ -390,10 +458,20 @@ func (o Options) loaderFromFilename(filename string) api.Loader {
                        return l
                }
        }
-       l, found := extensionToLoaderMap[ext]
-       if found {
-               return l
+       if o.IsCSS {
+               l, found := extensionToLoaderMapCSS[ext]
+               if found {
+                       return l
+               }
+               // For CSS builds, handling, default to the file loader for unknown extensions.
+               return api.LoaderFile
+       } else {
+               l, found := extensionToLoaderMapJS[ext]
+               if found {
+                       return l
+               }
        }
+
        return api.LoaderJS
 }
 
index e92c3bea681c18a2ea65dbddcfcc71e89cc1fae4..6d430e9abe211502d687c2b0afafcb2cecba2634 100644 (file)
@@ -47,7 +47,7 @@ func TestToBuildOptions(t *testing.T) {
 
        opts = Options{
                ExternalOptions: ExternalOptions{
-                       Target:   "es2018",
+                       Target:   []string{"es2018"},
                        Format:   "cjs",
                        Minify:   true,
                        AvoidTDZ: true,
@@ -75,7 +75,7 @@ func TestToBuildOptions(t *testing.T) {
 
        opts = Options{
                ExternalOptions: ExternalOptions{
-                       Target: "es2018", Format: "cjs", Minify: true,
+                       Target: []string{"es2018"}, Format: "cjs", Minify: true,
                        SourceMap: "inline",
                },
                InternalOptions: InternalOptions{
@@ -102,7 +102,7 @@ func TestToBuildOptions(t *testing.T) {
 
        opts = Options{
                ExternalOptions: ExternalOptions{
-                       Target: "es2018", Format: "cjs", Minify: true,
+                       Target: []string{"es2018"}, Format: "cjs", Minify: true,
                        SourceMap: "inline",
                },
                InternalOptions: InternalOptions{
@@ -129,7 +129,7 @@ func TestToBuildOptions(t *testing.T) {
 
        opts = Options{
                ExternalOptions: ExternalOptions{
-                       Target: "es2018", Format: "cjs", Minify: true,
+                       Target: []string{"es2018"}, Format: "cjs", Minify: true,
                        SourceMap: "external",
                },
                InternalOptions: InternalOptions{
@@ -223,7 +223,7 @@ func TestToBuildOptionsTarget(t *testing.T) {
                c.Run(test.target, func(c *qt.C) {
                        opts := Options{
                                ExternalOptions: ExternalOptions{
-                                       Target: test.target,
+                                       Target: []string{test.target},
                                },
                                InternalOptions: InternalOptions{
                                        MediaType: media.Builtin.JavascriptType,
@@ -260,3 +260,13 @@ func TestDecodeExternalOptions(t *testing.T) {
                SourcesContent: api.SourcesContentInclude,
        })
 }
+
+func TestDecodeExternalOptionsLegacyTargetString(t *testing.T) {
+       c := qt.New(t)
+       m := map[string]any{
+               "target": "es2018",
+       }
+       ext, err := DecodeExternalOptions(m)
+       c.Assert(err, qt.IsNil)
+       c.Assert(ext.Target, qt.DeepEquals, []string{"es2018"})
+}
index e66c72c7baeb7aa4b04e6d42b3cfd5194dd46b25..e9cfa7f9b085b317f730204ea4db0835d6e22218 100644 (file)
@@ -46,16 +46,20 @@ const (
        PrefixHugoMemory  = "__hu_m"
 )
 
-var extensionToLoaderMap = map[string]api.Loader{
+var extensionToLoaderMapJS = map[string]api.Loader{
        ".js":   api.LoaderJS,
        ".mjs":  api.LoaderJS,
        ".cjs":  api.LoaderJS,
        ".jsx":  api.LoaderJSX,
        ".ts":   api.LoaderTS,
        ".tsx":  api.LoaderTSX,
-       ".css":  api.LoaderCSS,
        ".json": api.LoaderJSON,
        ".txt":  api.LoaderText,
+       ".css":  api.LoaderCSS,
+}
+
+var extensionToLoaderMapCSS = map[string]api.Loader{
+       ".css": api.LoaderCSS,
 }
 
 // This is a common sub-set of ESBuild's default extensions.
@@ -93,6 +97,7 @@ func ResolveComponent[T any](impPath string, resolve func(string) (v T, found, i
        }
 
        base := filepath.Base(impPath)
+
        if base == "index" {
                // try index.esm.js etc.
                v, found, _ = findFirst(impPath + ".esm")
@@ -140,7 +145,7 @@ type fsResolver struct {
        resolved *hmaps.Cache[string, *hugofs.FileMeta]
 }
 
-func (r *fsResolver) resolveComponent(impPath string) *hugofs.FileMeta {
+func (r *fsResolver) resolveComponent(impPath string, direct bool) *hugofs.FileMeta {
        v, _ := r.resolved.GetOrCreate(impPath, func() (*hugofs.FileMeta, error) {
                resolve := func(name string) (*hugofs.FileMeta, bool, bool) {
                        if fi, err := r.fs.Stat(name); err == nil {
@@ -148,6 +153,14 @@ func (r *fsResolver) resolveComponent(impPath string) *hugofs.FileMeta {
                        }
                        return nil, false, false
                }
+               if direct {
+                       // Only resolve the path as is, without trying to add extensions etc.
+                       v, found, isDir := resolve(impPath)
+                       if found && !isDir {
+                               return v, nil
+                       }
+                       return nil, nil
+               }
                v, _ := ResolveComponent(impPath, resolve)
                return v, nil
        })
@@ -159,6 +172,8 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
 
        resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
                impPath := args.Path
+               isCSSToken := args.Kind == api.ResolveCSSImportRule || args.Kind == api.ResolveCSSURLToken
+
                shimmed := false
                if opts.Shims != nil {
                        override, found := opts.Shims[impPath]
@@ -182,9 +197,9 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
                }
 
                importer := args.Importer
-
                isStdin := importer == stdinImporter
                var relDir string
+
                if !isStdin {
                        if after, ok := strings.CutPrefix(importer, PrefixHugoVirtual); ok {
                                relDir = filepath.Dir(after)
@@ -208,23 +223,45 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
                        relDir = opts.SourceDir
                }
 
-               // Imports not starting with a "." is assumed to live relative to /assets.
-               // Hugo makes no assumptions about the directory structure below /assets.
-               if relDir != "" && strings.HasPrefix(impPath, ".") {
-                       impPath = filepath.Join(relDir, impPath)
+               var pathsToTry []string
+
+               if relDir != "" {
+                       // For JS imports not starting with a "." is assumed to live relative to /assets.
+                       // Hugo makes no assumptions about the directory structure below /assets.
+                       if strings.HasPrefix(impPath, ".") {
+                               pathsToTry = append(pathsToTry, filepath.Join(relDir, impPath))
+                       } else if isCSSToken && !strings.HasPrefix(impPath, "/") {
+                               // Follow the logic of both ESBuild and WebKit for CSS @import and url() tokens.
+                               // First try the relativ path, then the assets relative path.
+                               // See https://github.com/evanw/esbuild/issues/469
+                               pathsToTry = append(pathsToTry, filepath.Join(relDir, impPath), impPath)
+                       } else {
+                               // Try only the assets relative path.
+                               pathsToTry = append(pathsToTry, impPath)
+                       }
                }
 
-               m := assetsResolver.resolveComponent(impPath)
+               var m *hugofs.FileMeta
+               for _, p := range pathsToTry {
+                       m = assetsResolver.resolveComponent(p, isCSSToken)
+                       if m != nil {
+                               break
+                       }
+               }
 
                if m != nil {
                        depsManager.AddIdentity(m.PathInfo)
 
-                       // Store the source root so we can create a jsconfig.json
-                       // to help IntelliSense when the build is done.
-                       // This should be a small number of elements, and when
-                       // in server mode, we may get stale entries on renames etc.,
-                       // but that shouldn't matter too much.
-                       rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)
+                       //  jsconfig.json path mapping has no effect for CSS imports, so skip those.
+                       if !isCSSToken {
+                               // Store the source root so we can create a jsconfig.json
+                               // to help IntelliSense when the build is done.
+                               // This should be a small number of elements, and when
+                               // in server mode, we may get stale entries on renames etc.,
+                               // but that shouldn't matter too much.
+                               rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)
+                       }
+
                        return api.OnResolveResult{Path: m.Filename, Namespace: NsHugoImport}, nil
                }
 
index 86e3138f28117eadbefef765c5cf863b49509c27..44e24d2d41861066bd2c2d6a64450c934b6d196d 100644 (file)
@@ -71,7 +71,7 @@ func TestResolveComponentInAssets(t *testing.T) {
                        c.Assert(err, qt.IsNil)
                        resolver := newFSResolver(bfs.Assets.Fs)
 
-                       got := resolver.resolveComponent(test.impPath)
+                       got := resolver.resolveComponent(test.impPath, false)
 
                        gotPath := ""
                        expect := test.expect
index e00969516a494f8e24127fd63ee1689d57c507d5..859bf5805ccfd9b90285c5243afe8051761c17c8 100644 (file)
@@ -218,7 +218,7 @@ func (t *babelTransformation) Transform(ctx *resources.ResourceTransformationCtx
                if err != nil {
                        return err
                }
-               if err = ctx.PublishSourceMap(string(sourceMap)); err != nil {
+               if err = ctx.PublishSourceMap(sourceMap); err != nil {
                        return err
                }
                targetPath := path.Base(ctx.OutPath) + ".map"
index bd943461f0de5dbd06baa9c3c76090825c3b2522..41d9dd0dd1bf275e16fc473625e965a1f432eed4 100644 (file)
 package js
 
 import (
+       "fmt"
        "path"
+       "path/filepath"
        "regexp"
+       "strings"
 
        "github.com/evanw/esbuild/pkg/api"
        "github.com/gohugoio/hugo/hugolib/filesystems"
@@ -31,9 +34,9 @@ type Client struct {
 }
 
 // New creates a new client context.
-func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client {
+func New(fs *filesystems.SourceFilesystem, rs *resources.Spec, cssMode bool) *Client {
        return &Client{
-               c: esbuild.NewBuildClient(fs, rs),
+               c: esbuild.NewBuildClient(fs, rs, cssMode),
        }
 }
 
@@ -56,27 +59,56 @@ func (c *Client) transform(opts esbuild.Options, transformCtx *resources.Resourc
                return result, err
        }
 
-       if opts.ExternalOptions.SourceMap == "linked" || opts.ExternalOptions.SourceMap == "external" {
-               content := string(result.OutputFiles[1].Contents)
-               if opts.ExternalOptions.SourceMap == "linked" {
-                       symPath := path.Base(transformCtx.OutPath) + ".map"
-                       re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
-                       content = re.ReplaceAllString(content, "//# sourceMappingURL="+symPath+"\n")
-               }
+       hasLinkedSourceMap := opts.ExternalOptions.SourceMap == "linked"
+       hasSourceMap := hasLinkedSourceMap || opts.ExternalOptions.SourceMap == "external"
 
-               if err = transformCtx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil {
-                       return result, err
+       // Classify output files by path rather than relying on array ordering,
+       // which esbuild does not guarantee.
+       var mainOutput []byte
+       outDir := path.Dir(transformCtx.OutPath)
+       for _, file := range result.OutputFiles {
+               basePath := path.Base(filepath.ToSlash(file.Path))
+               if strings.HasSuffix(basePath, ".map") {
+                       if hasSourceMap {
+                               if err = transformCtx.PublishSourceMap(file.Contents); err != nil {
+                                       return result, err
+                               }
+                       }
+               } else if isStdinEntryOutput(basePath) {
+                       mainOutput = file.Contents
+               } else {
+                       // File-loader artifact; publish directly.
+                       target := path.Join(outDir, basePath)
+                       if err = transformCtx.PublishTo(target, file.Contents); err != nil {
+                               return result, err
+                       }
                }
-               _, err := transformCtx.To.Write([]byte(content))
-               if err != nil {
-                       return result, err
-               }
-       } else {
-               _, err := transformCtx.To.Write(result.OutputFiles[0].Contents)
-               if err != nil {
-                       return result, err
+       }
+
+       if mainOutput == nil {
+               return result, fmt.Errorf("esbuild: entry point output not found")
+       }
+
+       if hasLinkedSourceMap {
+               symPath := path.Base(transformCtx.OutPath) + ".map"
+               if opts.IsCSS {
+                       re := regexp.MustCompile(`/\*# sourceMappingURL=.*\n?`)
+                       mainOutput = re.ReplaceAll(mainOutput, []byte("/*# sourceMappingURL="+symPath+" */\n"))
+               } else {
+                       re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
+                       mainOutput = re.ReplaceAll(mainOutput, []byte("//# sourceMappingURL="+symPath+"\n"))
                }
+       }
 
+       if _, err = transformCtx.To.Write(mainOutput); err != nil {
+               return result, err
        }
+
        return result, nil
 }
+
+// isStdinEntryOutput reports whether basePath is the entry-point output
+// generated by esbuild when using stdin mode (e.g. "stdin.js", "stdin.css").
+func isStdinEntryOutput(basePath string) bool {
+       return strings.HasPrefix(basePath, "stdin.")
+}
index 13909e54cc6d8acaacb4ec25095de00cfff07c32..07933a92f1e34f5fc20b8796a100366adc88f6e4 100644 (file)
@@ -30,11 +30,18 @@ type buildTransformation struct {
 }
 
 func (t *buildTransformation) Key() internal.ResourceTransformationKey {
-       return internal.NewResourceTransformationKey("jsbuild", t.optsm)
+       key := "jsbuild"
+       if t.c.c.CssMode {
+               key = "cssbuild"
+       }
+       return internal.NewResourceTransformationKey(key, t.optsm)
 }
 
 func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
        ctx.OutMediaType = media.Builtin.JavascriptType
+       if ctx.InMediaType == media.Builtin.CSSType {
+               ctx.OutMediaType = media.Builtin.CSSType
+       }
 
        var opts esbuild.Options
 
@@ -49,7 +56,7 @@ func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx
        if opts.TargetPath != "" {
                ctx.OutPath = opts.TargetPath
        } else {
-               ctx.ReplaceOutPathExtension(".js")
+               ctx.ReplaceOutPathExtension(ctx.OutMediaType.FirstSuffix.FullSuffix)
        }
 
        src, err := io.ReadAll(ctx.From)
@@ -61,6 +68,7 @@ func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx
        opts.Contents = string(src)
        opts.MediaType = ctx.InMediaType
        opts.Stdin = true
+       opts.IsCSS = t.c.c.CssMode
 
        _, err = t.c.transform(opts, ctx)
 
index e1e9b0be0c6d2e325187670f96034b6bfe630c8c..d199e5cdf5e82b18ce60e670fd4f975c3239f86d 100644 (file)
@@ -119,7 +119,7 @@ func (t *transform) Transform(ctx *resources.ResourceTransformationCtx) error {
        }
 
        if opts.EnableSourceMap && res.SourceMap != "" {
-               if err := ctx.PublishSourceMap(res.SourceMap); err != nil {
+               if err := ctx.PublishSourceMap([]byte(res.SourceMap)); err != nil {
                        return err
                }
                _, err = fmt.Fprintf(ctx.To, "\n\n/*# sourceMappingURL=%s */", path.Base(ctx.OutPath)+".map")
index 2976578afa5af17e53f39b90333c1f74d8b93068..ea86171405e4a7ee8c1c5021ef9e42df9b4a68b4 100644 (file)
@@ -177,7 +177,7 @@ func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx
                // is important enough to go this extra mile.
                mapContent := strings.Replace(res.SourceMapContent, `stdin"`, fmt.Sprintf("%s\"", sourcePath), 1)
 
-               return ctx.PublishSourceMap(mapContent)
+               return ctx.PublishSourceMap([]byte(mapContent))
        }
        return nil
 }
index 169c50895e902adab952cecfeef4d8db230760d0..50f8de30c92999b4db25086d28dd9e92bae32c15 100644 (file)
@@ -146,14 +146,19 @@ func (ctx *ResourceTransformationCtx) AddOutPathIdentifier(identifier string) {
 
 // PublishSourceMap writes the content to the target folder of the main resource
 // with the ".map" extension added.
-func (ctx *ResourceTransformationCtx) PublishSourceMap(content string) error {
+func (ctx *ResourceTransformationCtx) PublishSourceMap(content []byte) error {
        target := ctx.OutPath + ".map"
+       return ctx.PublishTo(target, content)
+}
+
+// PublishTo writes the content to the target folder.
+func (ctx *ResourceTransformationCtx) PublishTo(target string, content []byte) error {
        f, err := ctx.OpenResourcePublisher(target)
        if err != nil {
                return err
        }
        defer f.Close()
-       _, err = f.Write([]byte(content))
+       _, err = f.Write(content)
        return err
 }
 
diff --git a/tpl/css/build_integration_test.go b/tpl/css/build_integration_test.go
new file mode 100644 (file)
index 0000000..edfef72
--- /dev/null
@@ -0,0 +1,315 @@
+// Copyright 2026 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 css_test
+
+import (
+       "strings"
+       "testing"
+
+       "github.com/gohugoio/hugo/htesting"
+       "github.com/gohugoio/hugo/hugolib"
+)
+
+func TestCSSBuild(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+-- assets/a/pixel.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+-- assets/css/main.css --
+@import "tailwindcss";
+@import url('https://example.org/foo.css');
+@import "./foo.css";
+@import "./bar.css" layer(mylayer);
+@import "./bar.css" layer;
+@import './baz.css' screen and (min-width: 800px);
+@import 'relnodot.css' supports(display: grid);
+@import 'relnodotboth.css';
+@import '/relnodotboth2.css';
+body {
+  background-image: url("a/pixel.png");
+}
+.mask1 {
+mask-image: url(a/pixel.png);
+mask-repeat: no-repeat;
+}
+-- assets/css/foo.css --
+p { background: red; }
+-- assets/css/bar.css --
+div { background: blue; }
+-- assets/css/baz.css --
+span { background: green; }
+-- assets/css/relnodot.css --
+article { background: yellow; }
+-- assets/css/relnodotboth.css --
+.relnodotbothrelative { background: green; }
+-- assets/relnodotboth.css --
+.relnodotbothroot { background: blue; }
+-- assets/css/relnodotboth2.css --
+.relnodotboth2relative { background: green; }
+-- assets/relnodotboth2.css --
+.relnodotboth2root { background: blue; }
+-- layouts/home.html --
+{{ with resources.Get "css/main.css"  }}
+{{ $opts := (dict "minify" true "target" (slice "chrome108" "firefox116" "safari16.4" "edge115" "ios16.4" "opera101")  "loaders" (dict ".png" "dataurl") "externals" (slice  "tailwindcss"))}}
+{{ with . | css.Build $opts  }}
+ <link rel="stylesheet" href="{{ .RelPermalink }}" />
+{{ end }}
+{{ end }}
+       `
+
+       b := hugolib.Test(t, files, hugolib.TestOptOsFs())
+       b.AssertFileContent("public/css/main.css",
+               `-webkit-mask-repeat`,                      // browser prefix.
+               "data:image/png;base64",                    // dataurl loader
+               `@import"tailwindcss";`,                    // external
+               `@import"https://example.org/foo.css";`,    // external
+               "mylayer{div",                              // imported layer
+               "@media screen and (min-width:800px){span", // imported media query
+               "@supports (display: grid){article",        // imported supports
+               "relnodotbothrelative",                     //@import 'relnodotboth.css'; file exists in both assets and project root.
+               "relnodotboth2root",                        //@import '/relnodotboth2.css'; file exists in both assets and project root.
+       )
+}
+
+func TestCSSBuildEdit(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term", "rss"]
+-- assets/images/bar.svg --
+barsvg
+-- assets/images/foo.svg --
+foosvg
+-- assets/css/main.css --
+@import "./foo.css";
+@import "./bar.css" layer(mylayer);
+.main { background: red; }
+-- assets/css/foo.css --
+.foo { background: green; }
+-- assets/css/bar.css --
+.bar { background-image: url("images/bar.svg"); }
+-- layouts/all.html --
+All. No CSS here.
+-- layouts/home.html --
+{{ with resources.Get "css/main.css"  }}
+{{ $opts := (dict "minify" true) }}
+{{ with . | css.Build $opts  }}
+ <link rel="stylesheet" href="{{ .RelPermalink }}" />
+{{ end }}
+{{ end }}
+`
+       b := hugolib.TestRunning(t, files, hugolib.TestOptOsFs())
+
+       b.AssertFileContent("public/css/main.css", `.foo{background:green}@layer mylayer{.bar{background-image:url("./bar-Y35ORVQM.svg")}}`)
+
+       // Edit svg
+       b.EditFileReplaceAll("assets/images/bar.svg", "barsvg", "newbarsvg").Build()
+       b.AssertRenderCountPage(1)
+       b.AssertFileContent("public/css/main.css", `bar-LVHHRPN5.svg`) // new hash.
+       b.AssertFileContent("public/css/bar-LVHHRPN5.svg", "newbarsvg")
+
+       // Edit foo.css
+       b.EditFileReplaceAll("assets/css/foo.css", "green", "red").Build()
+       b.AssertRenderCountPage(1)
+       b.AssertFileContent("public/css/main.css", `.foo{background:red}`) // updated content.
+
+       // Edit bar.css
+       b.EditFileReplaceAll("assets/css/bar.css", "bar.svg", "foo.svg").Build()
+       b.AssertRenderCountPage(1)
+       b.AssertFileContent("public/css/main.css", `foo-52JTT5GU.svg`)
+       b.AssertFileContent("public/css/foo-52JTT5GU.svg", "foosvg")
+
+       // Edit main.css
+       b.EditFileReplaceAll("assets/css/main.css", "red", "blue").Build()
+       b.AssertRenderCountPage(1)
+       b.AssertFileContent("public/css/main.css", `.main{background:#00f}`) // updated content, blue gets minified to #00f.
+}
+
+func TestCSSBuildSourceMaps(t *testing.T) {
+       t.Parallel()
+
+       filesTemplate := `
+-- hugo.toml --
+-- assets/a/pixel.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+-- assets/css/main.css --
+@import "./foo.css";
+@import "./bar.css" layer(mylayer);
+@import "./bar.css" layer;
+@import './baz.css' screen and (min-width: 800px);
+
+.main {
+  background-color: red;
+}
+-- assets/css/foo.css --
+p { background: red; }
+-- assets/css/bar.css --
+div { background: blue; }
+-- assets/css/baz.css --
+span { background: green; }
+-- assets/css/qux.css --
+article { background: yellow; }
+-- layouts/home.html --
+{{ with resources.Get "css/main.css"  }}
+{{ $opts := (dict "minify" false "sourceMap" "SOURCE_MAP" "sourcesContent" SOURCES_CONTENT  "loaders" (dict ".png" "dataurl"))}}
+{{ with . | css.Build $opts  }}
+ <link rel="stylesheet" href="{{ .RelPermalink }}" />
+{{ end }}
+{{ end }}
+       `
+
+       r := strings.NewReplacer(
+               "SOURCE_MAP", "linked",
+               "SOURCES_CONTENT", "true",
+       )
+
+       files := r.Replace(filesTemplate)
+
+       b := hugolib.Test(t, files, hugolib.TestOptOsFs())
+       b.AssertFileContent("public/css/main.css", "/*# sourceMappingURL=main.css.map */")
+       b.AssertFileContent("public/css/main.css.map",
+               `"sourcesContent":["p { background: red; }"`,
+               "AAAA;AAAI,cAAY;AAAK",
+       )
+
+       r = strings.NewReplacer(
+               "SOURCE_MAP", "external",
+               "SOURCES_CONTENT", "true",
+       )
+
+       files = r.Replace(filesTemplate)
+
+       b = hugolib.Test(t, files, hugolib.TestOptOsFs())
+       b.AssertFileContent("public/css/main.css", "! sourceMappingURL")
+       b.AssertFileContent("public/css/main.css.map",
+               `"sourcesContent":["p { background: red; }"`,
+               "AAAA;AAAI,cAAY;AAAK",
+       )
+
+       r = strings.NewReplacer(
+               "SOURCE_MAP", "external",
+               "SOURCES_CONTENT", "false",
+       )
+
+       files = r.Replace(filesTemplate)
+
+       b = hugolib.Test(t, files, hugolib.TestOptOsFs())
+       b.AssertFileContent("public/css/main.css.map",
+               `"sourcesContent":null,"`,
+               "AAAA;AAAI,cAAY;AAAK",
+       )
+
+       r = strings.NewReplacer(
+               "SOURCE_MAP", "inline",
+               "SOURCES_CONTENT", "false",
+       )
+
+       files = r.Replace(filesTemplate)
+
+       b = hugolib.Test(t, files, hugolib.TestOptOsFs())
+       b.AssertFileExists("public/css/main.css.map", false)
+       b.AssertFileContent("public/css/main.css", "sourceMappingURL=data:application/json;base64,")
+}
+
+func TestCSSBuildMultihost(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term"]
+defaultContentLanguage = "en"
+defaultContentLanguageInSubDir = true
+[languages]
+[languages.en]
+baseURL = "https://example.en"
+weight = 1
+[languages.fr]
+baseURL = "https://example.fr"
+weight = 2
+-- assets/a/pixel.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+-- assets/css/main.css --
+body {
+  background-image: url("a/pixel.png");
+  color: red;
+}
+-- layouts/home.html --
+{{ with resources.Get "css/main.css"  }}
+{{ with . | css.Build (dict "minify" true)  }}
+CSS: {{ .RelPermalink }}|{{ .Content }}
+{{ end }}
+{{ end }}
+`
+
+       b := hugolib.Test(t, files, hugolib.TestOptOsFs())
+
+       for _, lang := range []string{"en", "fr"} {
+               b.AssertFileContent("public/"+lang+"/css/main.css", `./pixel-NJRUOINY.png`)
+               b.AssertFileExists("public/"+lang+"/css/pixel-NJRUOINY.png", true)
+       }
+}
+
+func TestCSSBuildLoadersDefault(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+-- assets/a/pixel.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+-- assets/css/main.css --
+body {
+  background-image: url("a/pixel.png");
+}
+-- layouts/home.html --
+{{ with resources.Get "css/main.css"  }}
+{{ with . | css.Build (dict "minify" true)  }}
+ <link rel="stylesheet" href="{{ .RelPermalink }}" />
+{{ end }}
+{{ end }}
+`
+
+       b := hugolib.Test(t, files, hugolib.TestOptOsFs())
+       b.AssertFileContent("public/css/main.css", `./pixel-NJRUOINY.png`)
+       b.AssertFileExists("public/css/pixel-NJRUOINY.png", true)
+}
+
+func TestCSSBuildBootstrapFromNPM(t *testing.T) {
+       htesting.SkipSlowTestUnlessCI(t)
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term"]
+-- assets/css/main.css --
+@import "bootstrap";
+-- package.json --
+{
+       "devDependencies": {
+               "bootstrap": "5.3.8"
+       }
+}
+-- layouts/home.html --
+{{ with resources.Get "css/main.css"  }}
+{{ with . | css.Build (dict "minify" true "mainFields" (slice "style"))  }}
+        CSS size: {{ .Content | len }}|{{ .RelPermalink }}
+{{ end }}
+{{ end }}
+`
+
+       b := hugolib.Test(t, files, hugolib.TestOptOsFs(), hugolib.TestOptWithNpmInstall())
+       b.AssertFileContent("public/css/main.css", `--bs-indigo: #6610f2;`)
+}
index 9a2761f510534bdb51b191b8288ef8433056d1d3..64fc45ea6b2ac7480ddd07444a7bd449c34ca0a9 100644 (file)
@@ -15,6 +15,7 @@ import (
        "github.com/gohugoio/hugo/resources/resource"
        "github.com/gohugoio/hugo/resources/resource_transformers/babel"
        "github.com/gohugoio/hugo/resources/resource_transformers/cssjs"
+       jstransform "github.com/gohugoio/hugo/resources/resource_transformers/js"
        "github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass"
        "github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass"
        "github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss"
@@ -32,6 +33,7 @@ type Namespace struct {
        postcssClient     *cssjs.PostCSSClient
        tailwindcssClient *cssjs.TailwindCSSClient
        babelClient       *babel.Client
+       jsTransformClient *jstransform.Client
 
        // The Dart Client requires a os/exec process, so  only
        // create it if we really need it.
@@ -147,6 +149,33 @@ func (ns *Namespace) Sass(args ...any) (resource.Resource, error) {
        return client.ToCSS(r, m)
 }
 
+// Build processes the given CSS Resource with ESBuild.
+// Note that this method is identical to the one in the js Namespace.
+func (ns *Namespace) Build(args ...any) (resource.Resource, error) {
+       var (
+               r          resources.ResourceTransformer
+               m          map[string]any
+               targetPath string
+               err        error
+               ok         bool
+       )
+
+       r, targetPath, ok = resourcehelpers.ResolveIfFirstArgIsString(args)
+
+       if !ok {
+               r, m, err = resourcehelpers.ResolveArgs(args)
+               if err != nil {
+                       return nil, err
+               }
+       }
+
+       if targetPath != "" {
+               m = map[string]any{"targetPath": targetPath}
+       }
+
+       return ns.jsTransformClient.Process(r, m)
+}
+
 func init() {
        f := func(d *deps.Deps) *internal.TemplateFuncsNamespace {
                scssClient, err := scss.New(d.BaseFs.Assets, d.ResourceSpec)
@@ -159,6 +188,7 @@ func init() {
                        postcssClient:     cssjs.NewPostCSSClient(d.ResourceSpec),
                        tailwindcssClient: cssjs.NewTailwindCSSClient(d.ResourceSpec),
                        babelClient:       babel.New(d.ResourceSpec),
+                       jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec, true),
                }
 
                ns := &internal.TemplateFuncsNamespace{
@@ -181,6 +211,11 @@ func init() {
                        [][2]string{},
                )
 
+               ns.AddMethodMapping(ctx.Build,
+                       nil,
+                       [][2]string{},
+               )
+
                ns.AddMethodMapping(ctx.TailwindCSS,
                        nil,
                        [][2]string{},
index dfd0a358118ed353c7574368b29827b0dc4b1173..16acf56836c54ee4288bc1e0b2b6efdf2a83fbb8 100644 (file)
@@ -36,7 +36,7 @@ func New(d *deps.Deps) (*Namespace, error) {
 
        return &Namespace{
                d:                 d,
-               jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec),
+               jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec, false),
                createClient:      create.New(d.ResourceSpec),
                babelClient:       babel.New(d.ResourceSpec),
        }, nil
@@ -51,7 +51,7 @@ type Namespace struct {
        babelClient       *babel.Client
 }
 
-// Build processes the given Resource with ESBuild.
+// Build processes the given JavaScript Resource with ESBuild.
 func (ns *Namespace) Build(args ...any) (resource.Resource, error) {
        var (
                r          resources.ResourceTransformer