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:
@@ -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 = "$"
|
||||
|
||||
Reference in New Issue
Block a user