From e293e7ca6dcc34cded7eb90a644b5c720c2179cf Mon Sep 17 00:00:00 2001 From: Bjørn Erik Pedersen Date: Tue, 10 Dec 2024 16:22:08 +0100 Subject: Add js.Batch Fixes #12626 Closes #7499 Closes #9978 Closes #12879 Closes #13113 Fixes #13116 --- internal/js/esbuild/batch-esm-runner.gotmpl | 20 + internal/js/esbuild/batch.go | 1437 +++++++++++++++++++++++++ internal/js/esbuild/batch_integration_test.go | 686 ++++++++++++ internal/js/esbuild/build.go | 236 ++++ internal/js/esbuild/helpers.go | 15 + internal/js/esbuild/options.go | 375 +++++++ internal/js/esbuild/options_test.go | 219 ++++ internal/js/esbuild/resolve.go | 315 ++++++ internal/js/esbuild/resolve_test.go | 86 ++ internal/js/esbuild/sourcemap.go | 80 ++ 10 files changed, 3469 insertions(+) create mode 100644 internal/js/esbuild/batch-esm-runner.gotmpl create mode 100644 internal/js/esbuild/batch.go create mode 100644 internal/js/esbuild/batch_integration_test.go create mode 100644 internal/js/esbuild/build.go create mode 100644 internal/js/esbuild/helpers.go create mode 100644 internal/js/esbuild/options.go create mode 100644 internal/js/esbuild/options_test.go create mode 100644 internal/js/esbuild/resolve.go create mode 100644 internal/js/esbuild/resolve_test.go create mode 100644 internal/js/esbuild/sourcemap.go (limited to 'internal') 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 ( + + ) +} + +-- 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 ( + + ) +} + +-- 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 ( + + ) +} +-- 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 == "" { + 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 = "" +) + +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 +} -- cgit v1.2.3