From 7622dd86ced9ac2ef3c15b5d0740bf9634bb60ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Sun, 26 Apr 2026 14:03:39 +0200 Subject: [PATCH] css: Support nested hugo:vars/ imports Allow CSS variables to be grouped under sub-paths and imported via @import "hugo:vars/mobile" (or @use for Dart Sass), so callers can pass nested dicts like: {{ dict "primary-color" "blue" "mobile" (dict "primary-color" "red") }} Top-level "hugo:vars" now skips nested map entries instead of emitting garbage for them. Fixes #14705 Co-Authored-By: Claude Opus 4.7 (1M context) --- common/hstrings/strings.go | 9 +++ common/hstrings/strings_test.go | 10 +++ internal/js/esbuild/options.go | 3 + internal/js/esbuild/resolve.go | 17 ++-- .../tocss/dartsass/client.go | 3 + .../dartsass/dartsass_integration_test.go | 80 +++++++++++++++++++ .../tocss/dartsass/transform.go | 14 ++-- .../tocss/sass/helpers.go | 74 +++++++++++++++++ tpl/css/build_integration_test.go | 68 ++++++++++++++++ 9 files changed, 266 insertions(+), 12 deletions(-) diff --git a/common/hstrings/strings.go b/common/hstrings/strings.go index 29580e146..ee7a62a3a 100644 --- a/common/hstrings/strings.go +++ b/common/hstrings/strings.go @@ -107,6 +107,15 @@ func HasAnyPrefix(s string, prefixes ...string) bool { return false } +func HasUppercase(s string) bool { + for _, r := range s { + if 'A' <= r && r <= 'Z' { + return true + } + } + return false +} + // InSlice checks if a string is an element of a slice of strings // and returns a boolean value. func InSlice(arr []string, el string) bool { diff --git a/common/hstrings/strings_test.go b/common/hstrings/strings_test.go index 3150ada07..8cb5b3db5 100644 --- a/common/hstrings/strings_test.go +++ b/common/hstrings/strings_test.go @@ -71,6 +71,16 @@ func TestUniqueStringsSorted(t *testing.T) { c.Assert(UniqueStringsSorted(nil), qt.IsNil) } +func TestHasUppercase(t *testing.T) { + c := qt.New(t) + + c.Assert(HasUppercase("abc"), qt.Equals, false) + c.Assert(HasUppercase("Abc"), qt.Equals, true) + c.Assert(HasUppercase("aBc"), qt.Equals, true) + c.Assert(HasUppercase("abC"), qt.Equals, true) + c.Assert(HasUppercase("ABC"), qt.Equals, true) +} + // Note that these cannot use b.Loop() because of golang/go#27217. func BenchmarkUniqueStrings(b *testing.B) { input := []string{"a", "b", "d", "e", "d", "h", "a", "i"} diff --git a/internal/js/esbuild/options.go b/internal/js/esbuild/options.go index 716e4e7cd..246e70701 100644 --- a/internal/js/esbuild/options.go +++ b/internal/js/esbuild/options.go @@ -24,6 +24,7 @@ import ( "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass" "github.com/evanw/esbuild/pkg/api" @@ -420,6 +421,8 @@ OUTER: opts.MainFields = []string{"style", "main"} } + opts.Vars = sass.PrepareVars(opts.Vars) + opts.compiled = api.BuildOptions{ Outfile: outFile, Bundle: true, diff --git a/internal/js/esbuild/resolve.go b/internal/js/esbuild/resolve.go index 45fd4e405..9d4cffe68 100644 --- a/internal/js/esbuild/resolve.go +++ b/internal/js/esbuild/resolve.go @@ -29,6 +29,7 @@ import ( "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass" "github.com/spf13/afero" ) @@ -377,7 +378,7 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana varsPlugin := api.Plugin{ Name: "hugo-vars-plugin", Setup: func(build api.PluginBuild) { - build.OnResolve(api.OnResolveOptions{Filter: `^hugo:vars$`}, + build.OnResolve(api.OnResolveOptions{Filter: `^hugo:vars(/|$)`}, func(args api.OnResolveArgs) (api.OnResolveResult, error) { return api.OnResolveResult{ Path: args.Path, @@ -386,8 +387,9 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana }) build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsHugoVars}, func(args api.OnLoadArgs) (api.OnLoadResult, error) { + subPath, _ := sass.HugoVarsSubPath(args.Path) return api.OnLoadResult{ - Contents: createCSSVarsStyleSheet(opts.Vars), + Contents: createCSSVarsStyleSheet(opts.Vars, subPath), Loader: api.LoaderCSS, }, nil }) @@ -398,16 +400,19 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana } // createCSSVarsStyleSheet creates a CSS custom properties stylesheet from the given vars. -// The result is a :root block with CSS custom properties. -func createCSSVarsStyleSheet(vars map[string]any) *string { - if len(vars) == 0 { +// The result is a :root block with CSS custom properties. If subPath is non-empty, +// vars is navigated using the slash-separated path before emitting properties; nested +// map values are skipped at the resolved level. +func createCSSVarsStyleSheet(vars map[string]any, subPath string) *string { + resolved := sass.ResolveVars(vars, subPath) + if len(resolved) == 0 { // We need to return a non-nil pointer to an empty string to avoid ESBuild treating this as a missing file. s := "" return &s } var varsSlice []string - for k, v := range vars { + for k, v := range resolved { if !strings.HasPrefix(k, "--") { k = "--" + k } diff --git a/resources/resource_transformers/tocss/dartsass/client.go b/resources/resource_transformers/tocss/dartsass/client.go index 965232ad4..cd01b12ee 100644 --- a/resources/resource_transformers/tocss/dartsass/client.go +++ b/resources/resource_transformers/tocss/dartsass/client.go @@ -30,6 +30,7 @@ import ( "github.com/gohugoio/hugo/hugolib/filesystems" "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass" "github.com/spf13/afero" "github.com/mitchellh/mapstructure" @@ -178,5 +179,7 @@ func decodeOptions(m map[string]any) (opts Options, err error) { opts.TargetPath = paths.ToSlashTrimLeading(opts.TargetPath) } + opts.Vars = sass.PrepareVars(opts.Vars) + return } diff --git a/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go b/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go index 81591be71..72e012667 100644 --- a/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go +++ b/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go @@ -371,6 +371,86 @@ T1: {{ $r.Content }} b.AssertFileContent("public/index.html", `T1: body body{background:url(images/hero.jpg) no-repeat center/cover;font-family:Hugo's New Roman}p{color:blue;font-size:24px}b{color:green}`) } +func TestOptionVarsNestedIssue14705(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- assets/scss/main.scss -- +@use "hugo:vars"; +@use "hugo:vars/mobile" as mobile; + +body { + color: vars.$color1; + font-size: vars.$font_size; +} + +@media (max-width: 650px) { + body { + color: mobile.$color1; + font-size: mobile.$font_size; + } +} +-- layouts/home.html -- +{{ $vars := dict + "color1" "blue" + "font_size" "16px" + "mobile" (dict "color1" "red" "font_size" "12px") +}} +{{ $cssOpts := (dict "transpiler" "dartsass" "outputStyle" "compressed" "vars" $vars ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts }} +T1: {{ $r.Content }} + ` + + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + + b.AssertFileContent("public/index.html", `T1: body{color:blue;font-size:16px}@media(max-width: 650px){body{color:red;font-size:12px}}`) +} + +func TestOptionVarsNestedFromParamsIssue14705(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- hugo.toml -- +[params] +[params.sassvars] +color1 = "blue" +font_size = "16px" +[params.sassvars.mobile] +color1 = "red" +font_size = "12px" +-- assets/scss/main.scss -- +@use "hugo:vars"; +@use "hugo:vars/mobile" as mobile; + +body { + color: vars.$color1; + font-size: vars.$font_size; +} + +@media (max-width: 650px) { + body { + color: mobile.$color1; + font-size: mobile.$font_size; + } +} +-- layouts/home.html -- +{{ $vars := site.Params.sassvars }} +{{ $cssOpts := (dict "transpiler" "dartsass" "outputStyle" "compressed" "vars" $vars ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts }} +T1: {{ $r.Content }} + ` + + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + + b.AssertFileContent("public/index.html", `T1: body{color:blue;font-size:16px}@media(max-width: 650px){body{color:red;font-size:12px}}`) +} + func TestOptionVarsParams(t *testing.T) { t.Parallel() if !dartsass.Supports() { diff --git a/resources/resource_transformers/tocss/dartsass/transform.go b/resources/resource_transformers/tocss/dartsass/transform.go index d199e5cdf..e3d5823e9 100644 --- a/resources/resource_transformers/tocss/dartsass/transform.go +++ b/resources/resource_transformers/tocss/dartsass/transform.go @@ -84,7 +84,7 @@ func (t *transform) Transform(ctx *resources.ResourceTransformationCtx) error { c: t.c, dependencyManager: ctx.DependencyManager, - varsStylesheet: godartsass.Import{Content: sass.CreateVarsStyleSheet(sass.TranspilerDart, opts.Vars)}, + vars: opts.Vars, }, OutputStyle: godartsass.ParseOutputStyle(opts.OutputStyle), EnableSourceMap: opts.EnableSourceMap, @@ -132,12 +132,12 @@ type importResolver struct { baseDir string c *Client dependencyManager identity.Manager - varsStylesheet godartsass.Import + vars map[string]any } func (t importResolver) CanonicalizeURL(url string) (string, error) { - if url == sass.HugoVarsNamespace { - return url, nil + if _, ok := sass.HugoVarsSubPath(url); ok { + return strings.ToLower(url), nil } filePath, isURL := paths.UrlStringToFilename(url) @@ -193,8 +193,10 @@ func (t importResolver) CanonicalizeURL(url string) (string, error) { } func (t importResolver) Load(url string) (godartsass.Import, error) { - if url == sass.HugoVarsNamespace { - return t.varsStylesheet, nil + if subPath, ok := sass.HugoVarsSubPath(url); ok { + return godartsass.Import{ + Content: sass.CreateVarsStyleSheet(sass.TranspilerDart, sass.ResolveVars(t.vars, subPath)), + }, nil } filename, _ := paths.UrlStringToFilename(url) b, err := afero.ReadFile(hugofs.Os, filename) diff --git a/resources/resource_transformers/tocss/sass/helpers.go b/resources/resource_transformers/tocss/sass/helpers.go index d4091a39c..cb8cabd07 100644 --- a/resources/resource_transformers/tocss/sass/helpers.go +++ b/resources/resource_transformers/tocss/sass/helpers.go @@ -15,10 +15,14 @@ package sass import ( "fmt" + "maps" "regexp" "sort" "strings" + "github.com/gohugoio/hugo/common/hmaps" + "github.com/gohugoio/hugo/common/hreflect" + "github.com/gohugoio/hugo/common/hstrings" "github.com/gohugoio/hugo/common/types/css" ) @@ -31,6 +35,72 @@ const ( TranspilerLibSass = "libsass" ) +// HugoVarsSubPath returns the slash-separated sub-path of a "hugo:vars" URL, +// e.g. "hugo:vars" -> "" and "hugo:vars/mobile" -> "mobile". The second return +// is false if url is not in the "hugo:vars" namespace. +func HugoVarsSubPath(url string) (string, bool) { + if url == HugoVarsNamespace { + return "", true + } + if rest, ok := strings.CutPrefix(url, HugoVarsNamespace+"/"); ok { + return rest, true + } + return "", false +} + +// PrepareVars lowercases all keys for any map value recursively and returns a clone if modified. +func PrepareVars(vars map[string]any) map[string]any { + if vars == nil { + return nil + } + + // Lowercase all keys for map values recursively, so that they can be accessed case-insensitively from the stylesheet. + var isCloned bool + for k, v := range vars { + if hstrings.HasUppercase(k) && hreflect.IsMap(v) { + if !isCloned { + vars = maps.Clone(vars) + } + delete(vars, k) + vars[strings.ToLower(k)] = PrepareVars(hmaps.ToStringMap(v)) + isCloned = true + } + } + return vars +} + +// ResolveVars returns the entries of vars at the given slash-separated path. +// Nested map entries are excluded from the result, so only scalar/typed values remain. +// An empty path returns the top-level scalars. +func ResolveVars(vars map[string]any, path string) map[string]any { + if vars == nil { + return nil + } + if path == "" || path == "/" { + return removeMaps(vars) + } + vv, err := hmaps.GetNestedParam(path, "/", vars) + if err != nil { + return nil + } + + return removeMaps(hmaps.ToStringMap(vv)) +} + +func removeMaps(m map[string]any) map[string]any { + if m == nil { + return nil + } + res := make(map[string]any) + for k, v := range m { + if hreflect.IsMap(v) { + continue + } + res[k] = v + } + return res +} + func CreateVarsStyleSheet(transpiler string, vars map[string]any) string { if vars == nil { return "" @@ -39,6 +109,10 @@ func CreateVarsStyleSheet(transpiler string, vars map[string]any) string { var varsSlice []string for k, v := range vars { + if hreflect.IsMap(v) { + // Nested vars are exposed via "hugo:vars/" namespaces, skip here. + continue + } var prefix string if !strings.HasPrefix(k, "$") { prefix = "$" diff --git a/tpl/css/build_integration_test.go b/tpl/css/build_integration_test.go index b659b9965..4f8fbd405 100644 --- a/tpl/css/build_integration_test.go +++ b/tpl/css/build_integration_test.go @@ -465,6 +465,40 @@ body { ) } +func TestCSSBuildVarsNestedIssue14705(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- assets/css/main.css -- +@import "hugo:vars"; +@import "hugo:vars/mobile" (max-width: 650px); + +body { + background-color: var(--primary-color); +} +-- layouts/home.html -- +{{ with resources.Get "css/main.css" }} +{{ $opts := dict + "vars" (dict "primary-color" "blue" "mobile" (dict "primary-color" "red" "font-size" "12px")) +}} +{{ with . | css.Build $opts }} + +{{ end }} +{{ end }} +` + + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + b.AssertFileContent("public/css/main.css", + "--primary-color: blue;", + "@media (max-width: 650px)", + "--primary-color: red;", + "! --mobile:", + "--font-size: 12px;", + "background-color: var(--primary-color)", + ) +} + func TestCSSBuildVarsEmpty(t *testing.T) { t.Parallel() @@ -488,6 +522,40 @@ body { b.AssertFileContent("public/css/main.css", "background-color: red;") } +func TestCSSBuildVarsNestedUpperCase(t *testing.T) { + t.Parallel() + + filesTemplate := ` +-- hugo.toml -- +-- assets/css/main.css -- +@import "hugo:vars"; +@import "hugo:vars/MOBILE1" (max-width: 650px); + +body { + background-color: var(--primary-color); +} +-- layouts/home.html -- +{{ with resources.Get "css/main.css" }} +{{ $opts := dict + "vars" (dict "primary-color" "blue" "MOBILE2" (dict "primary-color" "red" "font-size" "12px" "MixedCaseKey" "value")) +}} +{{ with . | css.Build $opts }} + +{{ end }} +{{ end }} +` + + files := strings.ReplaceAll(filesTemplate, "MOBILE1", "Mobile") + files = strings.ReplaceAll(files, "MOBILE2", "mobile") + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + b.AssertFileContent("public/css/main.css", "primary-color: red;") + + files = strings.ReplaceAll(filesTemplate, "MOBILE1", "mobile") + files = strings.ReplaceAll(files, "MOBILE2", "MobilE") + b = hugolib.Test(t, files, hugolib.TestOptOsFs()) + b.AssertFileContent("public/css/main.css", "primary-color: red;") +} + func TestCSSBuildVarsQuoted(t *testing.T) { t.Parallel()