From 3e3b849cc758414262cffba370efcb810dfb1070 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Mon, 9 Mar 2026 11:16:36 +0100 Subject: [PATCH] Add css.Build Fixes #14609 Fixes #14613 --- hugolib/integrationtest_builder.go | 7 + internal/js/esbuild/batch.go | 3 +- internal/js/esbuild/build.go | 16 +- internal/js/esbuild/options.go | 102 +++++- internal/js/esbuild/options_test.go | 20 +- internal/js/esbuild/resolve.go | 67 +++- internal/js/esbuild/resolve_test.go | 2 +- .../resource_transformers/babel/babel.go | 2 +- resources/resource_transformers/js/build.go | 70 ++-- .../resource_transformers/js/transform.go | 12 +- .../tocss/dartsass/transform.go | 2 +- .../resource_transformers/tocss/scss/tocss.go | 2 +- resources/transform.go | 9 +- tpl/css/build_integration_test.go | 315 ++++++++++++++++++ tpl/css/css.go | 35 ++ tpl/js/js.go | 4 +- 16 files changed, 598 insertions(+), 70 deletions(-) create mode 100644 tpl/css/build_integration_test.go diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go index c08a86c47..d9f059903 100644 --- a/hugolib/integrationtest_builder.go +++ b/hugolib/integrationtest_builder.go @@ -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) { diff --git a/internal/js/esbuild/batch.go b/internal/js/esbuild/batch.go index dd5eb194e..28fc11ffa 100644 --- a/internal/js/esbuild/batch.go +++ b/internal/js/esbuild/batch.go @@ -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](), diff --git a/internal/js/esbuild/build.go b/internal/js/esbuild/build.go index 33b91eafc..cf88276c3 100644 --- a/internal/js/esbuild/build.go +++ b/internal/js/esbuild/build.go @@ -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 } diff --git a/internal/js/esbuild/options.go b/internal/js/esbuild/options.go index 310fa305d..55ef4bf39 100644 --- a/internal/js/esbuild/options.go +++ b/internal/js/esbuild/options.go @@ -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 } diff --git a/internal/js/esbuild/options_test.go b/internal/js/esbuild/options_test.go index e92c3bea6..6d430e9ab 100644 --- a/internal/js/esbuild/options_test.go +++ b/internal/js/esbuild/options_test.go @@ -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"}) +} diff --git a/internal/js/esbuild/resolve.go b/internal/js/esbuild/resolve.go index e66c72c7b..e9cfa7f9b 100644 --- a/internal/js/esbuild/resolve.go +++ b/internal/js/esbuild/resolve.go @@ -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 } diff --git a/internal/js/esbuild/resolve_test.go b/internal/js/esbuild/resolve_test.go index 86e3138f2..44e24d2d4 100644 --- a/internal/js/esbuild/resolve_test.go +++ b/internal/js/esbuild/resolve_test.go @@ -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 diff --git a/resources/resource_transformers/babel/babel.go b/resources/resource_transformers/babel/babel.go index e00969516..859bf5805 100644 --- a/resources/resource_transformers/babel/babel.go +++ b/resources/resource_transformers/babel/babel.go @@ -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" diff --git a/resources/resource_transformers/js/build.go b/resources/resource_transformers/js/build.go index bd943461f..41d9dd0dd 100644 --- a/resources/resource_transformers/js/build.go +++ b/resources/resource_transformers/js/build.go @@ -14,8 +14,11 @@ 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.") +} diff --git a/resources/resource_transformers/js/transform.go b/resources/resource_transformers/js/transform.go index 13909e54c..07933a92f 100644 --- a/resources/resource_transformers/js/transform.go +++ b/resources/resource_transformers/js/transform.go @@ -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) diff --git a/resources/resource_transformers/tocss/dartsass/transform.go b/resources/resource_transformers/tocss/dartsass/transform.go index e1e9b0be0..d199e5cdf 100644 --- a/resources/resource_transformers/tocss/dartsass/transform.go +++ b/resources/resource_transformers/tocss/dartsass/transform.go @@ -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") diff --git a/resources/resource_transformers/tocss/scss/tocss.go b/resources/resource_transformers/tocss/scss/tocss.go index 2976578af..ea8617140 100644 --- a/resources/resource_transformers/tocss/scss/tocss.go +++ b/resources/resource_transformers/tocss/scss/tocss.go @@ -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 } diff --git a/resources/transform.go b/resources/transform.go index 169c50895..50f8de30c 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -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 index 000000000..edfef721e --- /dev/null +++ b/tpl/css/build_integration_test.go @@ -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 }} + +{{ 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 }} + +{{ 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 }} + +{{ 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) }} + +{{ 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;`) +} diff --git a/tpl/css/css.go b/tpl/css/css.go index 9a2761f51..64fc45ea6 100644 --- a/tpl/css/css.go +++ b/tpl/css/css.go @@ -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{}, diff --git a/tpl/js/js.go b/tpl/js/js.go index dfd0a3581..16acf5683 100644 --- a/tpl/js/js.go +++ b/tpl/js/js.go @@ -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 -- 2.39.5