From 70db201ed4f76d0b80c568ffa6b1d35071aabd22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Mon, 3 Aug 2026 12:14:54 +0200 Subject: [PATCH] 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 --- common/hashing/hashing.go | 52 ++--- go.mod | 2 +- go.sum | 2 + hugolib/page.go | 5 - hugolib/page__output.go | 5 + internal/js/esbuild/options.go | 3 + internal/js/esbuild/resolve.go | 13 ++ resources/images/color.go | 9 +- resources/internal/key.go | 4 +- resources/resource.go | 3 + resources/resource/resources.go | 102 +++++----- resources/resource_factories/create/create.go | 5 +- resources/resource_spec.go | 2 +- .../cssjs/inline_imports.go | 53 +++++- .../cssjs/inline_imports_test.go | 69 +++++++ .../resource_transformers/cssjs/postcss.go | 5 + .../cssjs/tailwindcss.go | 1 + .../cssjs/tailwindcss_integration_test.go | 39 ++++ resources/resource_transformers/js/build.go | 2 + .../resource_transformers/js/transform.go | 27 +++ .../tocss/dartsass/client.go | 4 + .../dartsass/dartsass_integration_test.go | 29 +++ .../tocss/dartsass/transform.go | 103 +++++++--- tpl/css/build_integration_test.go | 178 ++++++++++++++++++ tpl/resources/resources.go | 10 +- tpl/testinginternal/init.go | 38 ++++ tpl/testinginternal/testinginternal.go | 41 ++++ tpl/tplimplinit/tplimplinit.go | 1 + 28 files changed, 697 insertions(+), 110 deletions(-) create mode 100644 tpl/testinginternal/init.go create mode 100644 tpl/testinginternal/testinginternal.go 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"