mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-24 15:28:54 +00:00
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:
+31
-21
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user