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

509 lines
14 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"
"maps"
"path/filepath"
"sort"
"strings"
"github.com/gohugoio/hugo/common/hmaps"
"github.com/gohugoio/hugo/common/hugio"
"github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass"
"github.com/evanw/esbuild/pkg/api"
"github.com/gohugoio/hugo/media"
"github.com/mitchellh/mapstructure"
)
var (
nameTarget = map[string]api.Target{
"": api.ESNext,
"esnext": api.ESNext,
"es5": api.ES5,
"es6": api.ES2015,
"es2015": api.ES2015,
"es2016": api.ES2016,
"es2017": api.ES2017,
"es2018": api.ES2018,
"es2019": api.ES2019,
"es2020": api.ES2020,
"es2021": api.ES2021,
"es2022": api.ES2022,
"es2023": api.ES2023,
"es2024": api.ES2024,
}
engineName = map[string]api.EngineName{
"chrome": api.EngineChrome,
"deno": api.EngineDeno,
"edge": api.EngineEdge,
"firefox": api.EngineFirefox,
"hermes": api.EngineHermes,
"ie": api.EngineIE,
"ios": api.EngineIOS,
"node": api.EngineNode,
"opera": api.EngineOpera,
"rhino": api.EngineRhino,
"safari": api.EngineSafari,
}
engineKeysSorted []string
// source names: https://github.com/evanw/esbuild/blob/9eca46464ed5615cb36a3beb3f7a7b9a8ffbe7cf/internal/config/config.go#L208
nameLoader = map[string]api.Loader{
"none": api.LoaderNone,
"base64": api.LoaderBase64,
"binary": api.LoaderBinary,
"copy": api.LoaderFile,
"css": api.LoaderCSS,
"dataurl": api.LoaderDataURL,
"default": api.LoaderDefault,
"empty": api.LoaderEmpty,
"file": api.LoaderFile,
"global-css": api.LoaderGlobalCSS,
"js": api.LoaderJS,
"json": api.LoaderJSON,
"jsx": api.LoaderJSX,
"local-css": api.LoaderLocalCSS,
"text": api.LoaderText,
"ts": api.LoaderTS,
"tsx": api.LoaderTSX,
}
)
func init() {
for k := range engineName {
engineKeysSorted = append(engineKeysSorted, k)
}
// Sort by length descending to match longest prefix first.
sort.Slice(engineKeysSorted, func(i, j int) bool {
return len(engineKeysSorted[i]) > len(engineKeysSorted[j])
})
}
// DecodeExternalOptions decodes the given map into ExternalOptions.
func DecodeExternalOptions(m map[string]any) (ExternalOptions, error) {
opts := ExternalOptions{
SourcesContent: true,
}
if err := mapstructure.WeakDecode(m, &opts); err != nil {
return opts, err
}
if opts.TargetPath != "" {
opts.TargetPath = paths.ToSlashTrimLeading(opts.TargetPath)
}
for i, t := range opts.Target {
opts.Target[i] = strings.ToLower(t)
}
opts.Format = strings.ToLower(opts.Format)
return opts, nil
}
// ErrorMessageResolved holds a resolved error message.
type ErrorMessageResolved struct {
Path string
Message string
Content hugio.ReadSeekCloser
}
// ExternalOptions holds user facing options for the js.Build template function.
type ExternalOptions struct {
// If not set, the source path will be used as the base target path.
// Note that the target path's extension may change if the target MIME type
// is different, e.g. when the source is TypeScript.
TargetPath string
// Whether to minify to output.
Minify bool
// One of "inline", "external", "linked" or "none".
SourceMap string
SourcesContent bool
// This sets the target environment for the generated JavaScript and/or CSS code.
// It can specify the JavaScript language version to target (es2015, es2016, es2017, es2018, es2019, es2020 or esnext; default is esnext),
// in addition to a set of environments to support, as slice of environment name followed by a version number. e.g. "chrome80", "firefox73", "safari13" or "edge80".
// See https://esbuild.github.io/api/#target
Target []string
// The output format.
// One of: iife, cjs, esm
// Default is to esm.
Format string
// One of browser, node, neutral.
// Default is browser.
// See https://esbuild.github.io/api/#platform
Platform string
// When you import a package in node, the main field in that package's package.json file
// determines which file is imported (along with a lot of other rules).
// The default main fields is controlled by ESBuild and depend on the current platform setting.
// See https://esbuild.github.io/api/#main-fields
MainFields []string
// External dependencies, e.g. "react".
Externals []string
// This option allows you to automatically replace a global variable with an import from another file.
// The filenames must be relative to /assets.
// See https://esbuild.github.io/api/#inject
Inject []string
// User defined symbols.
Defines map[string]any
// This tells esbuild to edit your source code before building to drop certain constructs.
// See https://esbuild.github.io/api/#drop
Drop string
// 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
// containing the contents of that image
//
// See https://esbuild.github.io/api/#loader
Loaders map[string]string
// User defined params. Will be marshaled to JSON and available as "@params", e.g.
// import * as params from '@params';
Params any
// What to use instead of React.createElement.
JSXFactory string
// What to use instead of React.Fragment.
JSXFragment string
// What to do about JSX syntax.
// See https://esbuild.github.io/api/#jsx
JSX string
// Which library to use to automatically import JSX helper functions from. Only works if JSX is set to automatic.
// See https://esbuild.github.io/api/#jsx-import-source
JSXImportSource string
// User defined CSS variables. Will be available as CSS global scope CSS variables via @import "hugo:vars".
Vars map[string]any
// There is/was a bug in WebKit with severe performance issue with the tracking
// of TDZ checks in JavaScriptCore.
//
// Enabling this flag removes the TDZ and `const` assignment checks and
// may improve performance of larger JS codebases until the WebKit fix
// is in widespread use.
//
// See https://bugs.webkit.org/show_bug.cgi?id=199866
// Deprecated: This no longer have any effect and will be removed.
// TODO(bep) remove. See https://github.com/evanw/esbuild/commit/869e8117b499ca1dbfc5b3021938a53ffe934dba
AvoidTDZ bool
}
// InternalOptions holds internal options for the js.Build template function.
type InternalOptions struct {
MediaType media.Type
OutDir string
Contents string
SourceDir string
ResolveDir string
AbsWorkingDir string
Metafile bool
// Used as a prefix for asset references that go through the file loader.
// See https://esbuild.github.io/api/#public-path
PublicPath string
StdinSourcePath string
DependencyManager identity.Manager
Stdin bool // Set to true to pass in the entry point as a byte slice.
Splitting bool
IsCSS bool // Entry point is CSS.
TsConfig string
EntryPoints []string
ImportOnResolveFunc func(string, api.OnResolveArgs) string
ImportOnLoadFunc func(api.OnLoadArgs) (string, error)
ImportParamsOnLoadFunc func(args api.OnLoadArgs) json.RawMessage
ErrorMessageResolveFunc func(api.Message) *ErrorMessageResolved
ResolveSourceMapSource func(string) string // Used to resolve paths in error source maps.
}
// Options holds the options passed to Build.
type Options struct {
ExternalOptions
InternalOptions
compiled api.BuildOptions
}
func (opts *Options) compile() (err error) {
var target api.Target
for _, value := range opts.Target {
if v, found := nameTarget[value]; found {
if v > target {
target = v
}
}
}
var engines []api.Engine
OUTER:
for _, value := range opts.Target {
for _, engine := range engineKeysSorted {
if strings.HasPrefix(value, engine) {
version := value[len(engine):]
if version == "" {
return fmt.Errorf("invalid engine version: %q", value)
}
engines = append(engines, api.Engine{Name: engineName[engine], Version: version})
continue OUTER
}
}
}
if target == 0 && len(engines) == 0 && len(opts.Target) > 0 {
return fmt.Errorf("unsupported target: %v", opts.Target)
}
if target == 0 {
target = api.ESNext
}
var loaders map[string]api.Loader
if opts.IsCSS {
loaders = make(map[string]api.Loader)
// Add default CSS file loaders.
// May be overridden by opts.Loaders.
maps.Copy(loaders, extensionToLoaderMapCSS)
}
if opts.Loaders != nil {
if loaders == nil {
loaders = make(map[string]api.Loader)
}
for k, v := range opts.Loaders {
loader, found := nameLoader[v]
if !found {
err = fmt.Errorf("invalid loader: %q", v)
return
}
loaders[k] = loader
}
}
mediaType := opts.MediaType
if mediaType.IsZero() {
mediaType = media.Builtin.JavascriptType
}
var loader api.Loader
switch mediaType.SubType {
case media.Builtin.JavascriptType.SubType:
loader = api.LoaderJS
case media.Builtin.TypeScriptType.SubType:
loader = api.LoaderTS
case media.Builtin.TSXType.SubType:
loader = api.LoaderTSX
case media.Builtin.JSXType.SubType:
loader = api.LoaderJSX
case media.Builtin.CSSType.SubType:
loader = api.LoaderCSS
default:
err = fmt.Errorf("unsupported Media Type: %q", opts.MediaType)
return
}
var format api.Format
// One of: iife, cjs, esm
switch opts.Format {
case "", "iife":
format = api.FormatIIFE
case "esm":
format = api.FormatESModule
case "cjs":
format = api.FormatCommonJS
default:
err = fmt.Errorf("unsupported script output format: %q", opts.Format)
return
}
var jsx api.JSX
switch opts.JSX {
case "", "transform":
jsx = api.JSXTransform
case "preserve":
jsx = api.JSXPreserve
case "automatic":
jsx = api.JSXAutomatic
default:
err = fmt.Errorf("unsupported jsx type: %q", opts.JSX)
return
}
var platform api.Platform
switch opts.Platform {
case "", "browser":
platform = api.PlatformBrowser
case "node":
platform = api.PlatformNode
case "neutral":
platform = api.PlatformNeutral
default:
err = fmt.Errorf("unsupported platform type: %q", opts.Platform)
return
}
var defines map[string]string
if opts.Defines != nil {
defines = hmaps.ToStringMapString(opts.Defines)
}
var drop api.Drop
switch opts.Drop {
case "":
case "console":
drop = api.DropConsole
case "debugger":
drop = api.DropDebugger
default:
err = fmt.Errorf("unsupported drop type: %q", opts.Drop)
}
// By default we only need to specify outDir and no outFile
outDir := opts.OutDir
outFile := ""
var sourceMap api.SourceMap
switch opts.SourceMap {
case "inline":
sourceMap = api.SourceMapInline
case "external":
sourceMap = api.SourceMapExternal
case "linked":
sourceMap = api.SourceMapLinked
case "", "none":
sourceMap = api.SourceMapNone
default:
err = fmt.Errorf("unsupported sourcemap type: %q", opts.SourceMap)
return
}
sourcesContent := api.SourcesContentInclude
if !opts.SourcesContent {
sourcesContent = api.SourcesContentExclude
}
if opts.IsCSS && opts.MainFields == nil {
opts.MainFields = []string{"style", "main"}
}
opts.Vars = sass.PrepareVars(opts.Vars)
opts.compiled = api.BuildOptions{
Outfile: outFile,
Bundle: true,
Metafile: opts.Metafile,
AbsWorkingDir: opts.AbsWorkingDir,
Target: target,
Engines: engines,
Format: format,
MainFields: opts.MainFields,
Platform: platform,
Sourcemap: sourceMap,
SourcesContent: sourcesContent,
Loader: loaders,
MinifyWhitespace: opts.Minify,
MinifyIdentifiers: opts.Minify,
MinifySyntax: opts.Minify,
Outdir: outDir,
PublicPath: opts.PublicPath,
Splitting: opts.Splitting,
Define: defines,
External: opts.Externals,
Drop: drop,
JSXFactory: opts.JSXFactory,
JSXFragment: opts.JSXFragment,
JSX: jsx,
JSXImportSource: opts.JSXImportSource,
Tsconfig: opts.TsConfig,
EntryPoints: opts.EntryPoints,
}
if opts.Stdin {
// This makes ESBuild pass `stdin` as the Importer to the import.
opts.compiled.Stdin = &api.StdinOptions{
Contents: opts.Contents,
ResolveDir: opts.ResolveDir,
Loader: loader,
}
}
return
}
func (o Options) loaderFromFilename(filename string) api.Loader {
ext := filepath.Ext(filename)
if optsLoaders := o.compiled.Loader; optsLoaders != nil {
if l, found := optsLoaders[ext]; found {
return l
}
}
if !o.IsCSS {
if l, found := extensionToLoaderMapJS[ext]; found {
return l
}
}
return api.LoaderDefault
}
func (opts *Options) validate() error {
if opts.ImportOnResolveFunc != nil && opts.ImportOnLoadFunc == nil {
return fmt.Errorf("ImportOnLoadFunc must be set if ImportOnResolveFunc is set")
}
if opts.ImportOnResolveFunc == nil && opts.ImportOnLoadFunc != nil {
return fmt.Errorf("ImportOnResolveFunc must be set if ImportOnLoadFunc is set")
}
if opts.AbsWorkingDir == "" {
return fmt.Errorf("AbsWorkingDir must be set")
}
return nil
}