mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-24 15:28:54 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'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 = "$"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user