diff --git a/common/hashing/hashing.go b/common/hashing/hashing.go index ad28bbce2..1b0ed2768 100644 --- a/common/hashing/hashing.go +++ b/common/hashing/hashing.go @@ -18,6 +18,7 @@ import ( "crypto/md5" "encoding/hex" "io" + "reflect" "strconv" "sync" @@ -124,11 +125,38 @@ func HashStringHex(vs ...any) string { var hashOptsPool = sync.Pool{ New: func() any { return &hashstructure.HashOptions{ - Hasher: xxhash.New(), + Hasher: xxhash.New(), + UnwrapFunc: unwrapForHashing, } }, } +// hashstructure only sees exported struct fields, so rewrite known identity types before hashing, +// e.g. a Resource or Page nested in an options map hashes by its Key. +func unwrapForHashing(v reflect.Value) (reflect.Value, error) { + if v.Kind() != reflect.Struct { + return v, nil + } + var in any + if v.CanAddr() { + // The common case; pointer receiver methods on a struct + // reached through a pointer. + in = v.Addr().Interface() + } else { + in = v.Interface() + } + switch t := in.(type) { + case hashstructure.Hashable: + // Let hashstructure handle it. + return v, nil + case keyer: + return reflect.ValueOf(t.Key()), nil + case identity.IdentityProvider: + return reflect.ValueOf(t.GetIdentity()), nil + } + return v, nil +} + func getHashOpts() *hashstructure.HashOptions { return hashOptsPool.Get().(*hashstructure.HashOptions) } @@ -145,15 +173,10 @@ func putHashOpts(opts *hashstructure.HashOptions) { func HashUint64(vs ...any) uint64 { var o any if len(vs) == 1 { - o = toHashable(vs[0]) + o = vs[0] } else { - elements := make([]any, len(vs)) - for i, e := range vs { - elements[i] = toHashable(e) - } - o = elements + o = vs } - hash, err := Hash(o) if err != nil { panic(err) @@ -176,19 +199,6 @@ type keyer interface { Key() string } -// For structs, hashstructure.Hash only works on the exported fields, -// so rewrite the input slice for known identity types. -func toHashable(v any) any { - switch t := v.(type) { - case keyer: - return t.Key() - case identity.IdentityProvider: - return t.GetIdentity() - default: - return v - } -} - type xxhashReadFrom struct { buff []byte *xxhash.Digest diff --git a/go.mod b/go.mod index 6e4498c98..4c0250df0 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( github.com/gohugoio/gift v0.2.0 github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 github.com/gohugoio/go-radix v1.2.0 - github.com/gohugoio/hashstructure v0.6.0 + github.com/gohugoio/hashstructure v1.0.0 github.com/gohugoio/httpcache v0.8.0 github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 diff --git a/go.sum b/go.sum index 5401b44fa..f1dded847 100644 --- a/go.sum +++ b/go.sum @@ -274,6 +274,8 @@ github.com/gohugoio/go-radix v1.2.0 h1:D5GTk8jIoeXirBSc2P4E4NdHKDrenk9k9N0ctU5Yr github.com/gohugoio/go-radix v1.2.0/go.mod h1:k6vDa0ebpbpgtzSj9lPGJcA4AZwJ9xUNObUy2vczPFM= github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= +github.com/gohugoio/hashstructure v1.0.0 h1:vWYuyzs1n0LdI0F54TJQeYAiB44fHX7H9hCp9X6gHKg= +github.com/gohugoio/hashstructure v1.0.0/go.mod h1:FSbTK4QwxucJ2bC4Lvrs9a6x0DbQDXNoyBO+h4nlCgE= github.com/gohugoio/httpcache v0.8.0 h1:hNdsmGSELztetYCsPVgjA960zSa4dfEqqF/SficorCU= github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI= github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus= diff --git a/hugolib/page.go b/hugolib/page.go index 46c7e5c07..af1d65f1f 100644 --- a/hugolib/page.go +++ b/hugolib/page.go @@ -19,7 +19,6 @@ import ( "iter" "path/filepath" "slices" - "strconv" "strings" "sync/atomic" @@ -174,10 +173,6 @@ func (ps *pageState) Param(key any) (any, error) { return resource.Param(ps, ps.s.Params(), key) } -func (ps *pageState) Key() string { - return "page-" + strconv.FormatUint(ps.pid, 10) -} - // RelatedKeywords implements the related.Document interface needed for fast page searches. func (ps *pageState) RelatedKeywords(cfg related.IndexConfig) ([]related.Keyword, error) { v, found, err := page.NamedPageMetaValue(ps, cfg.Name) diff --git a/hugolib/page__output.go b/hugolib/page__output.go index 384ed5f9e..cafe012b7 100644 --- a/hugolib/page__output.go +++ b/hugolib/page__output.go @@ -149,6 +149,11 @@ func (po *pageOutput) Aliases() []string { return aliases } +// Key returns a unique key for this page output, used to e.g. hashing. +func (po *pageOutput) Key() string { + return po.p.Path() + po.f.Name +} + func (po *pageOutput) incrRenderState() { po.renderState++ po.renderOnce = true diff --git a/internal/js/esbuild/options.go b/internal/js/esbuild/options.go index 88a5405a1..4176438e8 100644 --- a/internal/js/esbuild/options.go +++ b/internal/js/esbuild/options.go @@ -183,6 +183,9 @@ type ExternalOptions struct { // Maps a component import to another. Shims map[string]string + // User provided import context. If set, we will look here first. + ImportContext any + // Configuring a loader for a given file type lets you load that file type with an // import statement or a require call. For example, configuring the .png file extension // to use the data URL loader means importing a .png file gives you a data URL diff --git a/internal/js/esbuild/resolve.go b/internal/js/esbuild/resolve.go index 3438af375..a0db7874d 100644 --- a/internal/js/esbuild/resolve.go +++ b/internal/js/esbuild/resolve.go @@ -260,6 +260,19 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana } } + if opts.ImportOnResolveFunc != nil { + // Relative imports resolved against the importing file's directory, + // e.g. "./foo.css". The path as written was tried above. + for _, p := range pathsToTry { + if p == impPath { + continue + } + if s := opts.ImportOnResolveFunc(filepath.ToSlash(p), args); s != "" { + return api.OnResolveResult{Path: s, Namespace: NsHugoImportResolveFunc}, nil + } + } + } + var m *hugofs.FileMeta for _, p := range pathsToTry { m = assetsResolver.resolveComponent(p, isCSSToken) diff --git a/resources/images/color.go b/resources/images/color.go index 47c2848b9..fe233c7b7 100644 --- a/resources/images/color.go +++ b/resources/images/color.go @@ -16,13 +16,12 @@ package images import ( "encoding/hex" "fmt" - "hash/fnv" "image/color" "math" + "slices" "strings" "github.com/gohugoio/hugo/common/hstrings" - "slices" ) type colorGoProvider interface { @@ -65,10 +64,8 @@ func (c Color) String() string { // For hashstructure. This struct is used in template func options // that needs to be able to hash a Color. // For internal use only. -func (c Color) Hash() (uint64, error) { - h := fnv.New64a() - h.Write([]byte(c.hex)) - return h.Sum64(), nil +func (c Color) Key() string { + return c.hex } func (c *Color) init() error { diff --git a/resources/internal/key.go b/resources/internal/key.go index b0ac9703f..3f8926d0c 100644 --- a/resources/internal/key.go +++ b/resources/internal/key.go @@ -13,7 +13,9 @@ package internal -import "github.com/gohugoio/hugo/common/hashing" +import ( + "github.com/gohugoio/hugo/common/hashing" +) // ResourceTransformationKey are provided by the different transformation implementations. // It identifies the transformation (name) and its configuration (elements). diff --git a/resources/resource.go b/resources/resource.go index cf6e1e597..69facb098 100644 --- a/resources/resource.go +++ b/resources/resource.go @@ -96,6 +96,9 @@ type ResourceSourceDescriptor struct { // Delay publishing until either Permalink or RelPermalink is called. Maybe never. LazyPublish bool + // Whether to include the hash of the source content in the resource key. + IncludeHashInKey bool + // Set when its known up front, else it's resolved from the target filename. MediaType media.Type diff --git a/resources/resource/resources.go b/resources/resource/resources.go index 28fbab42f..08ee0b7ac 100644 --- a/resources/resource/resources.go +++ b/resources/resource/resources.go @@ -20,6 +20,8 @@ import ( "slices" "strings" + "github.com/gohugoio/hashstructure" + "github.com/gohugoio/hugo/common/hashing" "github.com/gohugoio/hugo/common/hmaps" "github.com/gohugoio/hugo/common/hreflect" "github.com/gohugoio/hugo/common/paths" @@ -33,44 +35,56 @@ var _ ResourceFinder = (*Resources)(nil) // I.e. both pages and images etc. type Resources []Resource +type resourceMount struct { + R Resources + Base string + Target string +} + +func (r resourceMount) Get(namev any) Resource { + name1, err := cast.ToStringE(namev) + if err != nil { + panic(err) + } + + isTargetAbs := strings.HasPrefix(r.Target, "/") + + if r.Target != "" { + name1 = strings.TrimPrefix(name1, r.Target) + if !isTargetAbs { + name1 = paths.TrimLeading(name1) + } + } + + if r.Base != "" && isTargetAbs { + name1 = path.Join(r.Base, name1) + } + + for _, res := range r.R { + name2 := res.Name() + + if r.Base != "" && !isTargetAbs { + name2 = paths.TrimLeading(strings.TrimPrefix(name2, r.Base)) + } + + if strings.EqualFold(name1, name2) { + return res + } + + } + + return nil +} + // Mount mounts the given resources from base to the given target path. // Note that leading slashes in target marks an absolute path. -// This method is currently only useful in js.Batch. +// This method can be used in any of the template funcs that takes an importContext option, e.g. css.Build. func (r Resources) Mount(base, target string) ResourceGetter { - return resourceGetterFunc(func(namev any) Resource { - name1, err := cast.ToStringE(namev) - if err != nil { - panic(err) - } - - isTargetAbs := strings.HasPrefix(target, "/") - - if target != "" { - name1 = strings.TrimPrefix(name1, target) - if !isTargetAbs { - name1 = paths.TrimLeading(name1) - } - } - - if base != "" && isTargetAbs { - name1 = path.Join(base, name1) - } - - for _, res := range r { - name2 := res.Name() - - if base != "" && !isTargetAbs { - name2 = paths.TrimLeading(strings.TrimPrefix(name2, base)) - } - - if strings.EqualFold(name1, name2) { - return res - } - - } - - return nil - }) + return resourceMount{ + R: r, + Base: base, + Target: target, + } } type ResourcesProvider interface { @@ -276,12 +290,6 @@ type StaleInfoResourceGetter interface { ResourceGetter } -type resourceGetterFunc func(name any) Resource - -func (f resourceGetterFunc) Get(name any) Resource { - return f(name) -} - // ResourceFinder provides methods to find Resources. // Note that GetRemote (as found in resources.GetRemote) is // not covered by this interface, as this is only available as a global template function. @@ -318,6 +326,8 @@ type ResourceFinder interface { ByType(typ any) Resources } +var _ hashstructure.Hashable = (*cachedResourceGetter)(nil) + // NewCachedResourceGetter creates a new ResourceGetter from the given objects. // If multiple objects are provided, they are merged into one where // the first match wins. @@ -329,12 +339,19 @@ func NewCachedResourceGetter(os ...any) *cachedResourceGetter { } } + hash := hashing.HashUint64(getters) + return &cachedResourceGetter{ cache: hmaps.NewCache[string, Resource](), delegate: getters, + hash: hash, } } +func (c *cachedResourceGetter) Hash() (uint64, error) { + return c.hash, nil +} + type multiResourceGetter []ResourceGetter func (m multiResourceGetter) Get(name any) Resource { @@ -354,6 +371,7 @@ var ( type cachedResourceGetter struct { cache *hmaps.Cache[string, Resource] delegate ResourceGetter + hash uint64 } func (c *cachedResourceGetter) Get(name any) Resource { @@ -390,8 +408,6 @@ func unwrapResourceGetter(v any) (ResourceGetter, bool) { return vv, true case ResourcesProvider: return vv.Resources(), true - case func(name any) Resource: - return resourceGetterFunc(vv), true default: vvv, ok := hreflect.ToSliceAny(v) if !ok { diff --git a/resources/resource_factories/create/create.go b/resources/resource_factories/create/create.go index 4a4d14da6..abea8c982 100644 --- a/resources/resource_factories/create/create.go +++ b/resources/resource_factories/create/create.go @@ -304,8 +304,9 @@ func (c *Client) FromOpts(opts Options) (resource.Resource, error) { } return c.rs.NewResource( resources.ResourceSourceDescriptor{ - LazyPublish: true, - GroupIdentity: identity.Anonymous, // All usage of this resource are tracked via its string content. + LazyPublish: true, + IncludeHashInKey: !opts.TargetPathHasHash, + GroupIdentity: identity.Anonymous, // All usage of this resource are tracked via its string content. OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) { return newReadSeeker() }, diff --git a/resources/resource_spec.go b/resources/resource_spec.go index 861cdc435..257edf1c3 100644 --- a/resources/resource_spec.go +++ b/resources/resource_spec.go @@ -201,7 +201,7 @@ func (r *Spec) NewResource(rd ResourceSourceDescriptor) (resource.Resource, erro h: &resourceHash{}, publishInit: &hsync.OnceMore{}, keyInit: &sync.Once{}, - includeHashInKey: isImage, + includeHashInKey: isImage || rd.IncludeHashInKey, paths: rp, spec: r, sd: rd, diff --git a/resources/resource_transformers/cssjs/inline_imports.go b/resources/resource_transformers/cssjs/inline_imports.go index 44ae4b3c6..230add6e6 100644 --- a/resources/resource_transformers/cssjs/inline_imports.go +++ b/resources/resource_transformers/cssjs/inline_imports.go @@ -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 } diff --git a/resources/resource_transformers/cssjs/inline_imports_test.go b/resources/resource_transformers/cssjs/inline_imports_test.go index 3578e2612..bfbef977a 100644 --- a/resources/resource_transformers/cssjs/inline_imports_test.go +++ b/resources/resource_transformers/cssjs/inline_imports_test.go @@ -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{}, diff --git a/resources/resource_transformers/cssjs/postcss.go b/resources/resource_transformers/cssjs/postcss.go index 94295eea9..e1ca308b0 100644 --- a/resources/resource_transformers/cssjs/postcss.go +++ b/resources/resource_transformers/cssjs/postcss.go @@ -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, diff --git a/resources/resource_transformers/cssjs/tailwindcss.go b/resources/resource_transformers/cssjs/tailwindcss.go index 539016dc2..e7c690cd0 100644 --- a/resources/resource_transformers/cssjs/tailwindcss.go +++ b/resources/resource_transformers/cssjs/tailwindcss.go @@ -124,6 +124,7 @@ func (t *tailwindcssTransformation) Transform(ctx *resources.ResourceTransformat src := ctx.From imp := newImportResolver( + ctx.Ctx, ctx.From, ctx.InPath, options.InlineImports, diff --git a/resources/resource_transformers/cssjs/tailwindcss_integration_test.go b/resources/resource_transformers/cssjs/tailwindcss_integration_test.go index e2ce293e3..03cd1e07e 100644 --- a/resources/resource_transformers/cssjs/tailwindcss_integration_test.go +++ b/resources/resource_transformers/cssjs/tailwindcss_integration_test.go @@ -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) diff --git a/resources/resource_transformers/js/build.go b/resources/resource_transformers/js/build.go index f0ea8ea8b..0415b058a 100644 --- a/resources/resource_transformers/js/build.go +++ b/resources/resource_transformers/js/build.go @@ -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}, diff --git a/resources/resource_transformers/js/transform.go b/resources/resource_transformers/js/transform.go index 07933a92f..3da5f8e87 100644 --- a/resources/resource_transformers/js/transform.go +++ b/resources/resource_transformers/js/transform.go @@ -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) diff --git a/resources/resource_transformers/tocss/dartsass/client.go b/resources/resource_transformers/tocss/dartsass/client.go index cd01b12ee..02ff0bb94 100644 --- a/resources/resource_transformers/tocss/dartsass/client.go +++ b/resources/resource_transformers/tocss/dartsass/client.go @@ -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]. diff --git a/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go b/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go index 72e012667..2acbd8194 100644 --- a/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go +++ b/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go @@ -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() { diff --git a/resources/resource_transformers/tocss/dartsass/transform.go b/resources/resource_transformers/tocss/dartsass/transform.go index e3d5823e9..680301b20 100644 --- a/resources/resource_transformers/tocss/dartsass/transform.go +++ b/resources/resource_transformers/tocss/dartsass/transform.go @@ -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 + } } diff --git a/tpl/css/build_integration_test.go b/tpl/css/build_integration_test.go index 31b692a8e..1818bcf11 100644 --- a/tpl/css/build_integration_test.go +++ b/tpl/css/build_integration_test.go @@ -731,3 +731,181 @@ body { "--text-color: #333;", ) } + +func TestCSSBuildImportContext(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- assets/foo.css -- +body { color: orange; } +-- assets/css/main.css -- +@import "foo.css" screen; +@import "bar.css" print; +p { + font-color: red; +} +-- layouts/home.html -- +{{ $foo := resources.FromString "foo.css" "body { color: blue; }" }} +{{ $bar := resources.FromString "bar.css" "body { color: green; }" }} +{{/* resource.Resources implements the resources.ResourceGetter interface (the type of importContext). */}} +{{ $resources := slice $foo $bar}} +{{ $opts := dict "minify" true "importContext" $resources }} +{{ $css := resources.Get "css/main.css" | css.Build $opts }} +CSS: {{ $css.RelPermalink }}| +` + + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + + b.AssertFileContent("public/css/main.css", `@media screen{body{color:#00f}}@media print{body{color:green}}p{font-color:red}`) +} + +func TestCSSBuildImportContextChromaStyles(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap", "section", "page"] +[markup.highlight] +style = "monokai" +-- assets/css/components/all.css -- +@import "./chroma-light.css"; +@import "./chroma-dark.css" (prefers-color-scheme: dark); +-- assets/css/main.css -- +@import "./components/all.css"; + +:root { + color-scheme: light dark; +} + +-- layouts/home.html -- +{{ $light := css.ChromaStyles (dict "targetPath" "css/components/chroma-light.css" "style" "github" "mode" "light") }} +{{ $dark := css.ChromaStyles (dict "targetPath" "css/components/chroma-dark.css" "style" "github" "mode" "dark") }} +{{ $opts := dict "minify" true "importContext" (slice $light $dark) }} +{{ $css := resources.Get "css/main.css" | css.Build $opts }} +CSS: {{ $css.RelPermalink }}| +` + + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + + b.AssertFileContent("public/index.html", "CSS: /css/main.css|") + b.AssertFileContent("public/css/main.css", ".bg{background-color:#f7f7f7}.chroma", "@media(prefers-color-scheme:dark){.bg{color:#e6edf3") +} + +func TestCSSBuildImportContextHashes(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- assets/foo.css -- +body { color: orange; } +-- assets/css/main.css -- +@import "foo.css" screen; +@import "bar.css" print; +p { + font-color: red; +} +-- layouts/home.html -- +{{ $foo := resources.FromString "foo.css" "body { color: blue; }" }}| +{{ $foo2 := resources.FromString "foo2.css" "body { color: blue; }" }}| +{{ $foo3 := resources.FromString "foo.css" "body { color: indigo; }" }}| +{{ $bar := resources.FromString "bar.css" "body { color: green; }" }}| +{{ $resources := slice $foo $bar}} +{{ $resourcesMount1 := $resources.Mount "a" "b"}} +{{ $resourcesMount2 := $resources.Mount "a" "c"}} +{{ $opts := dict "minify" true "importContext" $resources }} +foo: {{ $foo | testinginternal.HashString }}| +foo2: {{ $foo2 | testinginternal.HashString }}| +foo3: {{ $foo3 | testinginternal.HashString }}| +bar: {{ $bar | testinginternal.HashString }}| +resources: {{ $resources | testinginternal.HashString }}| +resourcesMount1: {{ $resourcesMount1 | testinginternal.HashString }}| +resourcesMount2: {{ $resourcesMount2 | testinginternal.HashString }}| +resources namespace: {{ resources | testinginternal.HashString }}| +opts: {{ $opts | testinginternal.HashString }}| +home: {{ . | testinginternal.HashString }}|{{ .Key }}| +slice with Page: {{ slice $foo $bar . | testinginternal.HashString }}| +map with Page: {{ dict "foo" $foo "bar" $bar "page" . | testinginternal.HashString }}| +{{ $css := resources.Get "css/main.css" | css.Build $opts -}} +css: {{ $css | testinginternal.HashString }}| +cached1: {{ testinginternal.NewCachedResourceGetter $resources resources | testinginternal.HashString}} +cached2: {{ testinginternal.NewCachedResourceGetter $resources | testinginternal.HashString}} +cached3: {{ testinginternal.NewCachedResourceGetter $resources resources $resourcesMount1 $resourcesMount2 | testinginternal.HashString}} + + +` + + for range 2 { + + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + + b.AssertFileContent("public/index.html", ` +foo: 17610550322594361312| +foo2: 12741631597870435822| +foo3: 4488319114298108946| +bar: 11981658863051989001| +resources: 10963320145636139629| +resourcesMount1: 7843365040860281860| +resourcesMount2: 10632811173795339581| +resources namespace: 12714111578321948564| +opts: 7538269917289793785| +css: 8075855322897706813| +home: 166103792234269065|/html| +slice with Page: 7769684332502419906| +map with Page: 10393165700333352395| +cached1: 7064840587060576200 +cached2: 6730658186132701788 +cached3: 13761938017162285970 +`) + + } +} + +func TestCSSBuildImportContextEdit(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableLiveReload = true +-- assets/css/common/baz.css -- +body { color: purple; } +-- assets/css/main.css -- +@import "foo.css" screen; +@import "bar.css" print; +@import "baz.css" screen and (min-width: 768px); +p { + font-color: red; +} +-- layouts/home.html -- +{{ $foo := resources.FromString "foo.css" "body { color: blue; }" }} +{{ $bar := resources.FromString "bar.css" "body { color: green; }" }} +{{ $common := resources.Match "/css/common/*.css" }} +{{ $commonMount := ($common.Mount "/css/common" ".")}} +{{ $resources := slice $foo $bar }} +{{ $opts := dict "minify" true "importContext" (slice $resources $commonMount) }} +resources: {{ $resources | testinginternal.HashString }}| +{{ $css := resources.Get "css/main.css" | css.Build $opts }} +CSS: {{ $css.RelPermalink }}| +` + + b := hugolib.TestRunning(t, files, hugolib.TestOptOsFs()) + + b.AssertFileContent("public/css/main.css", ` +@media screen{body{color:#00f}}@media print{body{color:green}} +@media screen and (min-width:768px){body{color:purple}} +`) + + // Edit resource built with resources.FromString. + b.EditFileReplaceAll("layouts/home.html", "color: green", "color: yellow").Build() + b.AssertFileContent("public/css/main.css", ` +@media screen{body{color:#00f}}@media print{body{color:#ff0}} +@media screen and (min-width:768px){body{color:purple}} +`) + + // Edit resource in the mounted slice. + b.EditFileReplaceAll("assets/css/common/baz.css", "purple", "orange").Build() + b.AssertFileContent("public/css/main.css", ` +@media screen{body{color:#00f}}@media print{body{color:#ff0}} +@media screen and (min-width:768px){body{color:orange}} +`) +} diff --git a/tpl/resources/resources.go b/tpl/resources/resources.go index 2512b5ce9..aae6ac1f3 100644 --- a/tpl/resources/resources.go +++ b/tpl/resources/resources.go @@ -58,7 +58,10 @@ func New(deps *deps.Deps) (*Namespace, error) { }, nil } -var _ resource.ResourceFinder = (*Namespace)(nil) +var ( + _ resource.ResourceFinder = (*Namespace)(nil) + _ resource.Identifier = (*Namespace)(nil) +) // Namespace provides template functions for the "resources" namespace. type Namespace struct { @@ -300,3 +303,8 @@ func (ns *Namespace) PostProcess(r resource.Resource) (postpub.PostPublishedReso hugo.DeprecateWithLogger("resources.PostProcess", "Use templates.Defer instead. See https://gohugo.io/functions/templates/defer/", "v0.164.0", ns.deps.Log.Logger()) return ns.deps.ResourceSpec.PostProcess(r) } + +// For internal use only. +func (ns *Namespace) Key() string { + return "tpl.resources" +} diff --git a/tpl/testinginternal/init.go b/tpl/testinginternal/init.go new file mode 100644 index 000000000..213e81b79 --- /dev/null +++ b/tpl/testinginternal/init.go @@ -0,0 +1,38 @@ +// Copyright 2026 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 testinginternal + +import ( + "context" + + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/tpl/internal" +) + +const name = "testinginternal" + +func init() { + f := func(d *deps.Deps) *internal.TemplateFuncsNamespace { + ctx := New(d) + + ns := &internal.TemplateFuncsNamespace{ + Name: name, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, + } + + return ns + } + + internal.AddTemplateFuncsNamespace(f) +} diff --git a/tpl/testinginternal/testinginternal.go b/tpl/testinginternal/testinginternal.go new file mode 100644 index 000000000..80beb5939 --- /dev/null +++ b/tpl/testinginternal/testinginternal.go @@ -0,0 +1,41 @@ +// Copyright 2026 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 testinginternal provides template functions for internal use. +// // These should not be used in user templates, as they are not guaranteed to be stable or even useful. +package testinginternal + +import ( + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/resources/resource" +) + +// New returns a new instance of the testinginternal-namespaced template functions. +func New(d *deps.Deps) *Namespace { + ns := &Namespace{} + + return ns +} + +// Namespace provides template functions for the "testinginternal" namespace. +type Namespace struct{} + +// HashString wraps the core hashing func used for e.g. calculating resource transformation keys. +func (ns *Namespace) HashString(args ...any) string { + return hashing.HashString(args...) +} + +func (ns *Namespace) NewCachedResourceGetter(args ...any) resource.ResourceGetter { + return resource.NewCachedResourceGetter(args...) +} diff --git a/tpl/tplimplinit/tplimplinit.go b/tpl/tplimplinit/tplimplinit.go index a958b0669..4dc844c7a 100644 --- a/tpl/tplimplinit/tplimplinit.go +++ b/tpl/tplimplinit/tplimplinit.go @@ -49,6 +49,7 @@ import ( _ "github.com/gohugoio/hugo/tpl/site" _ "github.com/gohugoio/hugo/tpl/strings" _ "github.com/gohugoio/hugo/tpl/templates" + _ "github.com/gohugoio/hugo/tpl/testinginternal" _ "github.com/gohugoio/hugo/tpl/time" _ "github.com/gohugoio/hugo/tpl/transform" _ "github.com/gohugoio/hugo/tpl/urls"