]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
tpl/css: Support @import "hugo:vars" for CSS custom properties in css.Build
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Wed, 1 Apr 2026 15:20:43 +0000 (17:20 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Thu, 2 Apr 2026 19:02:13 +0000 (21:02 +0200)
Closes #14699

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
internal/js/esbuild/options.go
internal/js/esbuild/resolve.go
tpl/css/build_integration_test.go

index 1e522f0ea099b222834018d0fd3ad84fbeef4c33..716e4e7cd619a2278e3135efafef69b87304d953 100644 (file)
@@ -207,6 +207,9 @@ type ExternalOptions struct {
        // See https://esbuild.github.io/api/#jsx-import-source
        JSXImportSource string
 
+       // User defined CSS variables. Will be available as CSS global scope CSS variables via @import "hugo:vars".
+       Vars map[string]any
+
        // There is/was a bug in WebKit with severe performance issue with the tracking
        // of TDZ checks in JavaScriptCore.
        //
index 4349bdcc26742563eee4b3f5c29bfd0f56cab574..45fd4e405217bcdfcb4ddc37b8eaa3bfeaa65326 100644 (file)
@@ -19,10 +19,12 @@ import (
        "os"
        "path/filepath"
        "slices"
+       "sort"
        "strings"
 
        "github.com/evanw/esbuild/pkg/api"
        "github.com/gohugoio/hugo/common/hmaps"
+       "github.com/gohugoio/hugo/common/types/css"
        "github.com/gohugoio/hugo/hugofs"
        "github.com/gohugoio/hugo/identity"
        "github.com/gohugoio/hugo/resources"
@@ -34,12 +36,13 @@ const (
        NsHugoImport            = "ns-hugo-imp"
        NsHugoImportResolveFunc = "ns-hugo-imp-func"
        nsHugoParams            = "ns-hugo-params"
+       nsHugoVars              = "ns-hugo-vars"
        pathHugoConfigParams    = "@params/config"
 
        stdinImporter = "<stdin>"
 )
 
-var hugoNamespaces = []string{NsHugoImport, NsHugoImportResolveFunc, nsHugoParams}
+var hugoNamespaces = []string{NsHugoImport, NsHugoImportResolveFunc, nsHugoParams, nsHugoVars}
 
 const (
        PrefixHugoVirtual = "__hu_v"
@@ -371,5 +374,54 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
                },
        }
 
-       return []api.Plugin{importResolver, paramsPlugin}, nil
+       varsPlugin := api.Plugin{
+               Name: "hugo-vars-plugin",
+               Setup: func(build api.PluginBuild) {
+                       build.OnResolve(api.OnResolveOptions{Filter: `^hugo:vars$`},
+                               func(args api.OnResolveArgs) (api.OnResolveResult, error) {
+                                       return api.OnResolveResult{
+                                               Path:      args.Path,
+                                               Namespace: nsHugoVars,
+                                       }, nil
+                               })
+                       build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsHugoVars},
+                               func(args api.OnLoadArgs) (api.OnLoadResult, error) {
+                                       return api.OnLoadResult{
+                                               Contents: createCSSVarsStyleSheet(opts.Vars),
+                                               Loader:   api.LoaderCSS,
+                                       }, nil
+                               })
+               },
+       }
+
+       return []api.Plugin{importResolver, paramsPlugin, varsPlugin}, nil
+}
+
+// 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 {
+               // 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 {
+               if !strings.HasPrefix(k, "--") {
+                       k = "--" + k
+               }
+
+               switch v.(type) {
+               case css.QuotedString:
+                       // E.g. Arial, sans-serif.
+                       varsSlice = append(varsSlice, fmt.Sprintf("  %s: %q;", k, v))
+               default:
+                       varsSlice = append(varsSlice, fmt.Sprintf("  %s: %v;", k, v))
+               }
+       }
+       sort.Strings(varsSlice)
+       s := ":root {\n" + strings.Join(varsSlice, "\n") + "\n}\n"
+
+       return &s
 }
index 78682ff8352491428391f7524e20042918b53f37..b659b9965ceb36677f58d64199d8a441ce745b81 100644 (file)
@@ -430,3 +430,128 @@ disableKinds = ["taxonomy", "term"]
        b := hugolib.Test(t, files, hugolib.TestOptOsFs(), hugolib.TestOptWithNpmInstall())
        b.AssertFileContent("public/css/main.css", `--bs-indigo: #6610f2;`)
 }
+
+func TestCSSBuildVars(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+-- assets/css/main.css --
+@import "hugo:vars";
+
+body {
+  background-color: var(--primary-color);
+  font-size: var(--font-size);
+  color: var(--text-color);
+}
+-- layouts/home.html --
+{{ with resources.Get "css/main.css" }}
+{{ $opts := dict
+  "vars" (dict "primary-color" "blue" "font-size" "24px" "text-color" "#333" "--already-prefixed" "red")
+}}
+{{ 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;",
+               "--font-size: 24px;",
+               "--text-color: #333;",
+               "--already-prefixed: red;",
+               "background-color: var(--primary-color)",
+       )
+}
+
+func TestCSSBuildVarsEmpty(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+-- assets/css/main.css --
+@import "hugo:vars";
+
+body {
+  background-color: red;
+}
+-- layouts/home.html --
+{{ with resources.Get "css/main.css" }}
+{{ with . | css.Build }}
+ <link rel="stylesheet" href="{{ .RelPermalink }}" />
+{{ end }}
+{{ end }}
+`
+
+       b := hugolib.Test(t, files, hugolib.TestOptOsFs())
+       b.AssertFileContent("public/css/main.css", "background-color: red;")
+}
+
+func TestCSSBuildVarsQuoted(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+-- assets/css/main.css --
+@import "hugo:vars";
+
+body {
+  font-family: var(--font-family);
+  color: var(--brand-color);
+}
+-- layouts/home.html --
+{{ with resources.Get "css/main.css" }}
+{{ $opts := dict
+  "vars" (dict "font-family" (css.Quoted "Arial, sans-serif") "brand-color" "hsl(0, 0%, 20%)")
+}}
+{{ with . | css.Build $opts }}
+ <link rel="stylesheet" href="{{ .RelPermalink }}" />
+{{ end }}
+{{ end }}
+`
+
+       b := hugolib.Test(t, files, hugolib.TestOptOsFs())
+       b.AssertFileContent("public/css/main.css",
+               `--font-family: "Arial, sans-serif";`,
+               "--brand-color: hsl(0, 0%, 20%);",
+       )
+}
+
+func TestCSSBuildVarsFromParams(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+[params.styles]
+primary-color = "blue"
+font-size = "24px"
+text-color = "#333"
+-- assets/css/main.css --
+@import "hugo:vars";
+-- assets/css/main.css --
+@import "hugo:vars";
+
+body {
+  background-color: var(--primary-color);
+  font-size: var(--font-size);
+  color: var(--text-color);
+}
+-- layouts/home.html --
+{{ with resources.Get "css/main.css" }}
+{{ $opts := dict
+  "vars" site.Params.styles
+}}
+{{ 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;",
+               "--font-size: 24px;",
+               "--text-color: #333;",
+       )
+}