Files
Bjørn Erik Pedersen 70db201ed4 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>
2026-08-03 21:20:06 +02:00

449 lines
13 KiB
Go

// Copyright 2024 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 esbuild
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"github.com/evanw/esbuild/pkg/api"
"github.com/gohugoio/hugo/common/hmaps"
"github.com/gohugoio/hugo/common/types/css"
"github.com/gohugoio/hugo/hugofs"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/resources"
"github.com/gohugoio/hugo/resources/resource"
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass"
"github.com/spf13/afero"
)
const (
NsHugoImport = "ns-hugo-imp"
NsHugoImportResolveFunc = "ns-hugo-imp-func"
nsHugoParams = "ns-hugo-params"
nsHugoVars = "ns-hugo-vars"
pathHugoConfigParams = "@params/config"
stdinImporter = "<stdin>"
)
var hugoNamespaces = []string{NsHugoImport, NsHugoImportResolveFunc, nsHugoParams, nsHugoVars}
const (
PrefixHugoVirtual = "__hu_v"
PrefixHugoMemory = "__hu_m"
)
var extensionToLoaderMapJS = map[string]api.Loader{
".js": api.LoaderJS,
".mjs": api.LoaderJS,
".cjs": api.LoaderJS,
".jsx": api.LoaderJSX,
".ts": api.LoaderTS,
".tsx": api.LoaderTSX,
".json": api.LoaderJSON,
".txt": api.LoaderText,
".css": api.LoaderCSS,
}
var extensionToLoaderMapCSS = map[string]api.Loader{
".css": api.LoaderCSS,
// Common static file extensions that should use the file loader in CSS builds.
".png": api.LoaderFile,
".jpg": api.LoaderFile,
".jpeg": api.LoaderFile,
".gif": api.LoaderFile,
".svg": api.LoaderFile,
".webp": api.LoaderFile,
".avif": api.LoaderFile,
".woff": api.LoaderFile,
".woff2": api.LoaderFile,
".ttf": api.LoaderFile,
".eot": api.LoaderFile,
".otf": api.LoaderFile,
}
// This is a common sub-set of ESBuild's default extensions.
// We assume that imports of JSON, CSS etc. will be using their full
// name with extension.
var commonExtensions = []string{".js", ".ts", ".tsx", ".jsx"}
// ResolveComponent resolves a component using the given resolver.
func ResolveComponent[T any](impPath string, resolve func(string) (v T, found, isDir bool)) (v T, found bool) {
findFirst := func(base string) (v T, found, isDir bool) {
for _, ext := range commonExtensions {
if strings.HasSuffix(impPath, ext) {
// Import of foo.js.js need the full name.
continue
}
if v, found, isDir = resolve(base + ext); found {
return
}
}
// Not found.
return
}
// We need to check if this is a regular file imported without an extension.
// There may be ambiguous situations where both foo.js and foo/index.js exists.
// This import order is in line with both how Node and ESBuild's native
// import resolver works.
// It may be a regular file imported without an extension, e.g.
// foo or foo/index.
v, found, _ = findFirst(impPath)
if found {
return v, found
}
base := filepath.Base(impPath)
if base == "index" {
// try index.esm.js etc.
v, found, _ = findFirst(impPath + ".esm")
if found {
return v, found
}
}
// Check the path as is.
var isDir bool
v, found, isDir = resolve(impPath)
if found && isDir {
v, found, _ = findFirst(filepath.Join(impPath, "index"))
if !found {
v, found, _ = findFirst(filepath.Join(impPath, "index.esm"))
}
}
if !found && strings.HasSuffix(base, ".js") {
v, found, _ = findFirst(strings.TrimSuffix(impPath, ".js"))
}
return
}
// ResolveResource resolves a resource using the given resourceGetter.
func ResolveResource(impPath string, resourceGetter resource.ResourceGetter) (r resource.Resource) {
resolve := func(name string) (v resource.Resource, found, isDir bool) {
r := resourceGetter.Get(name)
return r, r != nil, false
}
r, found := ResolveComponent(impPath, resolve)
if !found {
return nil
}
return r
}
func newFSResolver(fs afero.Fs) *fsResolver {
return &fsResolver{fs: fs, resolved: hmaps.NewCache[string, *hugofs.FileMeta]()}
}
type fsResolver struct {
fs afero.Fs
resolved *hmaps.Cache[string, *hugofs.FileMeta]
}
func (r *fsResolver) resolveComponent(impPath string, direct bool) *hugofs.FileMeta {
v, _ := r.resolved.GetOrCreate(impPath, func() (*hugofs.FileMeta, error) {
resolve := func(name string) (*hugofs.FileMeta, bool, bool) {
if fi, err := r.fs.Stat(name); err == nil {
return fi.(hugofs.FileMetaInfo).Meta(), true, fi.IsDir()
}
return nil, false, false
}
if direct {
// Only resolve the path as is, without trying to add extensions etc.
v, found, isDir := resolve(impPath)
if found && !isDir {
return v, nil
}
return nil, nil
}
v, _ := ResolveComponent(impPath, resolve)
return v, nil
})
return v
}
func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsManager identity.Manager, opts Options) ([]api.Plugin, error) {
fs := rs.Assets
resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
impPath := args.Path
isCSSToken := args.Kind == api.ResolveCSSImportRule || args.Kind == api.ResolveCSSURLToken
shimmed := false
if opts.Shims != nil {
override, found := opts.Shims[impPath]
if found {
impPath = override
shimmed = true
}
}
if slices.Contains(opts.Externals, impPath) {
return api.OnResolveResult{
Path: impPath,
External: true,
}, nil
}
if opts.ImportOnResolveFunc != nil {
if s := opts.ImportOnResolveFunc(impPath, args); s != "" {
return api.OnResolveResult{Path: s, Namespace: NsHugoImportResolveFunc}, nil
}
}
importer := args.Importer
isStdin := importer == stdinImporter
var relDir string
if !isStdin {
if after, ok := strings.CutPrefix(importer, PrefixHugoVirtual); ok {
relDir = filepath.Dir(after)
} else {
rel, found := fs.MakePathRelative(importer, true)
if !found {
if shimmed {
relDir = opts.SourceDir
} else {
// Not in any of the /assets folders.
// This is an import from a node_modules, let
// ESBuild resolve this.
return api.OnResolveResult{}, nil
}
} else {
relDir = filepath.Dir(rel)
}
}
} else {
relDir = opts.SourceDir
}
var pathsToTry []string
if relDir != "" {
// For JS imports not starting with a "." is assumed to live relative to /assets.
// Hugo makes no assumptions about the directory structure below /assets.
if strings.HasPrefix(impPath, ".") {
pathsToTry = append(pathsToTry, filepath.Join(relDir, impPath))
} else if isCSSToken && !strings.HasPrefix(impPath, "/") {
// Follow the logic of both ESBuild and WebKit for CSS @import and url() tokens.
// First try the relativ path, then the assets relative path.
// See https://github.com/evanw/esbuild/issues/469
pathsToTry = append(pathsToTry, filepath.Join(relDir, impPath), impPath)
} else {
// Try only the assets relative path.
pathsToTry = append(pathsToTry, impPath)
}
}
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)
if m != nil {
break
}
}
if m != nil {
depsManager.AddIdentity(m.PathInfo)
// jsconfig.json path mapping has no effect for CSS imports, so skip those.
if !isCSSToken {
// Store the source root so we can create a jsconfig.json
// to help IntelliSense when the build is done.
// This should be a small number of elements, and when
// in server mode, we may get stale entries on renames etc.,
// but that shouldn't matter too much.
rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)
}
return api.OnResolveResult{Path: m.Filename, Namespace: NsHugoImport}, nil
}
// Fall back to ESBuild's resolve.
return api.OnResolveResult{}, nil
}
importResolver := api.Plugin{
Name: "hugo-import-resolver",
Setup: func(build api.PluginBuild) {
build.OnResolve(api.OnResolveOptions{Filter: `.*`},
func(args api.OnResolveArgs) (api.OnResolveResult, error) {
return resolveImport(args)
})
build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: NsHugoImport},
func(args api.OnLoadArgs) (api.OnLoadResult, error) {
b, err := os.ReadFile(args.Path)
if err != nil {
return api.OnLoadResult{}, fmt.Errorf("failed to read %q: %w", args.Path, err)
}
c := string(b)
return api.OnLoadResult{
// See https://github.com/evanw/esbuild/issues/502
// This allows all modules to resolve dependencies
// in the main project's node_modules.
ResolveDir: opts.ResolveDir,
Contents: &c,
Loader: opts.loaderFromFilename(args.Path),
}, nil
})
build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: NsHugoImportResolveFunc},
func(args api.OnLoadArgs) (api.OnLoadResult, error) {
c, err := opts.ImportOnLoadFunc(args)
if err != nil {
return api.OnLoadResult{}, err
}
if c == "" {
return api.OnLoadResult{}, fmt.Errorf("ImportOnLoadFunc failed to resolve %q", args.Path)
}
return api.OnLoadResult{
ResolveDir: opts.ResolveDir,
Contents: &c,
Loader: opts.loaderFromFilename(args.Path),
}, nil
})
},
}
params := opts.Params
if params == nil {
// This way @params will always resolve to something.
params = make(map[string]any)
}
b, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("failed to marshal params: %w", err)
}
paramsPlugin := api.Plugin{
Name: "hugo-params-plugin",
Setup: func(build api.PluginBuild) {
build.OnResolve(api.OnResolveOptions{Filter: `^@params(/config)?$`},
func(args api.OnResolveArgs) (api.OnResolveResult, error) {
resolvedPath := args.Importer
if args.Path == pathHugoConfigParams {
resolvedPath = pathHugoConfigParams
}
return api.OnResolveResult{
Path: resolvedPath,
Namespace: nsHugoParams,
}, nil
})
build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsHugoParams},
func(args api.OnLoadArgs) (api.OnLoadResult, error) {
bb := b
if args.Path != pathHugoConfigParams && opts.ImportParamsOnLoadFunc != nil {
bb = opts.ImportParamsOnLoadFunc(args)
}
s := string(bb)
if s == "" {
s = "{}"
}
return api.OnLoadResult{
Contents: &s,
Loader: api.LoaderJSON,
}, nil
})
},
}
varsPlugin := api.Plugin{
Name: "hugo-vars-plugin",
Setup: func(build api.PluginBuild) {
build.OnResolve(api.OnResolveOptions{Filter: `^hugo:vars(/|$)`},
func(args api.OnResolveArgs) (api.OnResolveResult, error) {
return api.OnResolveResult{
Path: args.Path,
Namespace: nsHugoVars,
}, nil
})
build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsHugoVars},
func(args api.OnLoadArgs) (api.OnLoadResult, error) {
subPath, _ := sass.HugoVarsSubPath(args.Path)
return api.OnLoadResult{
Contents: createCSSVarsStyleSheet(opts.Vars, subPath),
Loader: api.LoaderCSS,
}, nil
})
},
}
return []api.Plugin{importResolver, paramsPlugin, varsPlugin}, nil
}
// createCSSVarsStyleSheet creates a CSS custom properties stylesheet from the given vars.
// The result is a :root block with CSS custom properties. If subPath is non-empty,
// vars is navigated using the slash-separated path before emitting properties; nested
// map values are skipped at the resolved level.
func createCSSVarsStyleSheet(vars map[string]any, subPath string) *string {
resolved := sass.ResolveVars(vars, subPath)
if len(resolved) == 0 {
// We need to return a non-nil pointer to an empty string to avoid ESBuild treating this as a missing file.
s := ""
return &s
}
var varsSlice []string
for k, v := range resolved {
if !strings.HasPrefix(k, "--") {
k = "--" + k
}
switch v.(type) {
case css.QuotedString:
// E.g. Arial, sans-serif.
varsSlice = append(varsSlice, fmt.Sprintf(" %s: %q;", k, v))
default:
varsSlice = append(varsSlice, fmt.Sprintf(" %s: %v;", k, v))
}
}
sort.Strings(varsSlice)
s := ":root {\n" + strings.Join(varsSlice, "\n") + "\n}\n"
return &s
}