}
}
+// 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) {
NsBatch = "_hugo-js-batch"
propsKeyImportContext = "importContext"
- propsResoure = "resource"
)
//go:embed batch-esm-runner.gotmpl
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](),
)
// 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.
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)
}
}
- if m := assetsResolver.resolveComponent(s); m != nil {
+ if m := assetsResolver.resolveComponent(s, false); m != nil {
return m.Filename
}
"encoding/json"
"fmt"
"path/filepath"
+ "sort"
"strings"
"github.com/gohugoio/hugo/common/hmaps"
"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,
}
)
+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{
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
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
// 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
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
}
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
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
AbsWorkingDir: opts.AbsWorkingDir,
Target: target,
+ Engines: engines,
Format: format,
+ MainFields: opts.MainFields,
Platform: platform,
Sourcemap: sourceMap,
SourcesContent: sourcesContent,
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
}
opts = Options{
ExternalOptions: ExternalOptions{
- Target: "es2018",
+ Target: []string{"es2018"},
Format: "cjs",
Minify: true,
AvoidTDZ: true,
opts = Options{
ExternalOptions: ExternalOptions{
- Target: "es2018", Format: "cjs", Minify: true,
+ Target: []string{"es2018"}, Format: "cjs", Minify: true,
SourceMap: "inline",
},
InternalOptions: InternalOptions{
opts = Options{
ExternalOptions: ExternalOptions{
- Target: "es2018", Format: "cjs", Minify: true,
+ Target: []string{"es2018"}, Format: "cjs", Minify: true,
SourceMap: "inline",
},
InternalOptions: InternalOptions{
opts = Options{
ExternalOptions: ExternalOptions{
- Target: "es2018", Format: "cjs", Minify: true,
+ Target: []string{"es2018"}, Format: "cjs", Minify: true,
SourceMap: "external",
},
InternalOptions: InternalOptions{
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,
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"})
+}
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.
}
base := filepath.Base(impPath)
+
if base == "index" {
// try index.esm.js etc.
v, found, _ = findFirst(impPath + ".esm")
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 {
}
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
})
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]
}
importer := args.Importer
-
isStdin := importer == stdinImporter
var relDir string
+
if !isStdin {
if after, ok := strings.CutPrefix(importer, PrefixHugoVirtual); ok {
relDir = filepath.Dir(after)
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
}
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
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"
package js
import (
+ "fmt"
"path"
+ "path/filepath"
"regexp"
+ "strings"
"github.com/evanw/esbuild/pkg/api"
"github.com/gohugoio/hugo/hugolib/filesystems"
}
// 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),
}
}
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.")
+}
}
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
if opts.TargetPath != "" {
ctx.OutPath = opts.TargetPath
} else {
- ctx.ReplaceOutPathExtension(".js")
+ ctx.ReplaceOutPathExtension(ctx.OutMediaType.FirstSuffix.FullSuffix)
}
src, err := io.ReadAll(ctx.From)
opts.Contents = string(src)
opts.MediaType = ctx.InMediaType
opts.Stdin = true
+ opts.IsCSS = t.c.c.CssMode
_, err = t.c.transform(opts, ctx)
}
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")
// 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
}
// 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
}
--- /dev/null
+// 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;`)
+}
"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"
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.
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)
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{
[][2]string{},
)
+ ns.AddMethodMapping(ctx.Build,
+ nil,
+ [][2]string{},
+ )
+
ns.AddMethodMapping(ctx.TailwindCSS,
nil,
[][2]string{},
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
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