Repository: gohugoio/hugo (language: go)

Issue:
Add imports option in tocss
I suggest we add a new option to `toscss`, maybe named `imports`.

This would allow to override certain imports and return either a string or a `Resource`, e.g.:


```handlebars
{{ $imports := dict 
   "@myvars" `$color: blue;`
}}
{{ $opts := dict "imports" $imports }}
```

And in the SCSS file you would either use/import the above:

```scss
@use "@myvars";
```

Note the at sign in the above is just a naming convention I used here, it can be anything.

As an added bonus, it would be nice if

* The `imports` value could also be a dict (with variable key/values I assume), so one could do `{{ $opts := dict "imports" (dict "@myvars" site.Params.my_scss_vars) }}`

This is related to another issue where people seem to assume that resources created via `resources.FromString` and similar would be visible to the SCSS importer etc., which I guess could be expected -- but that is harder to fix than the above.

/cc @jmooring 



Discussion / hints:
I think you nailed it with your "added bonus" example. Naming the map "imports" _might_ lead people to believe they can somehow import a file, so perhaps name it "varImports" instead. I'm fine either way.


I took it for a theoretical spin:

layouts/partials/func/get-scss-vars.html

```
{{ $heroImageUrl := "" }}
{{ with resources.Get "images/hero.jpg" }}
  {{ with .Fill "1920x1280 webp" }}
    {{ $heroImageUrl = .RelPermalink }}
  {{ end }}
{{ end }}

{{ $textColor := or (site.Params.style.textColor) "#222" }}
{{ $baseFontSize := or (site.Params.style.baseFontSize) "18px" }}

{{ $varImports := dict "@myvars" (dict
    "$hero-image-url" $heroImageUrl
    "$text-color" (or (site.Params.style.textColor) "#222")
    "$base-font-size" (or (site.Params.style.baseFontSize) "18px")
  )
}}

{{ return $varImports }}
```

layouts/_default/baseof.html

```text
{{ $opts := dict
  "targetPath" "css/style.css"
  "transpiler" "dartsass"
  "varImports" (partialCached "func/get-scss-vars.html" .)
}}

{{ with resources.Get "scss/main.scss" }}
  {{ with . | toCSS $opts }}
    <link rel="stylesheet" href="{{ .RelPermalink }}">
  {{ end }}
{{ end }}
```

This would be so much cleaner than having to define the vars in the root Sass import with `.ExecuteAsTemplate`.






>Naming the map "imports" might lead people to believe they can somehow import a file, so perhaps name it "varImports" instead. I'm fine either way.

Technically it's a way of resolving an import to something that's not mounted inside `assets`. The most common use case would be variable definitions, but I'm not sure we'd want to make that a restriction. People are going to mistake any property name, I suspect, but we still need to call a spade for a spade. But I'm open to better suggestions.

> "varImports" (partialCached "func/get-scss-vars.html" .)

Note that in my suggested set up, you need to 1) Give the import a name and 2) Actually import it in the file you want.

I did think about a slightly more limited/restricted feature that would maybe be easier to understand:

```
{{ $opts := dict
  "targetPath" "css/style.css"
  "transpiler" "dartsass"
  "vars" (dict "$color" "blue")
}}
```

Where we (Hugo) just injects the import at the top of the entry file (or maybe see if the Dart Sass api have some support for this).

Thinking about he explanation/documentation work needed for this, I think I like the last one better ...
> I'm not sure we'd want to make that a restriction

Then "imports" is fine.

> I think I like the last one better

If it is possible, great! But if not, what you've outlined is really convenient, despite the nested map. 

:+1: :+1: :+1: 

Fix patch:
diff --git a/resources/resource_transformers/tocss/dartsass/client.go b/resources/resource_transformers/tocss/dartsass/client.go
index f5f42a6dce3..49cafb52d6b 100644
--- a/resources/resource_transformers/tocss/dartsass/client.go
+++ b/resources/resource_transformers/tocss/dartsass/client.go
@@ -93,6 +93,7 @@ func (c *Client) toCSS(args godartsass.Args, src io.Reader) (godartsass.Result,
 	var res godartsass.Result
 
 	in := helpers.ReaderToString(src)
+
 	args.Source = in
 
 	res, err := c.transpiler.Execute(args)
@@ -130,6 +131,11 @@ type Options struct {
 
 	// If enabled, sources will be embedded in the generated source map.
 	SourceMapIncludeSources bool
+
+	// Vars will be available in 'hugo:vars', e.g:
+	//     @use "hugo:vars";
+	//     $color: vars.$color;
+	Vars map[string]string
 }
 
 func decodeOptions(m map[string]any) (opts Options, err error) {
diff --git a/resources/resource_transformers/tocss/dartsass/transform.go b/resources/resource_transformers/tocss/dartsass/transform.go
index 3aca916fc2c..1a5b81b47c3 100644
--- a/resources/resource_transformers/tocss/dartsass/transform.go
+++ b/resources/resource_transformers/tocss/dartsass/transform.go
@@ -1,4 +1,4 @@
-// Copyright 2020 The Hugo Authors. All rights reserved.
+// Copyright 2022 The Hugo Authors. All rights reserved.
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -28,6 +28,7 @@ import (
 	"github.com/gohugoio/hugo/resources"
 
 	"github.com/gohugoio/hugo/resources/internal"
+	"github.com/gohugoio/hugo/resources/resource_transformers/tocss/internal/sass"
 
 	"github.com/spf13/afero"
 
@@ -84,6 +85,8 @@ func (t *transform) Transform(ctx *resources.ResourceTransformationCtx) error {
 		ImportResolver: importResolver{
 			baseDir: baseDir,
 			c:       t.c,
+
+			varsStylesheet: sass.CreateVarsStyleSheet(opts.Vars),
 		},
 		OutputStyle:             godartsass.ParseOutputStyle(opts.OutputStyle),
 		EnableSourceMap:         opts.EnableSourceMap,
@@ -128,9 +131,14 @@ func (t *transform) Transform(ctx *resources.ResourceTransformationCtx) error {
 type importResolver struct {
 	baseDir string
 	c       *Client
+
+	varsStylesheet string
 }
 
 func (t importResolver) CanonicalizeURL(url string) (string, error) {
+	if url == sass.HugoVarsNamespace {
+		return url, nil
+	}
 	filePath, isURL := paths.UrlToFilename(url)
 	var prevDir string
 	var pathDir string
@@ -177,6 +185,9 @@ func (t importResolver) CanonicalizeURL(url string) (string, error) {
 }
 
 func (t importResolver) Load(url string) (string, error) {
+	if url == sass.HugoVarsNamespace {
+		return t.varsStylesheet, nil
+	}
 	filename, _ := paths.UrlToFilename(url)
 	b, err := afero.ReadFile(hugofs.Os, filename)
 	return string(b), err
diff --git a/resources/resource_transformers/tocss/internal/sass/helpers.go b/resources/resource_transformers/tocss/internal/sass/helpers.go
new file mode 100644
index 00000000000..8128fd4d765
--- /dev/null
+++ b/resources/resource_transformers/tocss/internal/sass/helpers.go
@@ -0,0 +1,48 @@
+// Copyright 2022 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sass
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+)
+
+const (
+	HugoVarsNamespace = "hugo:vars"
+)
+
+func CreateVarsStyleSheet(vars map[string]string) string {
+	if vars == nil {
+		return ""
+	}
+	var varsStylesheet string
+
+	var varsSlice []string
+	for k, v := range vars {
+		var prefix string
+		if !strings.HasPrefix(k, "$") {
+			prefix = "$"
+		}
+		// These variables can be a combination of Sass identifiers (e.g. sans-serif), which
+		// should not be quoted, and URLs et, which should be quoted.
+		// unquote() is knowing what to do with each.
+		varsSlice = append(varsSlice, fmt.Sprintf("%s%s: unquote('%s');", prefix, k, v))
+	}
+	sort.Strings(varsSlice)
+	varsStylesheet = strings.Join(varsSlice, "\n")
+
+	return varsStylesheet
+
+}
diff --git a/resources/resource_transformers/tocss/scss/client.go b/resources/resource_transformers/tocss/scss/client.go
index ecaceaa6c2b..0d027d8880b 100644
--- a/resources/resource_transformers/tocss/scss/client.go
+++ b/resources/resource_transformers/tocss/scss/client.go
@@ -60,6 +60,10 @@ type Options struct {
 
 	// When enabled, Hugo will generate a source map.
 	EnableSourceMap bool
+
+	// Vars will be available in 'hugo:vars', e.g:
+	//     @import "hugo:vars";
+	Vars map[string]string
 }
 
 func DecodeOptions(m map[string]any) (opts Options, err error) {
diff --git a/resources/resource_transformers/tocss/scss/tocss.go b/resources/resource_transformers/tocss/scss/tocss.go
index 10bd1f6f8d8..7e44f327e6f 100644
--- a/resources/resource_transformers/tocss/scss/tocss.go
+++ b/resources/resource_transformers/tocss/scss/tocss.go
@@ -31,6 +31,7 @@ import (
 	"github.com/gohugoio/hugo/hugofs"
 	"github.com/gohugoio/hugo/media"
 	"github.com/gohugoio/hugo/resources"
+	"github.com/gohugoio/hugo/resources/resource_transformers/tocss/internal/sass"
 )
 
 // Used in tests. This feature requires Hugo to be built with the extended tag.
@@ -63,11 +64,17 @@ func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx
 		}
 	}
 
+	varsStylesheet := sass.CreateVarsStyleSheet(options.from.Vars)
+
 	// To allow for overrides of SCSS files anywhere in the project/theme hierarchy, we need
 	// to help libsass revolve the filename by looking in the composite filesystem first.
 	// We add the entry directories for both project and themes to the include paths list, but
 	// that only work for overrides on the top level.
 	options.to.ImportResolver = func(url string, prev string) (newUrl string, body string, resolved bool) {
+		if url == sass.HugoVarsNamespace {
+			return url, varsStylesheet, true
+		}
+
 		// We get URL paths from LibSASS, but we need file paths.
 		url = filepath.FromSlash(url)
 		prev = filepath.FromSlash(prev)


Test patch:
diff --git a/resources/resource_transformers/tocss/dartsass/integration_test.go b/resources/resource_transformers/tocss/dartsass/integration_test.go
index c127057a5a4..083cef14f3a 100644
--- a/resources/resource_transformers/tocss/dartsass/integration_test.go
+++ b/resources/resource_transformers/tocss/dartsass/integration_test.go
@@ -24,6 +24,7 @@ import (
 )
 
 func TestTransformIncludePaths(t *testing.T) {
+	t.Parallel()
 	if !dartsass.Supports() {
 		t.Skip()
 	}
@@ -55,6 +56,7 @@ T1: {{ $r.Content }}
 }
 
 func TestTransformImportRegularCSS(t *testing.T) {
+	t.Parallel()
 	if !dartsass.Supports() {
 		t.Skip()
 	}
@@ -108,6 +110,7 @@ T1: {{ $r.Content | safeHTML }}
 }
 
 func TestTransformThemeOverrides(t *testing.T) {
+	t.Parallel()
 	if !dartsass.Supports() {
 		t.Skip()
 	}
@@ -169,6 +172,7 @@ zoo {
 }
 
 func TestTransformLogging(t *testing.T) {
+	t.Parallel()
 	if !dartsass.Supports() {
 		t.Skip()
 	}
@@ -200,6 +204,7 @@ T1: {{ $r.Content }}
 }
 
 func TestTransformErrors(t *testing.T) {
+	t.Parallel()
 	if !dartsass.Supports() {
 		t.Skip()
 	}
@@ -271,3 +276,93 @@ T1: {{ $r.Content }}
 	})
 
 }
+
+func TestOptionVars(t *testing.T) {
+	t.Parallel()
+	if !dartsass.Supports() {
+		t.Skip()
+	}
+
+	files := `
+-- assets/scss/main.scss --
+@use "hugo:vars";
+
+body {
+	body {
+		background: url(vars.$image) no-repeat center/cover;
+	  }	  
+}
+
+p {
+	color: vars.$color1;
+	font-size: vars.$font_size;
+}
+
+b {
+	color: vars.$color2;
+}
+-- layouts/index.html --
+{{ $image := "images/hero.jpg" }}
+{{ $vars := dict "$color1" "blue" "$color2" "green" "font_size" "24px" "image" $image }}
+{{ $cssOpts := (dict "transpiler" "dartsass" "outputStyle" "compressed" "vars" $vars ) }}
+{{ $r := resources.Get "scss/main.scss" |  toCSS $cssOpts }}
+T1: {{ $r.Content }}
+	`
+
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+			NeedsOsFS:   true,
+		}).Build()
+
+	b.AssertFileContent("public/index.html", `T1: body body{background:url(images/hero.jpg) no-repeat center/cover}p{color:blue;font-size:24px}b{color:green}`)
+}
+
+func TestOptionVarsParams(t *testing.T) {
+	t.Parallel()
+	if !dartsass.Supports() {
+		t.Skip()
+	}
+
+	files := `
+-- config.toml --
+[params]
+[params.sassvars]
+color1 = "blue"
+color2 = "green"
+font_size = "24px"
+image = "images/hero.jpg"
+-- assets/scss/main.scss --
+@use "hugo:vars";
+
+body {
+	body {
+		background: url(vars.$image) no-repeat center/cover;
+	  }	  
+}
+
+p {
+	color: vars.$color1;
+	font-size: vars.$font_size;
+}
+
+b {
+	color: vars.$color2;
+}
+-- layouts/index.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.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+			NeedsOsFS:   true,
+		}).Build()
+
+	b.AssertFileContent("public/index.html", `T1: body body{background:url(images/hero.jpg) no-repeat center/cover}p{color:blue;font-size:24px}b{color:green}`)
+}
diff --git a/resources/resource_transformers/tocss/scss/integration_test.go b/resources/resource_transformers/tocss/scss/integration_test.go
index 13b664cc723..799c70ee591 100644
--- a/resources/resource_transformers/tocss/scss/integration_test.go
+++ b/resources/resource_transformers/tocss/scss/integration_test.go
@@ -25,6 +25,7 @@ import (
 )
 
 func TestTransformIncludePaths(t *testing.T) {
+	t.Parallel()
 	if !scss.Supports() {
 		t.Skip()
 	}
@@ -57,6 +58,7 @@ T1: {{ $r.Content }}
 }
 
 func TestTransformImportRegularCSS(t *testing.T) {
+	t.Parallel()
 	if !scss.Supports() {
 		t.Skip()
 	}
@@ -113,6 +115,7 @@ moo {
 }
 
 func TestTransformThemeOverrides(t *testing.T) {
+	t.Parallel()
 	if !scss.Supports() {
 		t.Skip()
 	}
@@ -175,6 +178,7 @@ zoo {
 }
 
 func TestTransformErrors(t *testing.T) {
+	t.Parallel()
 	if !scss.Supports() {
 		t.Skip()
 	}
@@ -245,3 +249,45 @@ T1: {{ $r.Content }}
 	})
 
 }
+
+func TestOptionVars(t *testing.T) {
+	t.Parallel()
+	if !scss.Supports() {
+		t.Skip()
+	}
+
+	files := `
+-- assets/scss/main.scss --
+@import "hugo:vars";
+
+body {
+	body {
+		background: url($image) no-repeat center/cover;
+	  }	  
+}
+
+p {
+	color: $color1;
+	font-size: var$font_size;
+}
+
+b {
+	color: $color2;
+}
+-- layouts/index.html --
+{{ $image := "images/hero.jpg" }}
+{{ $vars := dict "$color1" "blue" "$color2" "green" "font_size" "24px" "image" $image }}
+{{ $cssOpts := (dict "transpiler" "libsass" "outputStyle" "compressed" "vars" $vars ) }}
+{{ $r := resources.Get "scss/main.scss" |  toCSS $cssOpts }}
+T1: {{ $r.Content }}
+	`
+
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+			NeedsOsFS:   true,
+		}).Build()
+
+	b.AssertFileContent("public/index.html", `T1: body body{background:url(images/hero.jpg) no-repeat center/cover}p{color:blue;font-size:var 24px}b{color:green}`)
+}