aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal
diff options
context:
space:
mode:
authorBjørn Erik Pedersen <[email protected]>2024-12-10 16:22:08 +0100
committerBjørn Erik Pedersen <[email protected]>2024-12-12 21:43:17 +0100
commite293e7ca6dcc34cded7eb90a644b5c720c2179cf (patch)
treedee8e8d272660d23aa7b76576e8267fc70c34f78 /internal
parent157d86414d43f6801e2a6996108f67d28679eac5 (diff)
downloadhugo-e293e7ca6dcc34cded7eb90a644b5c720c2179cf.tar.gz
hugo-e293e7ca6dcc34cded7eb90a644b5c720c2179cf.zip
Add js.Batch
Fixes #12626 Closes #7499 Closes #9978 Closes #12879 Closes #13113 Fixes #13116
Diffstat (limited to 'internal')
-rw-r--r--internal/js/esbuild/batch-esm-runner.gotmpl20
-rw-r--r--internal/js/esbuild/batch.go1437
-rw-r--r--internal/js/esbuild/batch_integration_test.go686
-rw-r--r--internal/js/esbuild/build.go236
-rw-r--r--internal/js/esbuild/helpers.go15
-rw-r--r--internal/js/esbuild/options.go375
-rw-r--r--internal/js/esbuild/options_test.go219
-rw-r--r--internal/js/esbuild/resolve.go315
-rw-r--r--internal/js/esbuild/resolve_test.go86
-rw-r--r--internal/js/esbuild/sourcemap.go80
10 files changed, 3469 insertions, 0 deletions
diff --git a/internal/js/esbuild/batch-esm-runner.gotmpl b/internal/js/esbuild/batch-esm-runner.gotmpl
new file mode 100644
index 000000000..3193b4c30
--- /dev/null
+++ b/internal/js/esbuild/batch-esm-runner.gotmpl
@@ -0,0 +1,20 @@
+{{ range $i, $e := .Scripts -}}
+ {{ if eq .Export "*" }}
+ {{- printf "import %s as Script%d from %q;" .Export $i .Import -}}
+ {{ else -}}
+ {{- printf "import { %s as Script%d } from %q;" .Export $i .Import -}}
+ {{ end -}}
+{{ end -}}
+{{ range $i, $e := .Runners }}
+ {{- printf "import { %s as Run%d } from %q;" .Export $i .Import -}}
+{{ end -}}
+{{ if .Runners -}}
+ let group = { id: "{{ $.ID }}", scripts: [] }
+ {{ range $i, $e := .Scripts -}}
+ group.scripts.push({{ .RunnerJSON $i }});
+ {{ end -}}
+ {{ range $i, $e := .Runners -}}
+ {{ $id := printf "Run%d" $i }}
+ {{ $id }}(group);
+ {{ end -}}
+{{ end -}}
diff --git a/internal/js/esbuild/batch.go b/internal/js/esbuild/batch.go
new file mode 100644
index 000000000..43e2da444
--- /dev/null
+++ b/internal/js/esbuild/batch.go
@@ -0,0 +1,1437 @@
+// 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 provides functions for building JavaScript resources.
+package esbuild
+
+import (
+ "bytes"
+ "context"
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "path"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "strings"
+ "sync"
+ "sync/atomic"
+
+ "github.com/evanw/esbuild/pkg/api"
+ "github.com/gohugoio/hugo/cache/dynacache"
+ "github.com/gohugoio/hugo/common/hugio"
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/common/paths"
+ "github.com/gohugoio/hugo/deps"
+ "github.com/gohugoio/hugo/identity"
+ "github.com/gohugoio/hugo/lazy"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/resource"
+ "github.com/gohugoio/hugo/resources/resource_factories/create"
+ "github.com/gohugoio/hugo/tpl"
+ "github.com/mitchellh/mapstructure"
+ "github.com/spf13/afero"
+ "github.com/spf13/cast"
+)
+
+var _ Batcher = (*batcher)(nil)
+
+const (
+ NsBatch = "_hugo-js-batch"
+
+ propsKeyImportContext = "importContext"
+ propsResoure = "resource"
+)
+
+//go:embed batch-esm-runner.gotmpl
+var runnerTemplateStr string
+
+var _ BatchPackage = (*Package)(nil)
+
+var _ buildToucher = (*optsHolder[scriptOptions])(nil)
+
+var (
+ _ buildToucher = (*scriptGroup)(nil)
+ _ isBuiltOrTouchedProvider = (*scriptGroup)(nil)
+)
+
+func NewBatcherClient(deps *deps.Deps) (*BatcherClient, error) {
+ c := &BatcherClient{
+ d: deps,
+ buildClient: NewBuildClient(deps.BaseFs.Assets, deps.ResourceSpec),
+ createClient: create.New(deps.ResourceSpec),
+ bundlesCache: maps.NewCache[string, BatchPackage](),
+ }
+
+ deps.BuildEndListeners.Add(func(...any) bool {
+ c.bundlesCache.Reset()
+ return false
+ })
+
+ return c, nil
+}
+
+func (o optionsMap[K, C]) ByKey() optionsGetSetters[K, C] {
+ var values []optionsGetSetter[K, C]
+ for _, v := range o {
+ values = append(values, v)
+ }
+
+ sort.Slice(values, func(i, j int) bool {
+ return values[i].Key().String() < values[j].Key().String()
+ })
+
+ return values
+}
+
+func (o *opts[K, C]) Compiled() C {
+ o.h.checkCompileErr()
+ return o.h.compiled
+}
+
+func (os optionsGetSetters[K, C]) Filter(predicate func(K) bool) optionsGetSetters[K, C] {
+ var a optionsGetSetters[K, C]
+ for _, v := range os {
+ if predicate(v.Key()) {
+ a = append(a, v)
+ }
+ }
+ return a
+}
+
+func (o *optsHolder[C]) IdentifierBase() string {
+ return o.optionsID
+}
+
+func (o *opts[K, C]) Key() K {
+ return o.key
+}
+
+func (o *opts[K, C]) Reset() {
+ mu := o.once.ResetWithLock()
+ defer mu.Unlock()
+ o.h.resetCounter++
+}
+
+func (o *opts[K, C]) Get(id uint32) OptionsSetter {
+ var b *optsHolder[C]
+ o.once.Do(func() {
+ b = o.h
+ b.setBuilt(id)
+ })
+ return b
+}
+
+func (o *opts[K, C]) GetIdentity() identity.Identity {
+ return o.h
+}
+
+func (o *optsHolder[C]) SetOptions(m map[string]any) string {
+ o.optsSetCounter++
+ o.optsPrev = o.optsCurr
+ o.optsCurr = m
+ o.compiledPrev = o.compiled
+ o.compiled, o.compileErr = o.compiled.compileOptions(m, o.defaults)
+ o.checkCompileErr()
+ return ""
+}
+
+// ValidateBatchID validates the given ID according to some very
+func ValidateBatchID(id string, isTopLevel bool) error {
+ if id == "" {
+ return fmt.Errorf("id must be set")
+ }
+ // No Windows slashes.
+ if strings.Contains(id, "\\") {
+ return fmt.Errorf("id must not contain backslashes")
+ }
+
+ // Allow forward slashes in top level IDs only.
+ if !isTopLevel && strings.Contains(id, "/") {
+ return fmt.Errorf("id must not contain forward slashes")
+ }
+
+ return nil
+}
+
+func newIsBuiltOrTouched() isBuiltOrTouched {
+ return isBuiltOrTouched{
+ built: make(buildIDs),
+ touched: make(buildIDs),
+ }
+}
+
+func newOpts[K any, C optionsCompiler[C]](key K, optionsID string, defaults defaultOptionValues) *opts[K, C] {
+ return &opts[K, C]{
+ key: key,
+ h: &optsHolder[C]{
+ optionsID: optionsID,
+ defaults: defaults,
+ isBuiltOrTouched: newIsBuiltOrTouched(),
+ },
+ }
+}
+
+// BatchPackage holds a group of JavaScript resources.
+type BatchPackage interface {
+ Groups() map[string]resource.Resources
+}
+
+// Batcher is used to build JavaScript packages.
+type Batcher interface {
+ Build(context.Context) (BatchPackage, error)
+ Config(ctx context.Context) OptionsSetter
+ Group(ctx context.Context, id string) BatcherGroup
+}
+
+// BatcherClient is a client for building JavaScript packages.
+type BatcherClient struct {
+ d *deps.Deps
+
+ once sync.Once
+ runnerTemplate tpl.Template
+
+ createClient *create.Client
+ buildClient *BuildClient
+
+ bundlesCache *maps.Cache[string, BatchPackage]
+}
+
+// New creates a new Batcher with the given ID.
+// This will be typically created once and reused across rebuilds.
+func (c *BatcherClient) New(id string) (Batcher, error) {
+ var initErr error
+ c.once.Do(func() {
+ // We should fix the initialization order here (or use the Go template package directly), but we need to wait
+ // for the Hugo templates to be ready.
+ tmpl, err := c.d.TextTmpl().Parse("batch-esm-runner", runnerTemplateStr)
+ if err != nil {
+ initErr = err
+ return
+ }
+ c.runnerTemplate = tmpl
+ })
+
+ if initErr != nil {
+ return nil, initErr
+ }
+
+ dependencyManager := c.d.Conf.NewIdentityManager("jsbatch_" + id)
+ configID := "config_" + id
+
+ b := &batcher{
+ id: id,
+ scriptGroups: make(map[string]*scriptGroup),
+ dependencyManager: dependencyManager,
+ client: c,
+ configOptions: newOpts[scriptID, configOptions](
+ scriptID(configID),
+ configID,
+ defaultOptionValues{},
+ ),
+ }
+
+ c.d.BuildEndListeners.Add(func(...any) bool {
+ b.reset()
+ return false
+ })
+
+ idFinder := identity.NewFinder(identity.FinderConfig{})
+
+ c.d.OnChangeListeners.Add(func(ids ...identity.Identity) bool {
+ for _, id := range ids {
+ if r := idFinder.Contains(id, b.dependencyManager, 50); r > 0 {
+ b.staleVersion.Add(1)
+ return false
+ }
+
+ sp, ok := id.(identity.DependencyManagerScopedProvider)
+ if !ok {
+ continue
+ }
+ idms := sp.GetDependencyManagerForScopesAll()
+
+ for _, g := range b.scriptGroups {
+ g.forEachIdentity(func(id2 identity.Identity) bool {
+ bt, ok := id2.(buildToucher)
+ if !ok {
+ return false
+ }
+ for _, id3 := range idms {
+ // This handles the removal of the only source for a script group (e.g. all shortcodes in a contnt page).
+ // Note the very shallow search.
+ if r := idFinder.Contains(id2, id3, 0); r > 0 {
+ bt.setTouched(b.buildCount)
+ return false
+ }
+ }
+ return false
+ })
+ }
+ }
+
+ return false
+ })
+
+ return b, nil
+}
+
+func (c *BatcherClient) buildBatchGroup(ctx context.Context, t *batchGroupTemplateContext) (resource.Resource, string, error) {
+ var buf bytes.Buffer
+
+ if err := c.d.Tmpl().ExecuteWithContext(ctx, c.runnerTemplate, &buf, t); err != nil {
+ return nil, "", err
+ }
+
+ s := paths.AddLeadingSlash(t.keyPath + ".js")
+ r, err := c.createClient.FromString(s, buf.String())
+ if err != nil {
+ return nil, "", err
+ }
+
+ return r, s, nil
+}
+
+// BatcherGroup is a group of scripts and instances.
+type BatcherGroup interface {
+ Instance(sid, iid string) OptionsSetter
+ Runner(id string) OptionsSetter
+ Script(id string) OptionsSetter
+}
+
+// OptionsSetter is used to set options for a batch, script or instance.
+type OptionsSetter interface {
+ SetOptions(map[string]any) string
+}
+
+// Package holds a group of JavaScript resources.
+type Package struct {
+ id string
+ b *batcher
+
+ groups map[string]resource.Resources
+}
+
+func (p *Package) Groups() map[string]resource.Resources {
+ return p.groups
+}
+
+type batchGroupTemplateContext struct {
+ keyPath string
+ ID string
+ Runners []scriptRunnerTemplateContext
+ Scripts []scriptBatchTemplateContext
+}
+
+type batcher struct {
+ mu sync.Mutex
+ id string
+ buildCount uint32
+ staleVersion atomic.Uint32
+ scriptGroups scriptGroups
+
+ client *BatcherClient
+ dependencyManager identity.Manager
+
+ configOptions optionsGetSetter[scriptID, configOptions]
+
+ // The last successfully built package.
+ // If this is non-nil and not stale, we can reuse it (e.g. on server rebuilds)
+ prevBuild *Package
+}
+
+// Build builds the batch if not already built or if it's stale.
+func (b *batcher) Build(ctx context.Context) (BatchPackage, error) {
+ key := dynacache.CleanKey(b.id + ".js")
+ p, err := b.client.bundlesCache.GetOrCreate(key, func() (BatchPackage, error) {
+ return b.build(ctx)
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to build JS batch %q: %w", b.id, err)
+ }
+ return p, nil
+}
+
+func (b *batcher) Config(ctx context.Context) OptionsSetter {
+ return b.configOptions.Get(b.buildCount)
+}
+
+func (b *batcher) Group(ctx context.Context, id string) BatcherGroup {
+ if err := ValidateBatchID(id, false); err != nil {
+ panic(err)
+ }
+
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ group, found := b.scriptGroups[id]
+ if !found {
+ idm := b.client.d.Conf.NewIdentityManager("jsbatch_" + id)
+ b.dependencyManager.AddIdentity(idm)
+
+ group = &scriptGroup{
+ id: id, b: b,
+ isBuiltOrTouched: newIsBuiltOrTouched(),
+ dependencyManager: idm,
+ scriptsOptions: make(optionsMap[scriptID, scriptOptions]),
+ instancesOptions: make(optionsMap[instanceID, paramsOptions]),
+ runnersOptions: make(optionsMap[scriptID, scriptOptions]),
+ }
+ b.scriptGroups[id] = group
+ }
+
+ group.setBuilt(b.buildCount)
+
+ return group
+}
+
+func (b *batcher) isStale() bool {
+ if b.staleVersion.Load() > 0 {
+ return true
+ }
+
+ if b.removeNotSet() {
+ return true
+ }
+
+ if b.configOptions.isStale() {
+ return true
+ }
+
+ for _, v := range b.scriptGroups {
+ if v.isStale() {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (b *batcher) build(ctx context.Context) (BatchPackage, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ defer func() {
+ b.staleVersion.Store(0)
+ b.buildCount++
+ }()
+
+ if b.prevBuild != nil {
+ if !b.isStale() {
+ return b.prevBuild, nil
+ }
+ }
+
+ p, err := b.doBuild(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ b.prevBuild = p
+
+ return p, nil
+}
+
+func (b *batcher) doBuild(ctx context.Context) (*Package, error) {
+ type importContext struct {
+ name string
+ resourceGetter resource.ResourceGetter
+ scriptOptions scriptOptions
+ dm identity.Manager
+ }
+
+ state := struct {
+ importResource *maps.Cache[string, resource.Resource]
+ resultResource *maps.Cache[string, resource.Resource]
+ importerImportContext *maps.Cache[string, importContext]
+ pathGroup *maps.Cache[string, string]
+ }{
+ importResource: maps.NewCache[string, resource.Resource](),
+ resultResource: maps.NewCache[string, resource.Resource](),
+ importerImportContext: maps.NewCache[string, importContext](),
+ pathGroup: maps.NewCache[string, string](),
+ }
+
+ // Entry points passed to ESBuid.
+ var entryPoints []string
+ addResource := func(group, pth string, r resource.Resource, isResult bool) {
+ state.pathGroup.Set(paths.TrimExt(pth), group)
+ state.importResource.Set(pth, r)
+ if isResult {
+ state.resultResource.Set(pth, r)
+ }
+ entryPoints = append(entryPoints, pth)
+ }
+
+ for k, v := range b.scriptGroups {
+ keyPath := k
+ var runners []scriptRunnerTemplateContext
+ for _, vv := range v.runnersOptions.ByKey() {
+ runnerKeyPath := keyPath + "_" + vv.Key().String()
+ runnerImpPath := paths.AddLeadingSlash(runnerKeyPath + "_runner" + vv.Compiled().Resource.MediaType().FirstSuffix.FullSuffix)
+ runners = append(runners, scriptRunnerTemplateContext{opts: vv, Import: runnerImpPath})
+ addResource(k, runnerImpPath, vv.Compiled().Resource, false)
+ }
+
+ t := &batchGroupTemplateContext{
+ keyPath: keyPath,
+ ID: v.id,
+ Runners: runners,
+ }
+
+ instances := v.instancesOptions.ByKey()
+
+ for _, vv := range v.scriptsOptions.ByKey() {
+ keyPath := keyPath + "_" + vv.Key().String()
+ opts := vv.Compiled()
+ impPath := path.Join(PrefixHugoVirtual, opts.Dir(), keyPath+opts.Resource.MediaType().FirstSuffix.FullSuffix)
+ impCtx := opts.ImportContext
+
+ state.importerImportContext.Set(impPath, importContext{
+ name: keyPath,
+ resourceGetter: impCtx,
+ scriptOptions: opts,
+ dm: v.dependencyManager,
+ })
+
+ bt := scriptBatchTemplateContext{
+ opts: vv,
+ Import: impPath,
+ }
+ state.importResource.Set(bt.Import, vv.Compiled().Resource)
+ predicate := func(k instanceID) bool {
+ return k.scriptID == vv.Key()
+ }
+ for _, vvv := range instances.Filter(predicate) {
+ bt.Instances = append(bt.Instances, scriptInstanceBatchTemplateContext{opts: vvv})
+ }
+
+ t.Scripts = append(t.Scripts, bt)
+ }
+
+ r, s, err := b.client.buildBatchGroup(ctx, t)
+ if err != nil {
+ return nil, fmt.Errorf("failed to build JS batch: %w", err)
+ }
+
+ state.importerImportContext.Set(s, importContext{
+ name: s,
+ resourceGetter: nil,
+ dm: v.dependencyManager,
+ })
+
+ addResource(v.id, s, r, true)
+ }
+
+ mediaTypes := b.client.d.ResourceSpec.MediaTypes()
+
+ externalOptions := b.configOptions.Compiled().Options
+ if externalOptions.Format == "" {
+ externalOptions.Format = "esm"
+ }
+ if externalOptions.Format != "esm" {
+ return nil, fmt.Errorf("only esm format is currently supported")
+ }
+
+ jsOpts := Options{
+ ExternalOptions: externalOptions,
+ InternalOptions: InternalOptions{
+ DependencyManager: b.dependencyManager,
+ Splitting: true,
+ ImportOnResolveFunc: func(imp string, args api.OnResolveArgs) string {
+ var importContextPath string
+ if args.Kind == api.ResolveEntryPoint {
+ importContextPath = args.Path
+ } else {
+ importContextPath = args.Importer
+ }
+ importContext, importContextFound := state.importerImportContext.Get(importContextPath)
+
+ // We want to track the dependencies closest to where they're used.
+ dm := b.dependencyManager
+ if importContextFound {
+ dm = importContext.dm
+ }
+
+ if r, found := state.importResource.Get(imp); found {
+ dm.AddIdentity(identity.FirstIdentity(r))
+ return imp
+ }
+
+ if importContext.resourceGetter != nil {
+ resolved := ResolveResource(imp, importContext.resourceGetter)
+ if resolved != nil {
+ resolvePath := resources.InternalResourceTargetPath(resolved)
+ dm.AddIdentity(identity.FirstIdentity(resolved))
+ imp := PrefixHugoVirtual + resolvePath
+ state.importResource.Set(imp, resolved)
+ state.importerImportContext.Set(imp, importContext)
+ return imp
+
+ }
+ }
+ return ""
+ },
+ ImportOnLoadFunc: func(args api.OnLoadArgs) string {
+ imp := args.Path
+
+ if r, found := state.importResource.Get(imp); found {
+ content, err := r.(resource.ContentProvider).Content(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return cast.ToString(content)
+ }
+ return ""
+ },
+ ImportParamsOnLoadFunc: func(args api.OnLoadArgs) json.RawMessage {
+ if importContext, found := state.importerImportContext.Get(args.Path); found {
+ if !importContext.scriptOptions.IsZero() {
+ return importContext.scriptOptions.Params
+ }
+ }
+ return nil
+ },
+ ErrorMessageResolveFunc: func(args api.Message) *ErrorMessageResolved {
+ if loc := args.Location; loc != nil {
+ path := strings.TrimPrefix(loc.File, NsHugoImportResolveFunc+":")
+ if r, found := state.importResource.Get(path); found {
+ sourcePath := resources.InternalResourceSourcePathBestEffort(r)
+
+ var contentr hugio.ReadSeekCloser
+ if cp, ok := r.(hugio.ReadSeekCloserProvider); ok {
+ contentr, _ = cp.ReadSeekCloser()
+ }
+ return &ErrorMessageResolved{
+ Content: contentr,
+ Path: sourcePath,
+ Message: args.Text,
+ }
+
+ }
+
+ }
+ return nil
+ },
+ ResolveSourceMapSource: func(s string) string {
+ if r, found := state.importResource.Get(s); found {
+ if ss := resources.InternalResourceSourcePath(r); ss != "" {
+ return ss
+ }
+ return PrefixHugoMemory + s
+ }
+ return ""
+ },
+ EntryPoints: entryPoints,
+ },
+ }
+
+ result, err := b.client.buildClient.Build(jsOpts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to build JS bundle: %w", err)
+ }
+
+ groups := make(map[string]resource.Resources)
+
+ createAndAddResource := func(targetPath, group string, o api.OutputFile, mt media.Type) error {
+ var sourceFilename string
+ if r, found := state.importResource.Get(targetPath); found {
+ sourceFilename = resources.InternalResourceSourcePathBestEffort(r)
+ }
+ targetPath = path.Join(b.id, targetPath)
+
+ rd := resources.ResourceSourceDescriptor{
+ LazyPublish: true,
+ OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) {
+ return hugio.NewReadSeekerNoOpCloserFromBytes(o.Contents), nil
+ },
+ MediaType: mt,
+ TargetPath: targetPath,
+ SourceFilenameOrPath: sourceFilename,
+ }
+ r, err := b.client.d.ResourceSpec.NewResource(rd)
+ if err != nil {
+ return err
+ }
+
+ groups[group] = append(groups[group], r)
+
+ return nil
+ }
+
+ outDir := b.client.d.AbsPublishDir
+
+ createAndAddResources := func(o api.OutputFile) (bool, error) {
+ p := paths.ToSlashPreserveLeading(strings.TrimPrefix(o.Path, outDir))
+ ext := path.Ext(p)
+ mt, _, found := mediaTypes.GetBySuffix(ext)
+ if !found {
+ return false, nil
+ }
+
+ group, found := state.pathGroup.Get(paths.TrimExt(p))
+
+ if !found {
+ return false, nil
+ }
+
+ if err := createAndAddResource(p, group, o, mt); err != nil {
+ return false, err
+ }
+
+ return true, nil
+ }
+
+ for _, o := range result.OutputFiles {
+ handled, err := createAndAddResources(o)
+ if err != nil {
+ return nil, err
+ }
+
+ if !handled {
+ // Copy to destination.
+ p := strings.TrimPrefix(o.Path, outDir)
+ targetFilename := filepath.Join(b.id, p)
+ fs := b.client.d.BaseFs.PublishFs
+ if err := fs.MkdirAll(filepath.Dir(targetFilename), 0o777); err != nil {
+ return nil, fmt.Errorf("failed to create dir %q: %w", targetFilename, err)
+ }
+
+ if err := afero.WriteFile(fs, targetFilename, o.Contents, 0o666); err != nil {
+ return nil, fmt.Errorf("failed to write to %q: %w", targetFilename, err)
+ }
+ }
+ }
+
+ p := &Package{
+ id: path.Join(NsBatch, b.id),
+ b: b,
+ groups: groups,
+ }
+
+ return p, nil
+}
+
+func (b *batcher) removeNotSet() bool {
+ // We already have the lock.
+ var removed bool
+ currentBuildID := b.buildCount
+ for k, v := range b.scriptGroups {
+ if !v.isBuilt(currentBuildID) && v.isTouched(currentBuildID) {
+ // Remove entire group.
+ removed = true
+ delete(b.scriptGroups, k)
+ continue
+ }
+ if v.removeTouchedButNotSet() {
+ removed = true
+ }
+ if v.removeNotSet() {
+ removed = true
+ }
+ }
+
+ return removed
+}
+
+func (b *batcher) reset() {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.configOptions.Reset()
+ for _, v := range b.scriptGroups {
+ v.Reset()
+ }
+}
+
+type buildIDs map[uint32]bool
+
+func (b buildIDs) Has(buildID uint32) bool {
+ return b[buildID]
+}
+
+func (b buildIDs) Set(buildID uint32) {
+ b[buildID] = true
+}
+
+type buildToucher interface {
+ setTouched(buildID uint32)
+}
+
+type configOptions struct {
+ Options ExternalOptions
+}
+
+func (s configOptions) isStaleCompiled(prev configOptions) bool {
+ return false
+}
+
+func (s configOptions) compileOptions(m map[string]any, defaults defaultOptionValues) (configOptions, error) {
+ config, err := DecodeExternalOptions(m)
+ if err != nil {
+ return configOptions{}, err
+ }
+
+ return configOptions{
+ Options: config,
+ }, nil
+}
+
+type defaultOptionValues struct {
+ defaultExport string
+}
+
+type instanceID struct {
+ scriptID scriptID
+ instanceID string
+}
+
+func (i instanceID) String() string {
+ return i.scriptID.String() + "_" + i.instanceID
+}
+
+type isBuiltOrTouched struct {
+ built buildIDs
+ touched buildIDs
+}
+
+func (i isBuiltOrTouched) setBuilt(id uint32) {
+ i.built.Set(id)
+}
+
+func (i isBuiltOrTouched) isBuilt(id uint32) bool {
+ return i.built.Has(id)
+}
+
+func (i isBuiltOrTouched) setTouched(id uint32) {
+ i.touched.Set(id)
+}
+
+func (i isBuiltOrTouched) isTouched(id uint32) bool {
+ return i.touched.Has(id)
+}
+
+type isBuiltOrTouchedProvider interface {
+ isBuilt(uint32) bool
+ isTouched(uint32) bool
+}
+
+type key interface {
+ comparable
+ fmt.Stringer
+}
+
+type optionsCompiler[C any] interface {
+ isStaleCompiled(C) bool
+ compileOptions(map[string]any, defaultOptionValues) (C, error)
+}
+
+type optionsGetSetter[K, C any] interface {
+ isBuiltOrTouchedProvider
+ identity.IdentityProvider
+ // resource.StaleInfo
+
+ Compiled() C
+ Key() K
+ Reset()
+
+ Get(uint32) OptionsSetter
+ isStale() bool
+ currPrev() (map[string]any, map[string]any)
+}
+
+type optionsGetSetters[K key, C any] []optionsGetSetter[K, C]
+
+type optionsMap[K key, C any] map[K]optionsGetSetter[K, C]
+
+type opts[K any, C optionsCompiler[C]] struct {
+ key K
+ h *optsHolder[C]
+ once lazy.OnceMore
+}
+
+type optsHolder[C optionsCompiler[C]] struct {
+ optionsID string
+
+ defaults defaultOptionValues
+
+ // Keep track of one generation so we can detect changes.
+ // Note that most of this tracking is performed on the options/map level.
+ compiled C
+ compiledPrev C
+ compileErr error
+
+ resetCounter uint32
+ optsSetCounter uint32
+ optsCurr map[string]any
+ optsPrev map[string]any
+
+ isBuiltOrTouched
+}
+
+type paramsOptions struct {
+ Params json.RawMessage
+}
+
+func (s paramsOptions) isStaleCompiled(prev paramsOptions) bool {
+ return false
+}
+
+func (s paramsOptions) compileOptions(m map[string]any, defaults defaultOptionValues) (paramsOptions, error) {
+ v := struct {
+ Params map[string]any
+ }{}
+
+ if err := mapstructure.WeakDecode(m, &v); err != nil {
+ return paramsOptions{}, err
+ }
+
+ paramsJSON, err := json.Marshal(v.Params)
+ if err != nil {
+ return paramsOptions{}, err
+ }
+
+ return paramsOptions{
+ Params: paramsJSON,
+ }, nil
+}
+
+type scriptBatchTemplateContext struct {
+ opts optionsGetSetter[scriptID, scriptOptions]
+ Import string
+ Instances []scriptInstanceBatchTemplateContext
+}
+
+func (s *scriptBatchTemplateContext) Export() string {
+ return s.opts.Compiled().Export
+}
+
+func (c scriptBatchTemplateContext) MarshalJSON() (b []byte, err error) {
+ return json.Marshal(&struct {
+ ID string `json:"id"`
+ Instances []scriptInstanceBatchTemplateContext `json:"instances"`
+ }{
+ ID: c.opts.Key().String(),
+ Instances: c.Instances,
+ })
+}
+
+func (b scriptBatchTemplateContext) RunnerJSON(i int) string {
+ script := fmt.Sprintf("Script%d", i)
+
+ v := struct {
+ ID string `json:"id"`
+
+ // Read-only live JavaScript binding.
+ Binding string `json:"binding"`
+ Instances []scriptInstanceBatchTemplateContext `json:"instances"`
+ }{
+ b.opts.Key().String(),
+ script,
+ b.Instances,
+ }
+
+ bb, err := json.Marshal(v)
+ if err != nil {
+ panic(err)
+ }
+ s := string(bb)
+
+ // Remove the quotes to make it a valid JS object.
+ s = strings.ReplaceAll(s, fmt.Sprintf("%q", script), script)
+
+ return s
+}
+
+type scriptGroup struct {
+ mu sync.Mutex
+ id string
+ b *batcher
+ isBuiltOrTouched
+ dependencyManager identity.Manager
+
+ scriptsOptions optionsMap[scriptID, scriptOptions]
+ instancesOptions optionsMap[instanceID, paramsOptions]
+ runnersOptions optionsMap[scriptID, scriptOptions]
+}
+
+// For internal use only.
+func (b *scriptGroup) GetDependencyManager() identity.Manager {
+ return b.dependencyManager
+}
+
+// For internal use only.
+func (b *scriptGroup) IdentifierBase() string {
+ return b.id
+}
+
+func (s *scriptGroup) Instance(sid, id string) OptionsSetter {
+ if err := ValidateBatchID(sid, false); err != nil {
+ panic(err)
+ }
+ if err := ValidateBatchID(id, false); err != nil {
+ panic(err)
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ iid := instanceID{scriptID: scriptID(sid), instanceID: id}
+ if v, found := s.instancesOptions[iid]; found {
+ return v.Get(s.b.buildCount)
+ }
+
+ fullID := "instance_" + s.key() + "_" + iid.String()
+
+ s.instancesOptions[iid] = newOpts[instanceID, paramsOptions](
+ iid,
+ fullID,
+ defaultOptionValues{},
+ )
+
+ return s.instancesOptions[iid].Get(s.b.buildCount)
+}
+
+func (g *scriptGroup) Reset() {
+ for _, v := range g.scriptsOptions {
+ v.Reset()
+ }
+ for _, v := range g.instancesOptions {
+ v.Reset()
+ }
+ for _, v := range g.runnersOptions {
+ v.Reset()
+ }
+}
+
+func (s *scriptGroup) Runner(id string) OptionsSetter {
+ if err := ValidateBatchID(id, false); err != nil {
+ panic(err)
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ sid := scriptID(id)
+ if v, found := s.runnersOptions[sid]; found {
+ return v.Get(s.b.buildCount)
+ }
+
+ runnerIdentity := "runner_" + s.key() + "_" + id
+
+ // A typical signature for a runner would be:
+ // export default function Run(scripts) {}
+ // The user can override the default export in the templates.
+
+ s.runnersOptions[sid] = newOpts[scriptID, scriptOptions](
+ sid,
+ runnerIdentity,
+ defaultOptionValues{
+ defaultExport: "default",
+ },
+ )
+
+ return s.runnersOptions[sid].Get(s.b.buildCount)
+}
+
+func (s *scriptGroup) Script(id string) OptionsSetter {
+ if err := ValidateBatchID(id, false); err != nil {
+ panic(err)
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ sid := scriptID(id)
+ if v, found := s.scriptsOptions[sid]; found {
+ return v.Get(s.b.buildCount)
+ }
+
+ scriptIdentity := "script_" + s.key() + "_" + id
+
+ s.scriptsOptions[sid] = newOpts[scriptID, scriptOptions](
+ sid,
+ scriptIdentity,
+ defaultOptionValues{
+ defaultExport: "*",
+ },
+ )
+
+ return s.scriptsOptions[sid].Get(s.b.buildCount)
+}
+
+func (s *scriptGroup) isStale() bool {
+ for _, v := range s.scriptsOptions {
+ if v.isStale() {
+ return true
+ }
+ }
+
+ for _, v := range s.instancesOptions {
+ if v.isStale() {
+ return true
+ }
+ }
+
+ for _, v := range s.runnersOptions {
+ if v.isStale() {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (v *scriptGroup) forEachIdentity(
+ f func(id identity.Identity) bool,
+) bool {
+ if f(v) {
+ return true
+ }
+ for _, vv := range v.instancesOptions {
+ if f(vv.GetIdentity()) {
+ return true
+ }
+ }
+
+ for _, vv := range v.scriptsOptions {
+ if f(vv.GetIdentity()) {
+ return true
+ }
+ }
+
+ for _, vv := range v.runnersOptions {
+ if f(vv.GetIdentity()) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (s *scriptGroup) key() string {
+ return s.b.id + "_" + s.id
+}
+
+func (g *scriptGroup) removeNotSet() bool {
+ currentBuildID := g.b.buildCount
+ if !g.isBuilt(currentBuildID) {
+ // This group was never accessed in this build.
+ return false
+ }
+ var removed bool
+
+ if g.instancesOptions.isBuilt(currentBuildID) {
+ // A new instance has been set in this group for this build.
+ // Remove any instance that has not been set in this build.
+ for k, v := range g.instancesOptions {
+ if v.isBuilt(currentBuildID) {
+ continue
+ }
+ delete(g.instancesOptions, k)
+ removed = true
+ }
+ }
+
+ if g.runnersOptions.isBuilt(currentBuildID) {
+ // A new runner has been set in this group for this build.
+ // Remove any runner that has not been set in this build.
+ for k, v := range g.runnersOptions {
+ if v.isBuilt(currentBuildID) {
+ continue
+ }
+ delete(g.runnersOptions, k)
+ removed = true
+ }
+ }
+
+ if g.scriptsOptions.isBuilt(currentBuildID) {
+ // A new script has been set in this group for this build.
+ // Remove any script that has not been set in this build.
+ for k, v := range g.scriptsOptions {
+ if v.isBuilt(currentBuildID) {
+ continue
+ }
+ delete(g.scriptsOptions, k)
+
+ // Also remove any instance with this ID.
+ for kk := range g.instancesOptions {
+ if kk.scriptID == k {
+ delete(g.instancesOptions, kk)
+ }
+ }
+ removed = true
+ }
+ }
+
+ return removed
+}
+
+func (g *scriptGroup) removeTouchedButNotSet() bool {
+ currentBuildID := g.b.buildCount
+ var removed bool
+ for k, v := range g.instancesOptions {
+ if v.isBuilt(currentBuildID) {
+ continue
+ }
+ if v.isTouched(currentBuildID) {
+ delete(g.instancesOptions, k)
+ removed = true
+ }
+ }
+ for k, v := range g.runnersOptions {
+ if v.isBuilt(currentBuildID) {
+ continue
+ }
+ if v.isTouched(currentBuildID) {
+ delete(g.runnersOptions, k)
+ removed = true
+ }
+ }
+ for k, v := range g.scriptsOptions {
+ if v.isBuilt(currentBuildID) {
+ continue
+ }
+ if v.isTouched(currentBuildID) {
+ delete(g.scriptsOptions, k)
+ removed = true
+
+ // Also remove any instance with this ID.
+ for kk := range g.instancesOptions {
+ if kk.scriptID == k {
+ delete(g.instancesOptions, kk)
+ }
+ }
+ }
+
+ }
+ return removed
+}
+
+type scriptGroups map[string]*scriptGroup
+
+func (s scriptGroups) Sorted() []*scriptGroup {
+ var a []*scriptGroup
+ for _, v := range s {
+ a = append(a, v)
+ }
+ sort.Slice(a, func(i, j int) bool {
+ return a[i].id < a[j].id
+ })
+ return a
+}
+
+type scriptID string
+
+func (s scriptID) String() string {
+ return string(s)
+}
+
+type scriptInstanceBatchTemplateContext struct {
+ opts optionsGetSetter[instanceID, paramsOptions]
+}
+
+func (c scriptInstanceBatchTemplateContext) ID() string {
+ return c.opts.Key().instanceID
+}
+
+func (c scriptInstanceBatchTemplateContext) MarshalJSON() (b []byte, err error) {
+ return json.Marshal(&struct {
+ ID string `json:"id"`
+ Params json.RawMessage `json:"params"`
+ }{
+ ID: c.opts.Key().instanceID,
+ Params: c.opts.Compiled().Params,
+ })
+}
+
+type scriptOptions struct {
+ // The script to build.
+ Resource resource.Resource
+
+ // The import context to use.
+ // Note that we will always fall back to the resource's own import context.
+ ImportContext resource.ResourceGetter
+
+ // The export name to use for this script's group's runners (if any).
+ // If not set, the default export will be used.
+ Export string
+
+ // Params marshaled to JSON.
+ Params json.RawMessage
+}
+
+func (o *scriptOptions) Dir() string {
+ return path.Dir(resources.InternalResourceTargetPath(o.Resource))
+}
+
+func (s scriptOptions) IsZero() bool {
+ return s.Resource == nil
+}
+
+func (s scriptOptions) isStaleCompiled(prev scriptOptions) bool {
+ if prev.IsZero() {
+ return false
+ }
+
+ // All but the ImportContext are checked at the options/map level.
+ i1nil, i2nil := prev.ImportContext == nil, s.ImportContext == nil
+ if i1nil && i2nil {
+ return false
+ }
+ if i1nil || i2nil {
+ return true
+ }
+ // On its own this check would have too many false positives, but combined with the other checks it should be fine.
+ // We cannot do equality checking here.
+ if !prev.ImportContext.(resource.IsProbablySameResourceGetter).IsProbablySameResourceGetter(s.ImportContext) {
+ return true
+ }
+
+ return false
+}
+
+func (s scriptOptions) compileOptions(m map[string]any, defaults defaultOptionValues) (scriptOptions, error) {
+ v := struct {
+ Resource resource.Resource
+ ImportContext any
+ Export string
+ Params map[string]any
+ }{}
+
+ if err := mapstructure.WeakDecode(m, &v); err != nil {
+ panic(err)
+ }
+
+ var paramsJSON []byte
+ if v.Params != nil {
+ var err error
+ paramsJSON, err = json.Marshal(v.Params)
+ if err != nil {
+ panic(err)
+ }
+ }
+
+ if v.Export == "" {
+ v.Export = defaults.defaultExport
+ }
+
+ compiled := scriptOptions{
+ Resource: v.Resource,
+ Export: v.Export,
+ ImportContext: resource.NewCachedResourceGetter(v.ImportContext),
+ Params: paramsJSON,
+ }
+
+ if compiled.Resource == nil {
+ return scriptOptions{}, fmt.Errorf("resource not set")
+ }
+
+ return compiled, nil
+}
+
+type scriptRunnerTemplateContext struct {
+ opts optionsGetSetter[scriptID, scriptOptions]
+ Import string
+}
+
+func (s *scriptRunnerTemplateContext) Export() string {
+ return s.opts.Compiled().Export
+}
+
+func (c scriptRunnerTemplateContext) MarshalJSON() (b []byte, err error) {
+ return json.Marshal(&struct {
+ ID string `json:"id"`
+ }{
+ ID: c.opts.Key().String(),
+ })
+}
+
+func (o optionsMap[K, C]) isBuilt(id uint32) bool {
+ for _, v := range o {
+ if v.isBuilt(id) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (o *opts[K, C]) isBuilt(id uint32) bool {
+ return o.h.isBuilt(id)
+}
+
+func (o *opts[K, C]) isStale() bool {
+ if o.h.isStaleOpts() {
+ return true
+ }
+ if o.h.compiled.isStaleCompiled(o.h.compiledPrev) {
+ return true
+ }
+ return false
+}
+
+func (o *optsHolder[C]) isStaleOpts() bool {
+ if o.optsSetCounter == 1 && o.resetCounter > 0 {
+ return false
+ }
+ isStale := func() bool {
+ if len(o.optsCurr) != len(o.optsPrev) {
+ return true
+ }
+ for k, v := range o.optsPrev {
+ vv, found := o.optsCurr[k]
+ if !found {
+ return true
+ }
+ if strings.EqualFold(k, propsKeyImportContext) {
+ // This is checked later.
+ } else if si, ok := vv.(resource.StaleInfo); ok {
+ if si.StaleVersion() > 0 {
+ return true
+ }
+ } else {
+ if !reflect.DeepEqual(v, vv) {
+ return true
+ }
+ }
+ }
+ return false
+ }()
+
+ return isStale
+}
+
+func (o *opts[K, C]) isTouched(id uint32) bool {
+ return o.h.isTouched(id)
+}
+
+func (o *optsHolder[C]) checkCompileErr() {
+ if o.compileErr != nil {
+ panic(o.compileErr)
+ }
+}
+
+func (o *opts[K, C]) currPrev() (map[string]any, map[string]any) {
+ return o.h.optsCurr, o.h.optsPrev
+}
+
+func init() {
+ // We don't want any dependencies/change tracking on the top level Package,
+ // we want finer grained control via Package.Group.
+ var p any = &Package{}
+ if _, ok := p.(identity.Identity); ok {
+ panic("esbuid.Package should not implement identity.Identity")
+ }
+ if _, ok := p.(identity.DependencyManagerProvider); ok {
+ panic("esbuid.Package should not implement identity.DependencyManagerProvider")
+ }
+}
diff --git a/internal/js/esbuild/batch_integration_test.go b/internal/js/esbuild/batch_integration_test.go
new file mode 100644
index 000000000..07f99ee4e
--- /dev/null
+++ b/internal/js/esbuild/batch_integration_test.go
@@ -0,0 +1,686 @@
+// 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 js provides functions for building JavaScript resources
+package esbuild_test
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+
+ "github.com/bep/logg"
+ "github.com/gohugoio/hugo/common/paths"
+ "github.com/gohugoio/hugo/hugolib"
+ "github.com/gohugoio/hugo/internal/js/esbuild"
+)
+
+// Used to test misc. error situations etc.
+const jsBatchFilesTemplate = `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term", "section"]
+disableLiveReload = true
+-- assets/js/styles.css --
+body {
+ background-color: red;
+}
+-- assets/js/main.js --
+import './styles.css';
+import * as params from '@params';
+import * as foo from 'mylib';
+console.log("Hello, Main!");
+console.log("params.p1", params.p1);
+export default function Main() {};
+-- assets/js/runner.js --
+console.log("Hello, Runner!");
+-- node_modules/mylib/index.js --
+console.log("Hello, My Lib!");
+-- layouts/shortcodes/hdx.html --
+{{ $path := .Get "r" }}
+{{ $r := or (.Page.Resources.Get $path) (resources.Get $path) }}
+{{ $batch := (js.Batch "mybatch") }}
+{{ $scriptID := $path | anchorize }}
+{{ $instanceID := .Ordinal | string }}
+{{ $group := .Page.RelPermalink | anchorize }}
+{{ $params := .Params | default dict }}
+{{ $export := .Get "export" | default "default" }}
+{{ with $batch.Group $group }}
+ {{ with .Runner "create-elements" }}
+ {{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }}
+ {{ end }}
+ {{ with .Script $scriptID }}
+ {{ .SetOptions (dict
+ "resource" $r
+ "export" $export
+ "importContext" (slice $.Page)
+ )
+ }}
+ {{ end }}
+ {{ with .Instance $scriptID $instanceID }}
+ {{ .SetOptions (dict "params" $params) }}
+ {{ end }}
+{{ end }}
+hdx-instance: {{ $scriptID }}: {{ $instanceID }}|
+-- layouts/_default/baseof.html --
+Base.
+{{ $batch := (js.Batch "mybatch") }}
+ {{ with $batch.Config }}
+ {{ .SetOptions (dict
+ "params" (dict "id" "config")
+ "sourceMap" ""
+ )
+ }}
+{{ end }}
+{{ with (templates.Defer (dict "key" "global")) }}
+Defer:
+{{ $batch := (js.Batch "mybatch") }}
+{{ range $k, $v := $batch.Build.Groups }}
+ {{ range $kk, $vv := . -}}
+ {{ $k }}: {{ .RelPermalink }}
+ {{ end }}
+{{ end -}}
+{{ end }}
+{{ block "main" . }}Main{{ end }}
+End.
+-- layouts/_default/single.html --
+{{ define "main" }}
+==> Single Template Content: {{ .Content }}$
+{{ $batch := (js.Batch "mybatch") }}
+{{ with $batch.Group "mygroup" }}
+ {{ with .Runner "run" }}
+ {{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }}
+ {{ end }}
+ {{ with .Script "main" }}
+ {{ .SetOptions (dict "resource" (resources.Get "js/main.js") "params" (dict "p1" "param-p1-main" )) }}
+ {{ end }}
+ {{ with .Instance "main" "i1" }}
+ {{ .SetOptions (dict "params" (dict "title" "Instance 1")) }}
+ {{ end }}
+{{ end }}
+{{ end }}
+-- layouts/index.html --
+{{ define "main" }}
+Home.
+{{ end }}
+-- content/p1/index.md --
+---
+title: "P1"
+---
+
+Some content.
+
+{{< hdx r="p1script.js" myparam="p1-param-1" >}}
+{{< hdx r="p1script.js" myparam="p1-param-2" >}}
+
+-- content/p1/p1script.js --
+console.log("P1 Script");
+
+
+`
+
+// Just to verify that the above file setup works.
+func TestBatchTemplateOKBuild(t *testing.T) {
+ b := hugolib.Test(t, jsBatchFilesTemplate, hugolib.TestOptWithOSFs())
+ b.AssertPublishDir("mybatch/mygroup.js", "mybatch/mygroup.css")
+}
+
+func TestBatchRemoveAllInGroup(t *testing.T) {
+ files := jsBatchFilesTemplate
+ b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs())
+
+ b.AssertFileContent("public/p1/index.html", "p1: /mybatch/p1.js")
+
+ b.EditFiles("content/p1/index.md", `
+---
+title: "P1"
+---
+Empty.
+`)
+ b.Build()
+
+ b.AssertFileContent("public/p1/index.html", "! p1: /mybatch/p1.js")
+
+ // Add one script back.
+ b.EditFiles("content/p1/index.md", `
+---
+title: "P1"
+---
+
+{{< hdx r="p1script.js" myparam="p1-param-1-new" >}}
+`)
+ b.Build()
+
+ b.AssertFileContent("public/mybatch/p1.js",
+ "p1-param-1-new",
+ "p1script.js")
+}
+
+func TestBatchEditInstance(t *testing.T) {
+ files := jsBatchFilesTemplate
+ b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs())
+ b.AssertFileContent("public/mybatch/mygroup.js", "Instance 1")
+ b.EditFileReplaceAll("layouts/_default/single.html", "Instance 1", "Instance 1 Edit").Build()
+ b.AssertFileContent("public/mybatch/mygroup.js", "Instance 1 Edit")
+}
+
+func TestBatchEditScriptParam(t *testing.T) {
+ files := jsBatchFilesTemplate
+ b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs())
+ b.AssertFileContent("public/mybatch/mygroup.js", "param-p1-main")
+ b.EditFileReplaceAll("layouts/_default/single.html", "param-p1-main", "param-p1-main-edited").Build()
+ b.AssertFileContent("public/mybatch/mygroup.js", "param-p1-main-edited")
+}
+
+func TestBatchErrorScriptResourceNotSet(t *testing.T) {
+ files := strings.Replace(jsBatchFilesTemplate, `(resources.Get "js/main.js")`, `(resources.Get "js/doesnotexist.js")`, 1)
+ b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err.Error(), qt.Contains, `error calling SetOptions: resource not set`)
+}
+
+func TestBatchSlashInBatchID(t *testing.T) {
+ files := strings.ReplaceAll(jsBatchFilesTemplate, `"mybatch"`, `"my/batch"`)
+ b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
+ b.Assert(err, qt.IsNil)
+ b.AssertPublishDir("my/batch/mygroup.js")
+}
+
+func TestBatchSourceMaps(t *testing.T) {
+ filesTemplate := `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term", "section"]
+disableLiveReload = true
+-- assets/js/styles.css --
+body {
+ background-color: red;
+}
+-- assets/js/main.js --
+import * as foo from 'mylib';
+console.log("Hello, Main!");
+-- assets/js/runner.js --
+console.log("Hello, Runner!");
+-- node_modules/mylib/index.js --
+console.log("Hello, My Lib!");
+-- layouts/shortcodes/hdx.html --
+{{ $path := .Get "r" }}
+{{ $r := or (.Page.Resources.Get $path) (resources.Get $path) }}
+{{ $batch := (js.Batch "mybatch") }}
+{{ $scriptID := $path | anchorize }}
+{{ $instanceID := .Ordinal | string }}
+{{ $group := .Page.RelPermalink | anchorize }}
+{{ $params := .Params | default dict }}
+{{ $export := .Get "export" | default "default" }}
+{{ with $batch.Group $group }}
+ {{ with .Runner "create-elements" }}
+ {{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }}
+ {{ end }}
+ {{ with .Script $scriptID }}
+ {{ .SetOptions (dict
+ "resource" $r
+ "export" $export
+ "importContext" (slice $.Page)
+ )
+ }}
+ {{ end }}
+ {{ with .Instance $scriptID $instanceID }}
+ {{ .SetOptions (dict "params" $params) }}
+ {{ end }}
+{{ end }}
+hdx-instance: {{ $scriptID }}: {{ $instanceID }}|
+-- layouts/_default/baseof.html --
+Base.
+{{ $batch := (js.Batch "mybatch") }}
+ {{ with $batch.Config }}
+ {{ .SetOptions (dict
+ "params" (dict "id" "config")
+ "sourceMap" ""
+ )
+ }}
+{{ end }}
+{{ with (templates.Defer (dict "key" "global")) }}
+Defer:
+{{ $batch := (js.Batch "mybatch") }}
+{{ range $k, $v := $batch.Build.Groups }}
+ {{ range $kk, $vv := . -}}
+ {{ $k }}: {{ .RelPermalink }}
+ {{ end }}
+{{ end -}}
+{{ end }}
+{{ block "main" . }}Main{{ end }}
+End.
+-- layouts/_default/single.html --
+{{ define "main" }}
+==> Single Template Content: {{ .Content }}$
+{{ $batch := (js.Batch "mybatch") }}
+{{ with $batch.Group "mygroup" }}
+ {{ with .Runner "run" }}
+ {{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }}
+ {{ end }}
+ {{ with .Script "main" }}
+ {{ .SetOptions (dict "resource" (resources.Get "js/main.js") "params" (dict "p1" "param-p1-main" )) }}
+ {{ end }}
+ {{ with .Instance "main" "i1" }}
+ {{ .SetOptions (dict "params" (dict "title" "Instance 1")) }}
+ {{ end }}
+{{ end }}
+{{ end }}
+-- layouts/index.html --
+{{ define "main" }}
+Home.
+{{ end }}
+-- content/p1/index.md --
+---
+title: "P1"
+---
+
+Some content.
+
+{{< hdx r="p1script.js" myparam="p1-param-1" >}}
+{{< hdx r="p1script.js" myparam="p1-param-2" >}}
+
+-- content/p1/p1script.js --
+import * as foo from 'mylib';
+console.lg("Foo", foo);
+console.log("P1 Script");
+export default function P1Script() {};
+
+
+`
+ files := strings.Replace(filesTemplate, `"sourceMap" ""`, `"sourceMap" "linked"`, 1)
+ b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs())
+ b.AssertFileContent("public/mybatch/mygroup.js.map", "main.js", "! ns-hugo")
+ b.AssertFileContent("public/mybatch/mygroup.js", "sourceMappingURL=mygroup.js.map")
+ b.AssertFileContent("public/mybatch/p1.js", "sourceMappingURL=p1.js.map")
+ b.AssertFileContent("public/mybatch/mygroup_run_runner.js", "sourceMappingURL=mygroup_run_runner.js.map")
+ b.AssertFileContent("public/mybatch/chunk-UQKPPNA6.js", "sourceMappingURL=chunk-UQKPPNA6.js.map")
+
+ checkMap := func(p string, expectLen int) {
+ s := b.FileContent(p)
+ sources := esbuild.SourcesFromSourceMap(s)
+ b.Assert(sources, qt.HasLen, expectLen)
+
+ // Check that all source files exist.
+ for _, src := range sources {
+ filename, ok := paths.UrlStringToFilename(src)
+ b.Assert(ok, qt.IsTrue)
+ _, err := os.Stat(filename)
+ b.Assert(err, qt.IsNil)
+ }
+ }
+
+ checkMap("public/mybatch/mygroup.js.map", 1)
+ checkMap("public/mybatch/p1.js.map", 1)
+ checkMap("public/mybatch/mygroup_run_runner.js.map", 0)
+ checkMap("public/mybatch/chunk-UQKPPNA6.js.map", 1)
+}
+
+func TestBatchErrorRunnerResourceNotSet(t *testing.T) {
+ files := strings.Replace(jsBatchFilesTemplate, `(resources.Get "js/runner.js")`, `(resources.Get "js/doesnotexist.js")`, 1)
+ b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err.Error(), qt.Contains, `resource not set`)
+}
+
+func TestBatchErrorScriptResourceInAssetsSyntaxError(t *testing.T) {
+ // Introduce JS syntax error in assets/js/main.js
+ files := strings.Replace(jsBatchFilesTemplate, `console.log("Hello, Main!");`, `console.log("Hello, Main!"`, 1)
+ b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`assets/js/main.js:5:0": Expected ")" but found "console"`))
+}
+
+func TestBatchErrorScriptResourceInBundleSyntaxError(t *testing.T) {
+ // Introduce JS syntax error in content/p1/p1script.js
+ files := strings.Replace(jsBatchFilesTemplate, `console.log("P1 Script");`, `console.log("P1 Script"`, 1)
+ b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`/content/p1/p1script.js:3:0": Expected ")" but found end of file`))
+}
+
+func TestBatch(t *testing.T) {
+ files := `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term"]
+disableLiveReload = true
+baseURL = "https://example.com"
+-- package.json --
+{
+ "devDependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ }
+}
+-- assets/js/shims/react.js --
+-- assets/js/shims/react-dom.js --
+module.exports = window.ReactDOM;
+module.exports = window.React;
+-- content/mybundle/index.md --
+---
+title: "My Bundle"
+---
+-- content/mybundle/mybundlestyles.css --
+@import './foo.css';
+@import './bar.css';
+@import './otherbundlestyles.css';
+
+.mybundlestyles {
+ background-color: blue;
+}
+-- content/mybundle/bundlereact.jsx --
+import * as React from "react";
+import './foo.css';
+import './mybundlestyles.css';
+window.React1 = React;
+
+let text = 'Click me, too!'
+
+export default function MyBundleButton() {
+ return (
+ <button>${text}</button>
+ )
+}
+
+-- assets/js/reactrunner.js --
+import * as ReactDOM from 'react-dom/client';
+import * as React from 'react';
+
+export default function Run(group) {
+ for (const module of group.scripts) {
+ for (const instance of module.instances) {
+ /* This is a convention in this project. */
+ let elId = §§${module.id}-${instance.id}§§;
+ let el = document.getElementById(elId);
+ if (!el) {
+ console.warn(§§Element with id ${elId} not found§§);
+ continue;
+ }
+ const root = ReactDOM.createRoot(el);
+ const reactEl = React.createElement(module.mod, instance.params);
+ root.render(reactEl);
+ }
+ }
+}
+-- assets/other/otherbundlestyles.css --
+.otherbundlestyles {
+ background-color: red;
+}
+-- assets/other/foo.css --
+@import './bar.css';
+
+.foo {
+ background-color: blue;
+}
+-- assets/other/bar.css --
+.bar {
+ background-color: red;
+}
+-- assets/js/button.css --
+button {
+ background-color: red;
+}
+-- assets/js/bar.css --
+.bar-assets {
+ background-color: red;
+}
+-- assets/js/helper.js --
+import './bar.css'
+
+export function helper() {
+ console.log('helper');
+}
+
+-- assets/js/react1styles_nested.css --
+.react1styles_nested {
+ background-color: red;
+}
+-- assets/js/react1styles.css --
+@import './react1styles_nested.css';
+.react1styles {
+ background-color: red;
+}
+-- assets/js/react1.jsx --
+import * as React from "react";
+import './button.css'
+import './foo.css'
+import './react1styles.css'
+
+window.React1 = React;
+
+let text = 'Click me'
+
+export default function MyButton() {
+ return (
+ <button>${text}</button>
+ )
+}
+
+-- assets/js/react2.jsx --
+import * as React from "react";
+import { helper } from './helper.js'
+import './foo.css'
+
+window.React2 = React;
+
+let text = 'Click me, too!'
+
+export function MyOtherButton() {
+ return (
+ <button>${text}</button>
+ )
+}
+-- assets/js/main1.js --
+import * as React from "react";
+import * as params from '@params';
+
+console.log('main1.React', React)
+console.log('main1.params.id', params.id)
+
+-- assets/js/main2.js --
+import * as React from "react";
+import * as params from '@params';
+
+console.log('main2.React', React)
+console.log('main2.params.id', params.id)
+
+export default function Main2() {};
+
+-- assets/js/main3.js --
+import * as React from "react";
+import * as params from '@params';
+import * as config from '@params/config';
+
+console.log('main3.params.id', params.id)
+console.log('config.params.id', config.id)
+
+export default function Main3() {};
+
+-- layouts/_default/single.html --
+Single.
+
+{{ $r := .Resources.GetMatch "*.jsx" }}
+{{ $batch := (js.Batch "mybundle") }}
+{{ $otherCSS := (resources.Match "/other/*.css").Mount "/other" "." }}
+ {{ with $batch.Config }}
+ {{ $shims := dict "react" "js/shims/react.js" "react-dom/client" "js/shims/react-dom.js" }}
+ {{ .SetOptions (dict
+ "target" "es2018"
+ "params" (dict "id" "config")
+ "shims" $shims
+ )
+ }}
+{{ end }}
+{{ with $batch.Group "reactbatch" }}
+ {{ with .Script "r3" }}
+ {{ .SetOptions (dict
+ "resource" $r
+ "importContext" (slice $ $otherCSS)
+ "params" (dict "id" "r3")
+ )
+ }}
+ {{ end }}
+ {{ with .Instance "r3" "r2i1" }}
+ {{ .SetOptions (dict "title" "r2 instance 1")}}
+ {{ end }}
+{{ end }}
+-- layouts/index.html --
+Home.
+{{ with (templates.Defer (dict "key" "global")) }}
+{{ $batch := (js.Batch "mybundle") }}
+{{ range $k, $v := $batch.Build.Groups }}
+ {{ range $kk, $vv := . }}
+ {{ $k }}: {{ $kk }}: {{ .RelPermalink }}
+ {{ end }}
+ {{ end }}
+{{ end }}
+{{ $myContentBundle := site.GetPage "mybundle" }}
+{{ $batch := (js.Batch "mybundle") }}
+{{ $otherCSS := (resources.Match "/other/*.css").Mount "/other" "." }}
+{{ with $batch.Group "mains" }}
+ {{ with .Script "main1" }}
+ {{ .SetOptions (dict
+ "resource" (resources.Get "js/main1.js")
+ "params" (dict "id" "main1")
+ )
+ }}
+ {{ end }}
+ {{ with .Script "main2" }}
+ {{ .SetOptions (dict
+ "resource" (resources.Get "js/main2.js")
+ "params" (dict "id" "main2")
+ )
+ }}
+ {{ end }}
+ {{ with .Script "main3" }}
+ {{ .SetOptions (dict
+ "resource" (resources.Get "js/main3.js")
+ )
+ }}
+ {{ end }}
+{{ with .Instance "main1" "m1i1" }}{{ .SetOptions (dict "params" (dict "title" "Main1 Instance 1"))}}{{ end }}
+{{ with .Instance "main1" "m1i2" }}{{ .SetOptions (dict "params" (dict "title" "Main1 Instance 2"))}}{{ end }}
+{{ end }}
+{{ with $batch.Group "reactbatch" }}
+ {{ with .Runner "reactrunner" }}
+ {{ .SetOptions ( dict "resource" (resources.Get "js/reactrunner.js") )}}
+ {{ end }}
+ {{ with .Script "r1" }}
+ {{ .SetOptions (dict
+ "resource" (resources.Get "js/react1.jsx")
+ "importContext" (slice $myContentBundle $otherCSS)
+ "params" (dict "id" "r1")
+ )
+ }}
+ {{ end }}
+ {{ with .Instance "r1" "i1" }}{{ .SetOptions (dict "params" (dict "title" "Instance 1"))}}{{ end }}
+ {{ with .Instance "r1" "i2" }}{{ .SetOptions (dict "params" (dict "title" "Instance 2"))}}{{ end }}
+ {{ with .Script "r2" }}
+ {{ .SetOptions (dict
+ "resource" (resources.Get "js/react2.jsx")
+ "export" "MyOtherButton"
+ "importContext" $otherCSS
+ "params" (dict "id" "r2")
+ )
+ }}
+ {{ end }}
+ {{ with .Instance "r2" "i1" }}{{ .SetOptions (dict "params" (dict "title" "Instance 2-1"))}}{{ end }}
+{{ end }}
+
+`
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: t,
+ NeedsOsFS: true,
+ NeedsNpmInstall: true,
+ TxtarString: files,
+ Running: true,
+ LogLevel: logg.LevelWarn,
+ // PrintAndKeepTempDir: true,
+ }).Build()
+
+ b.AssertFileContent("public/index.html",
+ "mains: 0: /mybundle/mains.js",
+ "reactbatch: 2: /mybundle/reactbatch.css",
+ )
+
+ b.AssertFileContent("public/mybundle/reactbatch.css",
+ ".bar {",
+ )
+
+ // Verify params resolution.
+ b.AssertFileContent("public/mybundle/mains.js",
+ `
+var id = "main1";
+console.log("main1.params.id", id);
+var id2 = "main2";
+console.log("main2.params.id", id2);
+
+
+# Params from top level config.
+var id3 = "config";
+console.log("main3.params.id", void 0);
+console.log("config.params.id", id3);
+`)
+
+ b.EditFileReplaceAll("content/mybundle/mybundlestyles.css", ".mybundlestyles", ".mybundlestyles-edit").Build()
+ b.AssertFileContent("public/mybundle/reactbatch.css", ".mybundlestyles-edit {")
+
+ b.EditFileReplaceAll("assets/other/bar.css", ".bar {", ".bar-edit {").Build()
+ b.AssertFileContent("public/mybundle/reactbatch.css", ".bar-edit {")
+
+ b.EditFileReplaceAll("assets/other/bar.css", ".bar-edit {", ".bar-edit2 {").Build()
+ b.AssertFileContent("public/mybundle/reactbatch.css", ".bar-edit2 {")
+}
+
+func TestEditBaseofManyTimes(t *testing.T) {
+ files := `
+-- hugo.toml --
+baseURL = "https://example.com"
+disableLiveReload = true
+disableKinds = ["taxonomy", "term"]
+-- layouts/_default/baseof.html --
+Baseof.
+{{ block "main" . }}{{ end }}
+{{ with (templates.Defer (dict "key" "global")) }}
+Now. {{ now }}
+{{ end }}
+-- layouts/_default/single.html --
+{{ define "main" }}
+Single.
+{{ end }}
+--
+-- layouts/_default/list.html --
+{{ define "main" }}
+List.
+{{ end }}
+-- content/mybundle/index.md --
+---
+title: "My Bundle"
+---
+-- content/_index.md --
+---
+title: "Home"
+---
+`
+
+ b := hugolib.TestRunning(t, files)
+ b.AssertFileContent("public/index.html", "Baseof.")
+
+ for i := 0; i < 100; i++ {
+ b.EditFileReplaceAll("layouts/_default/baseof.html", "Now", "Now.").Build()
+ b.AssertFileContent("public/index.html", "Now..")
+ }
+}
diff --git a/internal/js/esbuild/build.go b/internal/js/esbuild/build.go
new file mode 100644
index 000000000..33b91eafc
--- /dev/null
+++ b/internal/js/esbuild/build.go
@@ -0,0 +1,236 @@
+// 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 provides functions for building JavaScript resources.
+package esbuild
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/evanw/esbuild/pkg/api"
+ "github.com/gohugoio/hugo/common/herrors"
+ "github.com/gohugoio/hugo/common/hugio"
+ "github.com/gohugoio/hugo/common/text"
+ "github.com/gohugoio/hugo/hugofs"
+ "github.com/gohugoio/hugo/hugolib/filesystems"
+ "github.com/gohugoio/hugo/identity"
+ "github.com/gohugoio/hugo/resources"
+)
+
+// NewBuildClient creates a new BuildClient.
+func NewBuildClient(fs *filesystems.SourceFilesystem, rs *resources.Spec) *BuildClient {
+ return &BuildClient{
+ rs: rs,
+ sfs: fs,
+ }
+}
+
+// BuildClient is a client for building JavaScript resources using esbuild.
+type BuildClient struct {
+ rs *resources.Spec
+ sfs *filesystems.SourceFilesystem
+}
+
+// Build builds the given JavaScript resources using esbuild with the given options.
+func (c *BuildClient) Build(opts Options) (api.BuildResult, error) {
+ dependencyManager := opts.DependencyManager
+ if dependencyManager == nil {
+ dependencyManager = identity.NopManager
+ }
+
+ opts.OutDir = c.rs.AbsPublishDir
+ opts.ResolveDir = c.rs.Cfg.BaseConfig().WorkingDir // where node_modules gets resolved
+ opts.AbsWorkingDir = opts.ResolveDir
+ opts.TsConfig = c.rs.ResolveJSConfigFile("tsconfig.json")
+ assetsResolver := newFSResolver(c.rs.Assets.Fs)
+
+ if err := opts.validate(); err != nil {
+ return api.BuildResult{}, err
+ }
+
+ if err := opts.compile(); err != nil {
+ return api.BuildResult{}, err
+ }
+
+ var err error
+ opts.compiled.Plugins, err = createBuildPlugins(c.rs, assetsResolver, dependencyManager, opts)
+ if err != nil {
+ return api.BuildResult{}, err
+ }
+
+ if opts.Inject != nil {
+ // Resolve the absolute filenames.
+ for i, ext := range opts.Inject {
+ impPath := filepath.FromSlash(ext)
+ if filepath.IsAbs(impPath) {
+ return api.BuildResult{}, fmt.Errorf("inject: absolute paths not supported, must be relative to /assets")
+ }
+
+ m := assetsResolver.resolveComponent(impPath)
+
+ if m == nil {
+ return api.BuildResult{}, fmt.Errorf("inject: file %q not found", ext)
+ }
+
+ opts.Inject[i] = m.Filename
+
+ }
+
+ opts.compiled.Inject = opts.Inject
+
+ }
+
+ result := api.Build(opts.compiled)
+
+ if len(result.Errors) > 0 {
+ createErr := func(msg api.Message) error {
+ if msg.Location == nil {
+ return errors.New(msg.Text)
+ }
+ var (
+ contentr hugio.ReadSeekCloser
+ errorMessage string
+ loc = msg.Location
+ errorPath = loc.File
+ err error
+ )
+
+ var resolvedError *ErrorMessageResolved
+
+ if opts.ErrorMessageResolveFunc != nil {
+ resolvedError = opts.ErrorMessageResolveFunc(msg)
+ }
+
+ if resolvedError == nil {
+ if errorPath == stdinImporter {
+ errorPath = opts.StdinSourcePath
+ }
+
+ errorMessage = msg.Text
+
+ var namespace string
+ for _, ns := range hugoNamespaces {
+ if strings.HasPrefix(errorPath, ns) {
+ namespace = ns
+ break
+ }
+ }
+
+ if namespace != "" {
+ namespace += ":"
+ errorMessage = strings.ReplaceAll(errorMessage, namespace, "")
+ errorPath = strings.TrimPrefix(errorPath, namespace)
+ contentr, err = hugofs.Os.Open(errorPath)
+ } else {
+ var fi os.FileInfo
+ fi, err = c.sfs.Fs.Stat(errorPath)
+ if err == nil {
+ m := fi.(hugofs.FileMetaInfo).Meta()
+ errorPath = m.Filename
+ contentr, err = m.Open()
+ }
+ }
+ } else {
+ contentr = resolvedError.Content
+ errorPath = resolvedError.Path
+ errorMessage = resolvedError.Message
+ }
+
+ if contentr != nil {
+ defer contentr.Close()
+ }
+
+ if err == nil {
+ fe := herrors.
+ NewFileErrorFromName(errors.New(errorMessage), errorPath).
+ UpdatePosition(text.Position{Offset: -1, LineNumber: loc.Line, ColumnNumber: loc.Column}).
+ UpdateContent(contentr, nil)
+
+ return fe
+ }
+
+ return fmt.Errorf("%s", errorMessage)
+ }
+
+ var errors []error
+
+ for _, msg := range result.Errors {
+ errors = append(errors, createErr(msg))
+ }
+
+ // Return 1, log the rest.
+ for i, err := range errors {
+ if i > 0 {
+ c.rs.Logger.Errorf("js.Build failed: %s", err)
+ }
+ }
+
+ return result, errors[0]
+ }
+
+ inOutputPathToAbsFilename := opts.ResolveSourceMapSource
+ opts.ResolveSourceMapSource = func(s string) string {
+ if inOutputPathToAbsFilename != nil {
+ if filename := inOutputPathToAbsFilename(s); filename != "" {
+ return filename
+ }
+ }
+
+ if m := assetsResolver.resolveComponent(s); m != nil {
+ return m.Filename
+ }
+
+ return ""
+ }
+
+ for i, o := range result.OutputFiles {
+ if err := fixOutputFile(&o, func(s string) string {
+ if s == "<stdin>" {
+ return opts.ResolveSourceMapSource(opts.StdinSourcePath)
+ }
+ var isNsHugo bool
+ if strings.HasPrefix(s, "ns-hugo") {
+ isNsHugo = true
+ idxColon := strings.Index(s, ":")
+ s = s[idxColon+1:]
+ }
+
+ if !strings.HasPrefix(s, PrefixHugoVirtual) {
+ if !filepath.IsAbs(s) {
+ s = filepath.Join(opts.OutDir, s)
+ }
+ }
+
+ if isNsHugo {
+ if ss := opts.ResolveSourceMapSource(s); ss != "" {
+ if strings.HasPrefix(ss, PrefixHugoMemory) {
+ // File not on disk, mark it for removal from the sources slice.
+ return ""
+ }
+ return ss
+ }
+ return ""
+ }
+ return s
+ }); err != nil {
+ return result, err
+ }
+ result.OutputFiles[i] = o
+ }
+
+ return result, nil
+}
diff --git a/internal/js/esbuild/helpers.go b/internal/js/esbuild/helpers.go
new file mode 100644
index 000000000..b4cb565b8
--- /dev/null
+++ b/internal/js/esbuild/helpers.go
@@ -0,0 +1,15 @@
+// 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 provides functions for building JavaScript resources.
+package esbuild
diff --git a/internal/js/esbuild/options.go b/internal/js/esbuild/options.go
new file mode 100644
index 000000000..16fc0d4bb
--- /dev/null
+++ b/internal/js/esbuild/options.go
@@ -0,0 +1,375 @@
+// 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"
+ "path/filepath"
+ "strings"
+
+ "github.com/gohugoio/hugo/common/hugio"
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/common/paths"
+ "github.com/gohugoio/hugo/identity"
+
+ "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,
+ }
+
+ // 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,
+ }
+)
+
+// 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)
+ }
+
+ opts.Target = strings.ToLower(opts.Target)
+ 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
+
+ // The language target.
+ // One of: es2015, es2016, es2017, es2018, es2019, es2020 or esnext.
+ // Default is esnext.
+ Target string
+
+ // The output format.
+ // One of: iife, cjs, esm
+ // Default is to esm.
+ Format 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
+
+ // Maps a component import to another.
+ Shims map[string]string
+
+ // 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
+
+ // 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
+
+ StdinSourcePath string
+
+ DependencyManager identity.Manager
+
+ Stdin bool // Set to true to pass in the entry point as a byte slice.
+ Splitting bool
+ TsConfig string
+ EntryPoints []string
+ ImportOnResolveFunc func(string, api.OnResolveArgs) string
+ ImportOnLoadFunc func(api.OnLoadArgs) string
+ 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) {
+ target, found := nameTarget[opts.Target]
+ if !found {
+ err = fmt.Errorf("invalid target: %q", opts.Target)
+ return
+ }
+
+ var loaders map[string]api.Loader
+ if opts.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
+ 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 defines map[string]string
+ if opts.Defines != nil {
+ defines = maps.ToStringMapString(opts.Defines)
+ }
+
+ // 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
+ }
+
+ opts.compiled = api.BuildOptions{
+ Outfile: outFile,
+ Bundle: true,
+ Metafile: opts.Metafile,
+ AbsWorkingDir: opts.AbsWorkingDir,
+
+ Target: target,
+ Format: format,
+ Sourcemap: sourceMap,
+ SourcesContent: sourcesContent,
+
+ Loader: loaders,
+
+ MinifyWhitespace: opts.Minify,
+ MinifyIdentifiers: opts.Minify,
+ MinifySyntax: opts.Minify,
+
+ Outdir: outDir,
+ Splitting: opts.Splitting,
+
+ Define: defines,
+ External: opts.Externals,
+
+ 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
+ }
+ }
+ l, found := extensionToLoaderMap[ext]
+ if found {
+ return l
+ }
+ return api.LoaderJS
+}
+
+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
+}
diff --git a/internal/js/esbuild/options_test.go b/internal/js/esbuild/options_test.go
new file mode 100644
index 000000000..ca19717f7
--- /dev/null
+++ b/internal/js/esbuild/options_test.go
@@ -0,0 +1,219 @@
+// 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 (
+ "testing"
+
+ "github.com/gohugoio/hugo/media"
+
+ "github.com/evanw/esbuild/pkg/api"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestToBuildOptions(t *testing.T) {
+ c := qt.New(t)
+
+ opts := Options{
+ InternalOptions: InternalOptions{
+ MediaType: media.Builtin.JavascriptType,
+ Stdin: true,
+ },
+ }
+
+ c.Assert(opts.compile(), qt.IsNil)
+ c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{
+ Bundle: true,
+ Target: api.ESNext,
+ Format: api.FormatIIFE,
+ SourcesContent: 1,
+ Stdin: &api.StdinOptions{
+ Loader: api.LoaderJS,
+ },
+ })
+
+ opts = Options{
+ ExternalOptions: ExternalOptions{
+ Target: "es2018",
+ Format: "cjs",
+ Minify: true,
+ AvoidTDZ: true,
+ },
+ InternalOptions: InternalOptions{
+ MediaType: media.Builtin.JavascriptType,
+ Stdin: true,
+ },
+ }
+
+ c.Assert(opts.compile(), qt.IsNil)
+ c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{
+ Bundle: true,
+ Target: api.ES2018,
+ Format: api.FormatCommonJS,
+ SourcesContent: 1,
+ MinifyIdentifiers: true,
+ MinifySyntax: true,
+ MinifyWhitespace: true,
+ Stdin: &api.StdinOptions{
+ Loader: api.LoaderJS,
+ },
+ })
+
+ opts = Options{
+ ExternalOptions: ExternalOptions{
+ Target: "es2018", Format: "cjs", Minify: true,
+ SourceMap: "inline",
+ },
+ InternalOptions: InternalOptions{
+ MediaType: media.Builtin.JavascriptType,
+ Stdin: true,
+ },
+ }
+
+ c.Assert(opts.compile(), qt.IsNil)
+ c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{
+ Bundle: true,
+ Target: api.ES2018,
+ Format: api.FormatCommonJS,
+ MinifyIdentifiers: true,
+ MinifySyntax: true,
+ MinifyWhitespace: true,
+ SourcesContent: 1,
+ Sourcemap: api.SourceMapInline,
+ Stdin: &api.StdinOptions{
+ Loader: api.LoaderJS,
+ },
+ })
+
+ opts = Options{
+ ExternalOptions: ExternalOptions{
+ Target: "es2018", Format: "cjs", Minify: true,
+ SourceMap: "inline",
+ },
+ InternalOptions: InternalOptions{
+ MediaType: media.Builtin.JavascriptType,
+ Stdin: true,
+ },
+ }
+
+ c.Assert(opts.compile(), qt.IsNil)
+ c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{
+ Bundle: true,
+ Target: api.ES2018,
+ Format: api.FormatCommonJS,
+ MinifyIdentifiers: true,
+ MinifySyntax: true,
+ MinifyWhitespace: true,
+ Sourcemap: api.SourceMapInline,
+ SourcesContent: 1,
+ Stdin: &api.StdinOptions{
+ Loader: api.LoaderJS,
+ },
+ })
+
+ opts = Options{
+ ExternalOptions: ExternalOptions{
+ Target: "es2018", Format: "cjs", Minify: true,
+ SourceMap: "external",
+ },
+ InternalOptions: InternalOptions{
+ MediaType: media.Builtin.JavascriptType,
+ Stdin: true,
+ },
+ }
+
+ c.Assert(opts.compile(), qt.IsNil)
+ c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{
+ Bundle: true,
+ Target: api.ES2018,
+ Format: api.FormatCommonJS,
+ MinifyIdentifiers: true,
+ MinifySyntax: true,
+ MinifyWhitespace: true,
+ Sourcemap: api.SourceMapExternal,
+ SourcesContent: 1,
+ Stdin: &api.StdinOptions{
+ Loader: api.LoaderJS,
+ },
+ })
+
+ opts = Options{
+ ExternalOptions: ExternalOptions{
+ JSX: "automatic", JSXImportSource: "preact",
+ },
+ InternalOptions: InternalOptions{
+ MediaType: media.Builtin.JavascriptType,
+ Stdin: true,
+ },
+ }
+
+ c.Assert(opts.compile(), qt.IsNil)
+ c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{
+ Bundle: true,
+ Target: api.ESNext,
+ Format: api.FormatIIFE,
+ SourcesContent: 1,
+ Stdin: &api.StdinOptions{
+ Loader: api.LoaderJS,
+ },
+ JSX: api.JSXAutomatic,
+ JSXImportSource: "preact",
+ })
+}
+
+func TestToBuildOptionsTarget(t *testing.T) {
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ target string
+ expect api.Target
+ }{
+ {"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},
+ {"", api.ESNext},
+ {"esnext", api.ESNext},
+ } {
+ c.Run(test.target, func(c *qt.C) {
+ opts := Options{
+ ExternalOptions: ExternalOptions{
+ Target: test.target,
+ },
+ InternalOptions: InternalOptions{
+ MediaType: media.Builtin.JavascriptType,
+ },
+ }
+
+ c.Assert(opts.compile(), qt.IsNil)
+ c.Assert(opts.compiled.Target, qt.Equals, test.expect)
+ })
+ }
+}
+
+func TestDecodeExternalOptions(t *testing.T) {
+ c := qt.New(t)
+ m := map[string]any{}
+ opts, err := DecodeExternalOptions(m)
+ c.Assert(err, qt.IsNil)
+ c.Assert(opts, qt.DeepEquals, ExternalOptions{
+ SourcesContent: true,
+ })
+}
diff --git a/internal/js/esbuild/resolve.go b/internal/js/esbuild/resolve.go
new file mode 100644
index 000000000..ac0010da9
--- /dev/null
+++ b/internal/js/esbuild/resolve.go
@@ -0,0 +1,315 @@
+// 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"
+ "strings"
+
+ "github.com/evanw/esbuild/pkg/api"
+ "github.com/gohugoio/hugo/common/maps"
+ "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"
+)
+
+const (
+ NsHugoImport = "ns-hugo-imp"
+ NsHugoImportResolveFunc = "ns-hugo-imp-func"
+ nsHugoParams = "ns-hugo-params"
+ pathHugoConfigParams = "@params/config"
+
+ stdinImporter = "<stdin>"
+)
+
+var hugoNamespaces = []string{NsHugoImport, NsHugoImportResolveFunc, nsHugoParams}
+
+const (
+ PrefixHugoVirtual = "__hu_v"
+ PrefixHugoMemory = "__hu_m"
+)
+
+var extensionToLoaderMap = map[string]api.Loader{
+ ".js": api.LoaderJS,
+ ".mjs": api.LoaderJS,
+ ".cjs": api.LoaderJS,
+ ".jsx": api.LoaderJSX,
+ ".ts": api.LoaderTS,
+ ".tsx": api.LoaderTSX,
+ ".css": api.LoaderCSS,
+ ".json": api.LoaderJSON,
+ ".txt": api.LoaderText,
+}
+
+// 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: maps.NewCache[string, *hugofs.FileMeta]()}
+}
+
+type fsResolver struct {
+ fs afero.Fs
+ resolved *maps.Cache[string, *hugofs.FileMeta]
+}
+
+func (r *fsResolver) resolveComponent(impPath string) *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
+ }
+ 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
+ shimmed := false
+ if opts.Shims != nil {
+ override, found := opts.Shims[impPath]
+ if found {
+ impPath = override
+ shimmed = true
+ }
+ }
+
+ 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 strings.HasPrefix(importer, PrefixHugoVirtual) {
+ relDir = filepath.Dir(strings.TrimPrefix(importer, PrefixHugoVirtual))
+ } 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
+ }
+
+ // Imports not starting with a "." is assumed to live relative to /assets.
+ // Hugo makes no assumptions about the directory structure below /assets.
+ if relDir != "" && strings.HasPrefix(impPath, ".") {
+ impPath = filepath.Join(relDir, impPath)
+ }
+
+ m := assetsResolver.resolveComponent(impPath)
+
+ if m != nil {
+ depsManager.AddIdentity(m.PathInfo)
+
+ // 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 := opts.ImportOnLoadFunc(args)
+ 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
+ })
+ },
+ }
+
+ return []api.Plugin{importResolver, paramsPlugin}, nil
+}
diff --git a/internal/js/esbuild/resolve_test.go b/internal/js/esbuild/resolve_test.go
new file mode 100644
index 000000000..86e3138f2
--- /dev/null
+++ b/internal/js/esbuild/resolve_test.go
@@ -0,0 +1,86 @@
+// 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 (
+ "path"
+ "path/filepath"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/config"
+ "github.com/gohugoio/hugo/config/testconfig"
+ "github.com/gohugoio/hugo/hugofs"
+ "github.com/gohugoio/hugo/hugolib/filesystems"
+ "github.com/gohugoio/hugo/hugolib/paths"
+ "github.com/spf13/afero"
+)
+
+func TestResolveComponentInAssets(t *testing.T) {
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ name string
+ files []string
+ impPath string
+ expect string
+ }{
+ {"Basic, extension", []string{"foo.js", "bar.js"}, "foo.js", "foo.js"},
+ {"Basic, no extension", []string{"foo.js", "bar.js"}, "foo", "foo.js"},
+ {"Basic, no extension, typescript", []string{"foo.ts", "bar.js"}, "foo", "foo.ts"},
+ {"Not found", []string{"foo.js", "bar.js"}, "moo.js", ""},
+ {"Not found, double js extension", []string{"foo.js.js", "bar.js"}, "foo.js", ""},
+ {"Index file, folder only", []string{"foo/index.js", "bar.js"}, "foo", "foo/index.js"},
+ {"Index file, folder and index", []string{"foo/index.js", "bar.js"}, "foo/index", "foo/index.js"},
+ {"Index file, folder and index and suffix", []string{"foo/index.js", "bar.js"}, "foo/index.js", "foo/index.js"},
+ {"Index ESM file, folder only", []string{"foo/index.esm.js", "bar.js"}, "foo", "foo/index.esm.js"},
+ {"Index ESM file, folder and index", []string{"foo/index.esm.js", "bar.js"}, "foo/index", "foo/index.esm.js"},
+ {"Index ESM file, folder and index and suffix", []string{"foo/index.esm.js", "bar.js"}, "foo/index.esm.js", "foo/index.esm.js"},
+ // We added these index.esm.js cases in v0.101.0. The case below is unlikely to happen in the wild, but add a test
+ // to document Hugo's behavior. We pick the file with the name index.js; anything else would be breaking.
+ {"Index and Index ESM file, folder only", []string{"foo/index.esm.js", "foo/index.js", "bar.js"}, "foo", "foo/index.js"},
+
+ // Issue #8949
+ {"Check file before directory", []string{"foo.js", "foo/index.js"}, "foo", "foo.js"},
+ } {
+ c.Run(test.name, func(c *qt.C) {
+ baseDir := "assets"
+ mfs := afero.NewMemMapFs()
+
+ for _, filename := range test.files {
+ c.Assert(afero.WriteFile(mfs, filepath.Join(baseDir, filename), []byte("let foo='bar';"), 0o777), qt.IsNil)
+ }
+
+ conf := testconfig.GetTestConfig(mfs, config.New())
+ fs := hugofs.NewFrom(mfs, conf.BaseConfig())
+
+ p, err := paths.New(fs, conf)
+ c.Assert(err, qt.IsNil)
+ bfs, err := filesystems.NewBase(p, nil)
+ c.Assert(err, qt.IsNil)
+ resolver := newFSResolver(bfs.Assets.Fs)
+
+ got := resolver.resolveComponent(test.impPath)
+
+ gotPath := ""
+ expect := test.expect
+ if got != nil {
+ gotPath = filepath.ToSlash(got.Filename)
+ expect = path.Join(baseDir, test.expect)
+ }
+
+ c.Assert(gotPath, qt.Equals, expect)
+ })
+ }
+}
diff --git a/internal/js/esbuild/sourcemap.go b/internal/js/esbuild/sourcemap.go
new file mode 100644
index 000000000..647f0c081
--- /dev/null
+++ b/internal/js/esbuild/sourcemap.go
@@ -0,0 +1,80 @@
+// 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"
+ "strings"
+
+ "github.com/evanw/esbuild/pkg/api"
+ "github.com/gohugoio/hugo/common/paths"
+)
+
+type sourceMap struct {
+ Version int `json:"version"`
+ Sources []string `json:"sources"`
+ SourcesContent []string `json:"sourcesContent"`
+ Mappings string `json:"mappings"`
+ Names []string `json:"names"`
+}
+
+func fixOutputFile(o *api.OutputFile, resolve func(string) string) error {
+ if strings.HasSuffix(o.Path, ".map") {
+ b, err := fixSourceMap(o.Contents, resolve)
+ if err != nil {
+ return err
+ }
+ o.Contents = b
+ }
+ return nil
+}
+
+func fixSourceMap(s []byte, resolve func(string) string) ([]byte, error) {
+ var sm sourceMap
+ if err := json.Unmarshal([]byte(s), &sm); err != nil {
+ return nil, err
+ }
+
+ sm.Sources = fixSourceMapSources(sm.Sources, resolve)
+
+ b, err := json.Marshal(sm)
+ if err != nil {
+ return nil, err
+ }
+
+ return b, nil
+}
+
+func fixSourceMapSources(s []string, resolve func(string) string) []string {
+ var result []string
+ for _, src := range s {
+ if s := resolve(src); s != "" {
+ // Absolute filenames works fine on U*ix (tested in Chrome on MacOs), but works very poorly on Windows (again Chrome).
+ // So, convert it to a URL.
+ if u, err := paths.UrlFromFilename(s); err == nil {
+ result = append(result, u.String())
+ }
+ }
+ }
+ return result
+}
+
+// Used in tests.
+func SourcesFromSourceMap(s string) []string {
+ var sm sourceMap
+ if err := json.Unmarshal([]byte(s), &sm); err != nil {
+ return nil
+ }
+ return sm.Sources
+}