diff --git a/internal/js/esbuild/batch.go b/internal/js/esbuild/batch.go index dd5eb194e97..7e211c1d8b6 100644 --- a/internal/js/esbuild/batch.go +++ b/internal/js/esbuild/batch.go @@ -72,7 +72,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 33b91eafc94..c0dd6247f7f 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. diff --git a/internal/js/esbuild/options.go b/internal/js/esbuild/options.go index 310fa305dee..e6281e83a07 100644 --- a/internal/js/esbuild/options.go +++ b/internal/js/esbuild/options.go @@ -48,6 +48,20 @@ var ( "es2024": api.ES2024, } + nameEngine = 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, + } + // source names: https://github.com/evanw/esbuild/blob/9eca46464ed5615cb36a3beb3f7a7b9a8ffbe7cf/internal/config/config.go#L208 nameLoader = map[string]api.Loader{ "none": api.LoaderNone, @@ -117,6 +131,11 @@ type ExternalOptions struct { // Default is esnext. Target string + // Engines to target, e.g. "chrome58" or "node12". + // TODO1 sensible default if not set. For css only. + // See https://esbuild.github.io/api/#target + Engines []string + // The output format. // One of: iife, cjs, esm // Default is to esm. @@ -200,6 +219,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 @@ -224,6 +244,22 @@ func (opts *Options) compile() (err error) { return } + var engines []api.Engine + +OUTER: + for _, value := range opts.Engines { + for engine, name := range nameEngine { + 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: name, Version: version}) + continue OUTER + } + } + } + var loaders map[string]api.Loader if opts.Loaders != nil { loaders = make(map[string]api.Loader) @@ -252,6 +288,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,6 +381,7 @@ func (opts *Options) compile() (err error) { AbsWorkingDir: opts.AbsWorkingDir, Target: target, + Engines: engines, Format: format, Platform: platform, Sourcemap: sourceMap, @@ -394,6 +433,10 @@ func (o Options) loaderFromFilename(filename string) api.Loader { if found { return l } + if o.IsCSS { + // For CSS builds, handling, default to the file loader for unknown extensions. + return api.LoaderFile + } return api.LoaderJS } diff --git a/internal/js/esbuild/resolve.go b/internal/js/esbuild/resolve.go index e66c72c7bae..8b377ea8af8 100644 --- a/internal/js/esbuild/resolve.go +++ b/internal/js/esbuild/resolve.go @@ -59,9 +59,9 @@ var extensionToLoaderMap = map[string]api.Loader{ } // This is a common sub-set of ESBuild's default extensions. -// We assume that imports of JSON, CSS etc. will be using their full +// We assume that imports of JSON etc. will be using their full // name with extension. -var commonExtensions = []string{".js", ".ts", ".tsx", ".jsx"} +var commonExtensions = []string{".js", ".ts", ".tsx", ".jsx", ".css"} // ResolveComponent resolves a component using the given resolver. func ResolveComponent[T any](impPath string, resolve func(string) (v T, found, isDir bool)) (v T, found bool) { @@ -93,6 +93,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") @@ -159,6 +160,7 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) { impPath := args.Path + shimmed := false if opts.Shims != nil { override, found := opts.Shims[impPath] @@ -182,9 +184,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) @@ -219,12 +221,16 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana 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 have no effect for CSS imports, so skip those. + if !opts.IsCSS { + // 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/resources/resource_transformers/js/build.go b/resources/resource_transformers/js/build.go index bd943461f0d..f0f205f9279 100644 --- a/resources/resource_transformers/js/build.go +++ b/resources/resource_transformers/js/build.go @@ -15,6 +15,7 @@ package js import ( "path" + "path/filepath" "regexp" "github.com/evanw/esbuild/pkg/api" @@ -31,9 +32,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 +57,42 @@ 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" + + var idxContentFiles int + + if hasSourceMap { + idxContentFiles++ if err = transformCtx.PublishSourceMap(string(result.OutputFiles[0].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 { + } + + // Any files loaded with the file loader comes next. + // TODO1 verify that we can rely on order here, else look in result.Metafile. + outDir := path.Dir(transformCtx.OutPath) + for i := idxContentFiles; i < len(result.OutputFiles)-1; i++ { + file := result.OutputFiles[i] + basePath := path.Base(filepath.ToSlash(file.Path)) + target := path.Join(outDir, basePath) + if err = transformCtx.PublishTo(target, string(file.Contents)); err != nil { return result, err } + } + + // Write the main output. + main := result.OutputFiles[len(result.OutputFiles)-1].Contents + if hasLinkedSourceMap { + symPath := path.Base(transformCtx.OutPath) + ".map" + re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`) + main = re.ReplaceAll(main, []byte("//# sourceMappingURL="+symPath+"\n")) + } + if _, err = transformCtx.To.Write(main); err != nil { + return result, err } + return result, nil } diff --git a/resources/resource_transformers/js/transform.go b/resources/resource_transformers/js/transform.go index 13909e54cc6..07933a92f1e 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/transform.go b/resources/transform.go index 169c50895e9..6f628c045df 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -148,6 +148,11 @@ func (ctx *ResourceTransformationCtx) AddOutPathIdentifier(identifier string) { // with the ".map" extension added. func (ctx *ResourceTransformationCtx) PublishSourceMap(content string) 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 string) error { f, err := ctx.OpenResourcePublisher(target) if err != nil { return err diff --git a/tpl/css/build_integration_test.go b/tpl/css/build_integration_test.go new file mode 100644 index 00000000000..05c52f8521b --- /dev/null +++ b/tpl/css/build_integration_test.go @@ -0,0 +1,83 @@ +// 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 ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestCSSBuild(t *testing.T) { + 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 './qux.css' supports(display: grid); +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/qux.css -- +article { background: yellow; } +-- layouts/home.html -- +{{ with resources.Get "css/main.css" }} +{{ $engines := slice "chrome80" "firefox73" "safari13" "edge80" }} +{{ with . | css.Build (dict "minify" true "engines" $engines "loaders" (dict ".png" "dataurl") "externals" (slice "tailwindcss")) }} + +{{ end }} +{{ end }} + ` + + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + b.AssertFileContent("public/css/main.css", `webkit`) +} + +func TestCSSBuildLoadersDefault(t *testing.T) { + 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) +} diff --git a/tpl/css/css.go b/tpl/css/css.go index 9a2761f5105..322ae697db3 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{ diff --git a/tpl/js/js.go b/tpl/js/js.go index dfd0a358118..16acf56836c 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