Add importContext option to css.Build, js.Build, css.Sass and css.TailwindCSS

This allows @import statements to be resolved in a set of user provided resources (e.g. from resources.FromString or css.ChromaStyles) before the assets filesystem.

The option also applies to css.PostCSS via the shared import inlining, and css.Sass requires the dartsass transpiler. The import context is part of the transformation cache key.

Fixes #15103

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bjørn Erik Pedersen
2026-08-03 12:14:54 +02:00
parent 8a468df065
commit 70db201ed4
28 changed files with 697 additions and 110 deletions
@@ -14,6 +14,7 @@
package cssjs
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -30,6 +31,8 @@ import (
"github.com/gohugoio/hugo/common/text"
"github.com/gohugoio/hugo/hugofs"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/resources"
"github.com/gohugoio/hugo/resources/resource"
"github.com/spf13/afero"
)
@@ -46,10 +49,12 @@ type fileOffset struct {
}
type importResolver struct {
ctx context.Context
r io.Reader
inPath string
opts InlineImports
importContext resource.ResourceGetter
contentSeen map[string]bool
dependencyManager identity.Manager
linemap map[int]fileOffset
@@ -57,8 +62,9 @@ type importResolver struct {
logger loggers.Logger
}
func newImportResolver(r io.Reader, inPath string, opts InlineImports, fs afero.Fs, logger loggers.Logger, dependencyManager identity.Manager) *importResolver {
return &importResolver{
func newImportResolver(ctx context.Context, r io.Reader, inPath string, opts InlineImports, fs afero.Fs, logger loggers.Logger, dependencyManager identity.Manager) *importResolver {
imp := &importResolver{
ctx: ctx,
r: r,
dependencyManager: dependencyManager,
inPath: inPath,
@@ -66,6 +72,10 @@ func newImportResolver(r io.Reader, inPath string, opts InlineImports, fs afero.
linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool),
opts: opts,
}
if opts.ImportContext != nil {
imp.importContext = resource.NewCachedResourceGetter(opts.ImportContext)
}
return imp
}
func (imp *importResolver) contentHash(filename string) ([]byte, string) {
@@ -73,9 +83,13 @@ func (imp *importResolver) contentHash(filename string) ([]byte, string) {
if err != nil {
return nil, ""
}
return b, hashBytes(b)
}
func hashBytes(b []byte) string {
h := sha256.New()
h.Write(b)
return b, hex.EncodeToString(h.Sum(nil))
return hex.EncodeToString(h.Sum(nil))
}
func (imp *importResolver) importRecursive(
@@ -105,8 +119,35 @@ func (imp *importResolver) importRecursive(
} else {
path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';")
filename := filepath.Join(basePath, path)
imp.dependencyManager.AddIdentity(identity.CleanStringIdentity(filename))
importContent, hash := imp.contentHash(filename)
var (
importContent []byte
hash string
nestedInPath string
)
if imp.importContext != nil {
// Try first the path as written in the import statement,
// then resolved relative to the importing file.
for _, name := range []string{path, filepath.ToSlash(filename)} {
r := imp.importContext.Get(name)
if r == nil {
continue
}
imp.dependencyManager.AddIdentity(identity.FirstIdentity(r))
s, err := resources.InternalResourceSourceContent(imp.ctx, r)
if err != nil {
return 0, "", err
}
importContent, hash = []byte(s), hashBytes([]byte(s))
nestedInPath = name
break
}
}
if importContent == nil {
imp.dependencyManager.AddIdentity(identity.CleanStringIdentity(filename))
importContent, hash = imp.contentHash(filename)
nestedInPath = filepath.ToSlash(filename)
}
if importContent == nil {
if imp.opts.SkipInlineImportsNotFound {
@@ -135,7 +176,7 @@ func (imp *importResolver) importRecursive(
imp.contentSeen[hash] = true
// Handle recursive imports.
l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename))
l, nested, err := imp.importRecursive(i+lineNum, string(importContent), nestedInPath)
if err != nil {
return 0, "", err
}
@@ -14,6 +14,7 @@
package cssjs
import (
"context"
"regexp"
"strings"
"testing"
@@ -21,10 +22,12 @@ import (
"github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/htesting/hqt"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/resources/resource"
"github.com/gohugoio/hugo/helpers"
"github.com/spf13/afero"
"github.com/spf13/cast"
qt "github.com/frankban/quicktest"
)
@@ -103,6 +106,7 @@ LOCAL_STYLE
@import "e.css";`)
imp := newImportResolver(
t.Context(),
mainStyles,
"styles.css",
InlineImports{},
@@ -130,6 +134,70 @@ E_STYLE`)
})
}
func TestImportResolverImportContext(t *testing.T) {
c := qt.New(t)
fs := afero.NewMemMapFs()
writeFile := func(name, content string) {
c.Assert(afero.WriteFile(fs, name, []byte(content), 0o777), qt.IsNil)
}
// Loses to the import context entry with the same name.
writeFile("css/a.css", "A_STYLE_FS")
writeFile("css/b.css", "B_STYLE_FS")
importContext := testImportContext{
"a.css": "@import \"c.css\";\nA_STYLE",
"c.css": "C_STYLE",
"css/d.css": "D_STYLE",
}
mainStyles := strings.NewReader(`@import "a.css";
@import "b.css";
@import "./d.css";
LOCAL_STYLE`)
imp := newImportResolver(
t.Context(),
mainStyles,
"css/styles.css",
InlineImports{ImportContext: importContext},
fs, loggers.NewDefault(),
identity.NopManager,
)
r, err := imp.resolve()
c.Assert(err, qt.IsNil)
rs := helpers.ReaderToString(r)
result := regexp.MustCompile(`\n+`).ReplaceAllString(rs, "\n")
c.Assert(result, hqt.IsSameString, `C_STYLE
A_STYLE
B_STYLE_FS
D_STYLE
LOCAL_STYLE`)
}
type testImportContext map[string]string
func (g testImportContext) Get(name any) resource.Resource {
s := cast.ToString(name)
if content, found := g[s]; found {
return testImportContextResource{name: s, content: content}
}
return nil
}
type testImportContextResource struct {
resource.Resource
name string
content string
}
func (r testImportContextResource) Name() string { return r.name }
func (r testImportContextResource) Content(context.Context) (any, error) { return r.content, nil }
func BenchmarkImportResolver(b *testing.B) {
c := qt.New(b)
fs := afero.NewMemMapFs()
@@ -161,6 +229,7 @@ LOCAL_STYLE
for b.Loop() {
b.StopTimer()
imp := newImportResolver(
b.Context(),
strings.NewReader(mainStyles),
"styles.css",
InlineImports{},
@@ -87,6 +87,10 @@ type InlineImports struct {
// Note that the inline importer does not process url location or imports with media queries,
// so those will be left as-is even without enabling this option.
SkipInlineImportsNotFound bool
// User provided import context. If set, imports are looked up here first,
// by the path as written in the @import statement, then in the assets filesystem.
ImportContext any
}
// Some of the options from https://github.com/postcss/postcss-cli
@@ -213,6 +217,7 @@ func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationC
src := ctx.From
imp := newImportResolver(
ctx.Ctx,
ctx.From,
ctx.InPath,
options.InlineImports,
@@ -124,6 +124,7 @@ func (t *tailwindcssTransformation) Transform(ctx *resources.ResourceTransformat
src := ctx.From
imp := newImportResolver(
ctx.Ctx,
ctx.From,
ctx.InPath,
options.InlineImports,
@@ -64,6 +64,45 @@ CSS: {{ $css.Content | safeCSS }}|
b.AssertFileContent("public/index.html", "/*! tailwindcss v4.")
}
// See issue 15103.
func TestTailwindCSSImportContext(t *testing.T) {
t.Parallel()
htesting.SkipSlowTestUnlessCI(t)
files := `
-- hugo.toml --
disableKinds = ['page','rss','section','sitemap','taxonomy','term']
-- assets/css/main.css --
@import "tailwindcss";
@import "foo.css";
@import "bar.css";
-- assets/css/foo.css --
.foo {color: orange;}
-- layouts/home.html --
{{ $foo := resources.FromString "foo.css" ".foo {color: blue;}" }}
{{ $bar := resources.FromString "bar.css" ".bar {color: green;}" }}
{{ $opts := dict "importContext" (slice $foo $bar) }}
{{ $css := resources.Get "css/main.css" | css.TailwindCSS $opts }}
CSS: {{ $css.Content | safeCSS }}|
-- package.json --
{
"devDependencies": {
"@tailwindcss/cli": "^4.1.7",
"tailwindcss": "^4.1.7"
}
}
`
b := hugolib.Test(t, files, hugolib.TestOptOsFs(), hugolib.TestOptWithNpmInstall(), hugolib.TestOptInfo())
// foo.css resolves in the import context before the assets filesystem.
b.AssertFileContent("public/index.html",
".foo {\n color: blue;\n }",
".bar {\n color: green;\n }",
)
}
func TestTailwindCSSNoInlineImportsIssue13719(t *testing.T) {
t.Parallel()
htesting.SkipSlowTestUnlessCI(t)
@@ -42,6 +42,8 @@ func New(fs *filesystems.SourceFilesystem, rs *resources.Spec, cssMode bool) *Cl
}
// Process processes a resource with the user provided options.
// If importContext is not nil, imports are resolved in it first,
// then in the assets filesystem (the same precedence as in js.Batch).
func (c *Client) Process(res resources.ResourceTransformer, opts map[string]any) (resource.Resource, error) {
return res.Transform(
&buildTransformation{c: c, optsm: opts},
@@ -18,10 +18,14 @@ import (
"path"
"path/filepath"
"github.com/evanw/esbuild/pkg/api"
"github.com/gohugoio/hugo/common/hmaps"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/internal/js/esbuild"
"github.com/gohugoio/hugo/media"
"github.com/gohugoio/hugo/resources"
"github.com/gohugoio/hugo/resources/internal"
"github.com/gohugoio/hugo/resources/resource"
)
type buildTransformation struct {
@@ -69,6 +73,29 @@ func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx
opts.MediaType = ctx.InMediaType
opts.Stdin = true
opts.IsCSS = t.c.c.CssMode
var ic resource.ResourceGetter
if opts.ImportContext != nil {
ic = resource.NewCachedResourceGetter(opts.ImportContext)
}
if ic != nil {
resolved := hmaps.NewCache[string, resource.Resource]()
opts.ImportOnResolveFunc = func(imp string, args api.OnResolveArgs) string {
if r := esbuild.ResolveResource(imp, ic); r != nil {
p := esbuild.PrefixHugoVirtual + resources.InternalResourceTargetPath(r)
resolved.Set(p, r)
ctx.DependencyManager.AddIdentity(identity.FirstIdentity(r))
return p
}
return ""
}
opts.ImportOnLoadFunc = func(args api.OnLoadArgs) (string, error) {
if r, found := resolved.Get(args.Path); found {
return resources.InternalResourceSourceContent(ctx.Ctx, r)
}
return "", nil
}
}
_, err = t.c.transform(opts, ctx)
@@ -158,6 +158,10 @@ type Options struct {
// $color: vars.$color;
Vars map[string]any
// User provided import context. If set, imports are looked up here first,
// then in the assets filesystem.
ImportContext any
// Deprecations IDs in this slice will be silenced.
// The IDs can be found in the Dart Sass log output, e.g. "import" in
// WARN Dart Sass: DEPRECATED [import].
@@ -51,6 +51,35 @@ T1: {{ $r.Content }}
b.AssertFileContent("public/index.html", `T1: moo{color:#fff}`)
}
// See issue 15103.
func TestTransformImportContext(t *testing.T) {
t.Parallel()
if !dartsass.Supports() {
t.Skip()
}
files := `
-- hugo.toml --
-- assets/scss/_foo.scss --
body { color: orange; }
-- assets/scss/main.scss --
@import "foo";
@import "bar";
-- layouts/home.html --
{{ $foo := resources.FromString "foo.scss" "body { color: blue; }" }}
{{ $bar := resources.FromString "bar.scss" "@import \"baz\";\nbody { color: green; }" }}
{{ $baz := resources.FromString "baz.scss" "p { color: red; }" }}
{{ $opts := dict "transpiler" "dartsass" "outputStyle" "compressed" "importContext" (slice $foo $bar $baz) }}
{{ $r := resources.Get "scss/main.scss" | css.Sass $opts }}
T1: {{ $r.Content }}
`
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
// foo resolves in the import context before the assets filesystem.
b.AssertFileContent("public/index.html", `T1: body{color:blue}p{color:red}body{color:green}`)
}
func TestTransformImportRegularCSS(t *testing.T) {
t.Parallel()
if !dartsass.Supports() {
@@ -14,6 +14,7 @@
package dartsass
import (
"context"
"fmt"
"io"
"path"
@@ -27,6 +28,7 @@ import (
"github.com/gohugoio/hugo/media"
"github.com/gohugoio/hugo/resources"
"github.com/gohugoio/hugo/resources/resource"
"github.com/gohugoio/hugo/resources/internal"
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass"
@@ -38,6 +40,10 @@ import (
"github.com/bep/godartsass/v2"
)
// Prefix for canonical URLs of stylesheets resolved in the user provided import context.
// Note: This prefix must be all lower case.
const dartSassImportContextPrefix = "hugoimportcontext:"
// Supports returns whether sass, dart-sass, or dart-sass-embedded is found in $PATH.
func Supports() bool {
if htesting.SupportsAll() {
@@ -76,6 +82,11 @@ func (t *transform) Transform(ctx *resources.ResourceTransformationCtx) error {
filename += t.c.sfs.RealFilename(ctx.SourcePath)
}
var ic resource.ResourceGetter
if opts.ImportContext != nil {
ic = resource.NewCachedResourceGetter(opts.ImportContext)
}
args := godartsass.Args{
URL: filename,
IncludePaths: t.c.sfs.RealDirs(baseDir),
@@ -83,6 +94,8 @@ func (t *transform) Transform(ctx *resources.ResourceTransformationCtx) error {
baseDir: baseDir,
c: t.c,
dependencyManager: ctx.DependencyManager,
importContext: ic,
ctx: ctx.Ctx,
vars: opts.Vars,
},
@@ -132,6 +145,8 @@ type importResolver struct {
baseDir string
c *Client
dependencyManager identity.Manager
importContext resource.ResourceGetter
ctx context.Context
vars map[string]any
}
@@ -140,6 +155,11 @@ func (t importResolver) CanonicalizeURL(url string) (string, error) {
return strings.ToLower(url), nil
}
if r := t.resolveInImportContext(url); r != nil {
t.dependencyManager.AddIdentity(identity.FirstIdentity(r))
return dartSassImportContextPrefix + paths.ToSlashTrimLeading(resources.InternalResourceTargetPath(r)), nil
}
filePath, isURL := paths.UrlStringToFilename(url)
var prevDir string
var pathDir string
@@ -160,21 +180,7 @@ func (t importResolver) CanonicalizeURL(url string) (string, error) {
name := filepath.Base(filePath)
// Pick the first match.
var namePatterns []string
if strings.Contains(name, ".") {
namePatterns = []string{"_%s", "%s"}
} else if strings.HasPrefix(name, "_") {
namePatterns = []string{"_%s.scss", "_%s.sass", "_%s.css"}
} else {
namePatterns = []string{
"_%s.scss", "%s.scss",
"_%s.sass", "%s.sass",
"_%s.css", "%s.css",
"%s/_index.scss", "%s/_index.sass",
"%s/index.scss", "%s/index.sass",
}
}
namePatterns := sassNamePatterns(name)
name = strings.TrimPrefix(name, "_")
for _, namePattern := range namePatterns {
@@ -192,21 +198,72 @@ func (t importResolver) CanonicalizeURL(url string) (string, error) {
return "", nil
}
func sassNamePatterns(name string) []string {
if strings.Contains(name, ".") {
return []string{"_%s", "%s"}
}
if strings.HasPrefix(name, "_") {
return []string{"_%s.scss", "_%s.sass", "_%s.css"}
}
return []string{
"_%s.scss", "%s.scss",
"_%s.sass", "%s.sass",
"_%s.css", "%s.css",
"%s/_index.scss", "%s/_index.sass",
"%s/index.scss", "%s/index.sass",
}
}
// resolveInImportContext resolves url in the user provided import context, if any,
// using the same name patterns as for the file system.
func (t importResolver) resolveInImportContext(url string) resource.Resource {
if t.importContext == nil {
return nil
}
url = strings.TrimPrefix(url, dartSassImportContextPrefix)
if r := t.importContext.Get(url); r != nil {
return r
}
dir, name := path.Split(url)
namePatterns := sassNamePatterns(name)
name = strings.TrimPrefix(name, "_")
for _, namePattern := range namePatterns {
if r := t.importContext.Get(path.Join(dir, fmt.Sprintf(namePattern, name))); r != nil {
return r
}
}
return nil
}
func (t importResolver) Load(url string) (godartsass.Import, error) {
if subPath, ok := sass.HugoVarsSubPath(url); ok {
return godartsass.Import{
Content: sass.CreateVarsStyleSheet(sass.TranspilerDart, sass.ResolveVars(t.vars, subPath)),
}, nil
}
if strings.HasPrefix(url, dartSassImportContextPrefix) {
r := t.resolveInImportContext(url)
if r == nil {
return godartsass.Import{}, fmt.Errorf("could not find %q in the import context", url)
}
content, err := resources.InternalResourceSourceContent(t.ctx, r)
return godartsass.Import{Content: content, SourceSyntax: sassSourceSyntax(url)}, err
}
filename, _ := paths.UrlStringToFilename(url)
b, err := afero.ReadFile(hugofs.Os, filename)
sourceSyntax := godartsass.SourceSyntaxSCSS
if strings.HasSuffix(filename, ".sass") {
sourceSyntax = godartsass.SourceSyntaxSASS
} else if strings.HasSuffix(filename, ".css") {
sourceSyntax = godartsass.SourceSyntaxCSS
}
return godartsass.Import{Content: string(b), SourceSyntax: sourceSyntax}, err
return godartsass.Import{Content: string(b), SourceSyntax: sassSourceSyntax(filename)}, err
}
func sassSourceSyntax(name string) godartsass.SourceSyntax {
switch {
case strings.HasSuffix(name, ".sass"):
return godartsass.SourceSyntaxSASS
case strings.HasSuffix(name, ".css"):
return godartsass.SourceSyntaxCSS
default:
return godartsass.SourceSyntaxSCSS
}
}