css: Support nested hugo:vars/<name> 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) <noreply@anthropic.com>
This commit is contained in:
Bjørn Erik Pedersen
2026-04-26 14:03:39 +02:00
parent 0814059bb6
commit 7622dd86ce
9 changed files with 266 additions and 12 deletions
+9
View File
@@ -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 {
+10
View File
@@ -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"}
+3
View File
@@ -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,
+11 -6
View File
@@ -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
}
@@ -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
}
@@ -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&#39;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() {
@@ -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)
@@ -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/<name>" namespaces, skip here.
continue
}
var prefix string
if !strings.HasPrefix(k, "$") {
prefix = "$"
+68
View File
@@ -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 }}
<link rel="stylesheet" href="{{ .RelPermalink }}" />
{{ 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 }}
<link rel="stylesheet" href="{{ .RelPermalink }}" />
{{ 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()