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