diff --git a/commands/commandeer.go b/commands/commandeer.go index 69077ad73..84e344218 100644 --- a/commands/commandeer.go +++ b/commands/commandeer.go @@ -455,6 +455,7 @@ func (r *rootCommand) PreRun(cd, runner *simplecobra.Commandeer) error { r.hugoSites = lazycache.New(lazycache.Options[configKey, *hugolib.HugoSites]{ MaxEntries: 1, OnEvict: func(key configKey, value *hugolib.HugoSites) { + fmt.Println("Evicting HugoSites", key) // TODO1 remove me. value.Close() runtime.GC() }, diff --git a/common/herrors/errors.go b/common/herrors/errors.go index 40833e55c..c7ee90dd0 100644 --- a/common/herrors/errors.go +++ b/common/herrors/errors.go @@ -133,6 +133,21 @@ func IsNotExist(err error) bool { return false } +// IsExist returns true if the error is a file exists error. +// Unlike os.IsExist, this also considers wrapped errors. +func IsExist(err error) bool { + if os.IsExist(err) { + return true + } + + // os.IsExist does not consider wrapped errors. + if os.IsExist(errors.Unwrap(err)) { + return true + } + + return false +} + var nilPointerErrRe = regexp.MustCompile(`at <(.*)>: error calling (.*?): runtime error: invalid memory address or nil pointer dereference`) const deferredPrefix = "__hdeferred/" diff --git a/common/maps/cache.go b/common/maps/cache.go index 0175974b5..dec1abe21 100644 --- a/common/maps/cache.go +++ b/common/maps/cache.go @@ -69,11 +69,14 @@ func (c *Cache[K, T]) Set(key K, value T) { } // ForEeach calls the given function for each key/value pair in the cache. -func (c *Cache[K, T]) ForEeach(f func(K, T)) { +// If the function returns false, the iteration stops. +func (c *Cache[K, T]) ForEeach(f func(K, T) bool) { c.RLock() defer c.RUnlock() for k, v := range c.m { - f(k, v) + if !f(k, v) { + return + } } } diff --git a/common/maps/scratch.go b/common/maps/scratch.go index e9f412540..0d47f452c 100644 --- a/common/maps/scratch.go +++ b/common/maps/scratch.go @@ -107,6 +107,23 @@ func (c *Scratch) Get(key string) any { return val } +// GetOrCreate returns the value for the given key if it exists, or creates it +// using the given func and stores that value in the map. +// For internal use. +func (c *Scratch) GetOrCreate(key string, create func() (any, error)) (any, error) { + c.mu.Lock() + defer c.mu.Unlock() + if val, found := c.values[key]; found { + return val, nil + } + val, err := create() + if err != nil { + return nil, err + } + c.values[key] = val + return val, nil +} + // Values returns the raw backing map. Note that you should just use // this method on the locally scoped Scratch instances you obtain via newScratch, not // .Page.Scratch etc., as that will lead to concurrency issues. diff --git a/common/types/closer.go b/common/types/closer.go index 2844b1986..9f8875a8a 100644 --- a/common/types/closer.go +++ b/common/types/closer.go @@ -19,6 +19,13 @@ type Closer interface { Close() error } +// CloserFunc is a convenience type to create a Closer from a function. +type CloserFunc func() error + +func (f CloserFunc) Close() error { + return f() +} + type CloseAdder interface { Add(Closer) } diff --git a/config/allconfig/configlanguage.go b/config/allconfig/configlanguage.go index 38d2309ef..deec61449 100644 --- a/config/allconfig/configlanguage.go +++ b/config/allconfig/configlanguage.go @@ -137,11 +137,11 @@ func (c ConfigLanguage) Watching() bool { return c.m.Base.Internal.Watch } -func (c ConfigLanguage) NewIdentityManager(name string) identity.Manager { +func (c ConfigLanguage) NewIdentityManager(name string, opts ...identity.ManagerOption) identity.Manager { if !c.Watching() { return identity.NopManager } - return identity.NewManager(name) + return identity.NewManager(name, opts...) } func (c ConfigLanguage) ContentTypes() config.ContentTypesProvider { diff --git a/config/configProvider.go b/config/configProvider.go index ee6691cf1..5bda2c55a 100644 --- a/config/configProvider.go +++ b/config/configProvider.go @@ -58,7 +58,7 @@ type AllProvider interface { BuildDrafts() bool Running() bool Watching() bool - NewIdentityManager(name string) identity.Manager + NewIdentityManager(name string, opts ...identity.ManagerOption) identity.Manager FastRenderMode() bool PrintUnusedTemplates() bool EnableMissingTranslationPlaceholders() bool diff --git a/debug.log b/debug.log new file mode 100644 index 000000000..e69de29bb diff --git a/deps/deps.go b/deps/deps.go index 4389036cb..aa24ce4f2 100644 --- a/deps/deps.go +++ b/deps/deps.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "os" "path/filepath" "sort" "strings" @@ -268,6 +269,20 @@ func (d *Deps) Compile(prototype *Deps) error { return nil } +// MkdirTemp returns a temporary directory path that will be cleaned up on exit. +func (d Deps) MkdirTemp(pattern string) (string, error) { + filename, err := os.MkdirTemp("", pattern) + if err != nil { + return "", err + } + d.BuildClosers.Add(types.CloserFunc( + func() error { + return os.RemoveAll(filename) + })) + + return filename, nil +} + type globalErrHandler struct { logger loggers.Logger diff --git a/hugolib/hugo_sites.go b/hugolib/hugo_sites.go index a5186fd44..792d6a990 100644 --- a/hugolib/hugo_sites.go +++ b/hugolib/hugo_sites.go @@ -111,6 +111,10 @@ func (h *HugoSites) ShouldSkipFileChangeEvent(ev fsnotify.Event) bool { return h.skipRebuildForFilenames[ev.Name] } +func (h *HugoSites) Close() error { + return h.Deps.Close() +} + func (h *HugoSites) isRebuild() bool { return h.buildCounter.Load() > 0 } diff --git a/hugolib/hugo_sites_build.go b/hugolib/hugo_sites_build.go index dd548be51..cb2c5be79 100644 --- a/hugolib/hugo_sites_build.go +++ b/hugolib/hugo_sites_build.go @@ -520,8 +520,9 @@ func (s *Site) executeDeferredTemplates(de *deps.DeferredExecutions) error { }, }) - de.FilenamesWithPostPrefix.ForEeach(func(filename string, _ bool) { + de.FilenamesWithPostPrefix.ForEeach(func(filename string, _ bool) bool { g.Enqueue(filename) + return true }) return g.Wait() diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go index 5dc13592f..d0d029e43 100644 --- a/hugolib/integrationtest_builder.go +++ b/hugolib/integrationtest_builder.go @@ -83,6 +83,13 @@ func TestOptWithNFDOnDarwin() TestOpt { } } +// TestOptWithOSFs enables the real file system. +func TestOptWithOSFs() TestOpt { + return func(c *IntegrationTestConfig) { + c.NeedsOsFS = true + } +} + // TestOptWithWorkingDir allows setting any config optiona as a function al option. func TestOptWithConfig(fn func(c *IntegrationTestConfig)) TestOpt { return func(c *IntegrationTestConfig) { @@ -277,8 +284,9 @@ func (s *IntegrationTestBuilder) negate(match string) (string, bool) { func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...string) { s.Helper() content := strings.TrimSpace(s.FileContent(filename)) + for _, m := range matches { - cm := qt.Commentf("File: %s Match %s", filename, m) + cm := qt.Commentf("File: %s Match %s\nContent:\n%s", filename, m, content) lines := strings.Split(m, "\n") for _, match := range lines { match = strings.TrimSpace(match) @@ -288,7 +296,8 @@ func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...s var negate bool match, negate = s.negate(match) if negate { - s.Assert(content, qt.Not(qt.Contains), match, cm) + if !s.Assert(content, qt.Not(qt.Contains), match, cm) { + } continue } s.Assert(content, qt.Contains, match, cm) @@ -306,7 +315,8 @@ func (s *IntegrationTestBuilder) AssertFileContentExact(filename string, matches s.Helper() content := s.FileContent(filename) for _, m := range matches { - s.Assert(content, qt.Contains, m, qt.Commentf(m)) + cm := qt.Commentf("File: %s Match %s\nContent:\n%s", filename, m, content) + s.Assert(content, qt.Contains, m, cm) } } @@ -443,6 +453,11 @@ func (s *IntegrationTestBuilder) Build() *IntegrationTestBuilder { return s } +func (s *IntegrationTestBuilder) Close() { + s.Helper() + s.Assert(s.H.Close(), qt.IsNil) +} + func (s *IntegrationTestBuilder) LogString() string { return s.lastBuildLog } diff --git a/hugolib/page__new.go b/hugolib/page__new.go index 9a4972d07..41d634822 100644 --- a/hugolib/page__new.go +++ b/hugolib/page__new.go @@ -174,6 +174,7 @@ func (h *HugoSites) doNewPage(m *pageMeta) (*pageState, *paths.Path, error) { return nil, m.wrapError(err, h.SourceFs) } + ps := &pageState{ pid: pid, pageOutput: nopPageOutput, diff --git a/hugolib/pages_capture.go b/hugolib/pages_capture.go index 96c2c0f96..3993e7ff3 100644 --- a/hugolib/pages_capture.go +++ b/hugolib/pages_capture.go @@ -149,7 +149,15 @@ func (c *pagesCollector) Collect() (collectErr error) { id.p, false, func(fim hugofs.FileMetaInfo) bool { - return true + if id.isStructuralChange() { + return true + } + fimp := fim.Meta().PathInfo + if fimp == nil { + return true + } + + return fimp.Path() == id.p.Path() }, ) } else if id.p.IsBranchBundle() { diff --git a/hugolib/pagesfromdata/pagesfromgotmpl.go b/hugolib/pagesfromdata/pagesfromgotmpl.go index fd7213bd9..79cdb621e 100644 --- a/hugolib/pagesfromdata/pagesfromgotmpl.go +++ b/hugolib/pagesfromdata/pagesfromgotmpl.go @@ -245,10 +245,11 @@ func (b *BuildState) resolveDeletedPaths() { return } var paths []string - b.sourceInfosPrevious.ForEeach(func(k string, _ *sourceInfo) { + b.sourceInfosPrevious.ForEeach(func(k string, _ *sourceInfo) bool { if _, found := b.sourceInfosCurrent.Get(k); !found { paths = append(paths, k) } + return true }) b.DeletedPaths = paths diff --git a/hugolib/rebuild_test.go b/hugolib/rebuild_test.go index 2219fe812..e3d7c7952 100644 --- a/hugolib/rebuild_test.go +++ b/hugolib/rebuild_test.go @@ -71,6 +71,18 @@ Foo. ` +func TestRebuildEditLeafBundleHeaderOnly(t *testing.T) { + b := TestRunning(t, rebuildFilesSimple) + b.AssertFileContent("public/mysection/mysectionbundle/index.html", + "My Section Bundle Content Content.") + + b.EditFileReplaceAll("content/mysection/mysectionbundle/index.md", "My Section Bundle Content.", "My Section Bundle Content Edited.").Build() + b.AssertFileContent("public/mysection/mysectionbundle/index.html", + "My Section Bundle Content Edited.") + b.AssertRenderCountPage(1) + b.AssertRenderCountContent(1) +} + func TestRebuildEditTextFileInLeafBundle(t *testing.T) { b := TestRunning(t, rebuildFilesSimple) b.AssertFileContent("public/mysection/mysectionbundle/index.html", diff --git a/hugolib/site.go b/hugolib/site.go index c5a4956e2..c76d2e8e8 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -1513,7 +1513,11 @@ func (s *Site) renderForTemplate(ctx context.Context, name, outputFormat string, } if err = s.Tmpl().ExecuteWithContext(ctx, templ, w, d); err != nil { - return fmt.Errorf("render of %q failed: %w", name, err) + filename := name + if p, ok := d.(*pageState); ok { + filename = p.pathOrTitle() + } + return fmt.Errorf("render of %q failed: %w", filename, err) } return } diff --git a/identity/identity.go b/identity/identity.go index d106eb1fc..6228982de 100644 --- a/identity/identity.go +++ b/identity/identity.go @@ -82,9 +82,8 @@ func FirstIdentity(v any) Identity { var result Identity = Anonymous WalkIdentitiesShallow(v, func(level int, id Identity) bool { result = id - return true + return result != Anonymous }) - return result } @@ -308,11 +307,13 @@ type identityManager struct { func (im *identityManager) AddIdentity(ids ...Identity) { im.mu.Lock() + defer im.mu.Unlock() for _, id := range ids { if id == nil || id == Anonymous { continue } + if _, found := im.ids[id]; !found { if im.onAddIdentity != nil { im.onAddIdentity(id) @@ -320,7 +321,6 @@ func (im *identityManager) AddIdentity(ids ...Identity) { im.ids[id] = true } } - im.mu.Unlock() } func (im *identityManager) AddIdentityForEach(ids ...ForEeachIdentityProvider) { diff --git a/internal/js/esbuild/batch-esm-runner.gotmpl b/internal/js/esbuild/batch-esm-runner.gotmpl new file mode 100644 index 000000000..bba13b00c --- /dev/null +++ b/internal/js/esbuild/batch-esm-runner.gotmpl @@ -0,0 +1,22 @@ +{{ 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 scripts = []; + {{ range $i, $e := .Scripts -}} + scripts.push({{ .RunnerJSON $i }}); + {{ end -}} + {{/* */}} + {{ range $i, $e := .Runners }} + {{ $id := printf "Run%d" $i }} + {{ $id }}(scripts); + {{ end }} +{{ end }} diff --git a/internal/js/esbuild/batch.go b/internal/js/esbuild/batch.go new file mode 100644 index 000000000..2ba3752da --- /dev/null +++ b/internal/js/esbuild/batch.go @@ -0,0 +1,1204 @@ +// 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" + "os" + "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/hugofs" + "github.com/gohugoio/hugo/identity" + "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/cast" +) + +var _ Batcher = (*batcher)(nil) + +var _ resource.StaleInfo = (*options)(nil) + +var _ identity.Identity = (*Package)(nil) + +const ( + NsBatch = "__hugo-js-batch" +) + +type optionsOptions struct { + name string + defaultExport string +} + +type optionsCompiler[C any] interface { + compileOptions(*options) (C, error) + isStaleCompiled(C) bool +} + +type optionsCompiled[C optionsCompiler[C]] struct { + opts *options + + // 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 + + optsPrev map[string]any + + zero C +} + +func (o *optionsCompiled[C]) compile() error { + var c C + c, err := c.compileOptions(o.opts) + if err != nil { + return err + } + o.compiledPrev = o.compiled + o.compiled = c + + if o.opts.v.isStale(o.optsPrev) || c.isStaleCompiled(o.compiledPrev) { + o.opts.staleVersion.Add(1) + } + + o.optsPrev = o.opts.v.optsCurr + + return nil +} + +func newOptions(opts optionsOptions) *options { + return &options{getOnce: getOnce[*optionsSetter]{ + v: &optionsSetter{opts: opts}, + }} +} + +type Batcher interface { + Build(context.Context) (*Package, error) + Config() OptionsSetter + Group(id string) BatcherGroup +} + +//go:embed batch-esm-runner.gotmpl +var runnerTemplateStr string + +func NewBatcherClient(deps *deps.Deps) (*BatcherClient, error) { + return &BatcherClient{ + d: deps, + buildClient: NewBuildClient(deps.BaseFs.Assets, deps.ResourceSpec), + createClient: create.New(deps.ResourceSpec), + bundlesCache: dynacache.GetOrCreatePartition[string, *Package]( + deps.MemCache, + "/jsb1", + // Mark it to clear on rebuild, but each package will evaluate itself for changes. + dynacache.OptionsPartition{ClearWhen: dynacache.ClearOnRebuild, Weight: 10}, + ), + }, nil +} + +type BatcherClient struct { + d *deps.Deps + + once sync.Once + runnerTemplate tpl.Template + + createClient *create.Client + buildClient *BuildClient + + bundlesCache *dynacache.Partition[string, *Package] +} + +// New creates a new Batcher with the given ID. +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") + + return &batcher{ + id: id, + scriptGroups: make(map[string]*scriptGroup), + dependencyManager: dependencyManager, + client: c, + configOptions: &optionsCompiled[configOptions]{opts: newOptions(optionsOptions{name: "config"})}, + }, nil +} + +func (c *BatcherClient) buildBatch(ctx context.Context, t *batchTemplateContext) (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 +} + +type BatcherGroup interface { + Instance(sid, iid string) OptionsSetter + Runner(id string) OptionsSetter + Script(id string) OptionsSetter +} + +type OptionsSetter interface { + SetOptions(map[string]any) string +} + +// TODO1 names. +type Package struct { + origin *batcher + outDir string + id string + b *batcher + Groups map[string]resource.Resources +} + +// For internal use only. +func (b *Package) GetDependencyManager() identity.Manager { + return b.origin.dependencyManager +} + +// For internal use only. +func (p *Package) IdentifierBase() string { + return p.id +} + +// For internal use only. +func (p *Package) MarkStale() { + p.origin.reset() +} + +func (p *Package) isStale() bool { + for _, v := range p.b.scriptGroups { + for _, vv := range v.instancesOptions { + curr := vv.opts.v.optsCurr + prev := vv.optsPrev + if prev != nil { + if len(curr) != len(prev) { + return true + } + if !reflect.DeepEqual(curr, prev) { + return true + } + } + } + } + + var isStale bool + p.b.forEeachStaleInfo(func(v resource.StaleInfo) bool { + if i := v.StaleVersion(); i > 0 { + isStale = true + } + return isStale + }) + + return isStale +} + +// You should not depend on the invocation order when calling this. +// TODO1 check that this does not get called on first build. +func (b *batcher) forEeachStaleInfo(f func(si resource.StaleInfo) bool) { + check := func(v any) bool { + if si, ok := v.(resource.StaleInfo); ok { + return f(si) + } + return false + } + for _, v := range b.scriptGroups { + if b := func() bool { + v.mu.Lock() + defer v.mu.Unlock() + + for _, vv := range v.instancesOptions { + if check(vv) { + return true + } + } + + for _, vv := range v.scriptsOptions { + if check(vv) { + return true + } + } + + for _, vv := range v.runnersOptions { + if check(vv) { + return true + } + } + + return false + }(); b { + return + } + } +} + +type configOptions struct { + Options ExternalOptions +} + +func (s configOptions) isStaleCompiled(prev configOptions) bool { + return false +} + +func (s configOptions) compileOptions(o *options) (configOptions, error) { + m := o.commit().optsCurr + + config, err := DecodeExternalOptions(m) + if err != nil { + return configOptions{}, err + } + + return configOptions{ + Options: config, + }, nil +} + +type paramsOptions struct { + Params json.RawMessage +} + +func (s paramsOptions) isStaleCompiled(prev paramsOptions) bool { + return false +} + +func (s paramsOptions) compileOptions(o *options) (paramsOptions, error) { + v := struct { + Params map[string]any + }{} + + m := o.commit().optsCurr + + 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 scriptOptions struct { + // The script to build. + // TODO1 handle stale. + 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 (s scriptOptions) isStaleCompiled(prev scriptOptions) bool { + // 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) IsZero() bool { + return s.Resource == nil +} + +func (s scriptOptions) compileOptions(o *options) (scriptOptions, error) { + v := struct { + Resource resource.Resource + ImportContext any + Export string + Params map[string]any + }{} + + m := o.commit().optsCurr + + 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 = o.v.opts.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 +} + +func (o *scriptOptions) Dir() string { + return path.Dir(o.Resource.(resource.PathProvider).Path()) +} + +type batchTemplateContext struct { + keyPath string + ID string + Runners []scriptRunnerTemplateContext + Scripts []scriptBatchTemplateContext +} + +type batcher struct { + mu sync.Mutex + id string + buildCount int + scriptGroups scriptGroups + + client *BatcherClient + dependencyManager identity.Manager + + configOptions *optionsCompiled[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 +} + +func (b *batcher) Build(ctx context.Context) (*Package, error) { + key := dynacache.CleanKey(b.id + ".js") + p, err := b.client.bundlesCache.GetOrCreate(key, func(string) (*Package, error) { + return b.build(ctx) + }) + if err != nil { + return nil, fmt.Errorf("failed to build Batch %q: %w", b.id, err) + } + + if p.b != b { + panic("bundler mismatch") + } + + return p, nil +} + +func (b *batcher) Config() OptionsSetter { + return b.configOptions.opts.Get() +} + +func (b *batcher) Group(id string) BatcherGroup { + b.mu.Lock() + defer b.mu.Unlock() + + group, found := b.scriptGroups[id] + if !found { + group = &scriptGroup{ + id: id, b: b, + scriptsOptions: make(optionsCompiledMap[scriptID, scriptOptions]), + instancesOptions: make(optionsCompiledMap[instanceID, paramsOptions]), + runnersOptions: make(optionsCompiledMap[scriptID, scriptOptions]), + } + b.scriptGroups[id] = group + } + + return group +} + +// TODO1 remove. +func deb2(what string, v ...any) { + // fmt.Println(what, v) +} + +func (b *batcher) build(ctx context.Context) (*Package, error) { + b.mu.Lock() + defer b.mu.Unlock() + + defer func() { + b.buildCount++ + }() + + if b.prevBuild != nil { + if b.prevBuild.isStale() { + return b.prevBuild, nil + } + b.removeStale() + } + + p, err := b.doBuild(ctx) + if err != nil { + return nil, err + } + b.prevBuild = p + return p, nil +} + +func (b *batcher) compile() error { + if err := b.configOptions.compile(); err != nil { + return err + } + + for _, v := range b.scriptGroups { + if err := v.compile(); err != nil { + return err + } + } + return nil +} + +func (b *batcher) doBuild(ctx context.Context) (*Package, error) { + keyPath := b.id + + b.forEeachStaleInfo(func(si resource.StaleInfo) bool { + identity.WalkIdentitiesShallow(si, func(level int, id identity.Identity) bool { + b.dependencyManager.AddIdentity(id) + return false + }) + + return false + }) + + type importContext struct { + name string + resourceGetter resource.ResourceGetter + scriptOptions scriptOptions + } + + 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(pth, group) + state.importResource.Set(pth, r) + if isResult { + state.resultResource.Set(pth, r) + } + entryPoints = append(entryPoints, pth) + } + + if err := b.compile(); err != nil { + return nil, err + } + + for k, v := range b.scriptGroups { + keyPath := keyPath + "_" + k + var runners []scriptRunnerTemplateContext + for _, vv := range v.runnersOptions.Sorted() { + runnerKeyPath := keyPath + "_" + vv.key.String() + runnerImpPath := paths.AddLeadingSlash(runnerKeyPath + "_runner" + vv.compiled.Resource.MediaType().FirstSuffix.FullSuffix) + runners = append(runners, scriptRunnerTemplateContext{keyOpts: vv, Import: runnerImpPath}) + addResource(k, runnerImpPath, vv.compiled.Resource, false) + } + + t := &batchTemplateContext{ + keyPath: keyPath, + ID: v.id, + Runners: runners, + } + + instances := v.instancesOptions.Sorted() + + for _, vv := range v.scriptsOptions.Sorted() { + 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, + }) + + bt := scriptBatchTemplateContext{ + keyOpts: 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{keyOpts: vvv}) + } + + t.Scripts = append(t.Scripts, bt) + } + + r, s, err := b.client.buildBatch(ctx, t) + if err != nil { + return nil, fmt.Errorf("failed to build batch: %w", err) + } + + state.importerImportContext.Set(s, importContext{ + name: s, + resourceGetter: nil, + }) + + addResource(v.id, s, r, true) + } + + absPublishDir := b.client.d.AbsPublishDir + mediaTypes := b.client.d.ResourceSpec.MediaTypes() + cssMt, _, _ := mediaTypes.GetFirstBySuffix("css") + + outDir, err := b.client.d.MkdirTemp("hugo-jsbatch") + if err != nil { + return nil, err + } + + 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, + OutDir: outDir, + Write: true, + AllowOverwrite: true, + Splitting: true, + ImportOnResolveFunc: func(depsManager identity.Manager, imp string, args api.OnResolveArgs) string { + if r, found := state.importResource.Get(imp); found { + depsManager.AddIdentity(identity.FirstIdentity(r)) + return imp + } + var importContextPath string + if args.Kind == api.ResolveEntryPoint { + importContextPath = args.Path + } else { + importContextPath = args.Importer + } + importContext, _ := state.importerImportContext.Get(importContextPath) + + if importContext.resourceGetter != nil { + resolved := importContext.resourceGetter.Get(imp) + if resolved != nil { + depsManager.AddIdentity(identity.FirstIdentity(resolved)) + imp := PrefixHugoVirtual + resolved.(resource.PathProvider).Path() + 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 { + path = strings.TrimPrefix(path, PrefixHugoVirtual) + var contentr hugio.ReadSeekCloser + if cp, ok := r.(hugio.ReadSeekCloserProvider); ok { + contentr, _ = cp.ReadSeekCloser() + } + return &ErrorMessageResolved{ + Content: contentr, + Path: path, + Message: args.Text, + } + + } + + } + return nil + }, + EntryPoints: entryPoints, + }, + } + + result, err := b.client.buildClient.Build(jsOpts) + if err != nil { + return nil, fmt.Errorf("failed to build bundle: %w", err) + } + + m := fromJSONToESBuildResultMeta(b.client.d.Conf.WorkingDir(), result.Metafile) + + groups := make(map[string]resource.Resources) + + createAndAddResource := func(filename, targetPath, group string, mt media.Type) error { + rd := resources.ResourceSourceDescriptor{ + LazyPublish: true, + OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) { + return os.Open(filename) + }, + MediaType: mt, + TargetPath: targetPath, + } + r, err := b.client.d.ResourceSpec.NewResource(rd) + if err != nil { + return err + } + + groups[group] = append(groups[group], r) + + return nil + } + + createAndAddResources := func(o esBuildResultMetaOutput) (bool, error) { + p := filepath.ToSlash(strings.TrimPrefix(o.filename, outDir)) + ext := path.Ext(p) + mt, _, found := mediaTypes.GetBySuffix(ext) + if !found { + return false, nil + } + groupPath := p + group, found := state.pathGroup.Get(groupPath) + + if !found { + return false, nil + } + + if err := createAndAddResource(o.filename, p, group, mt); err != nil { + return false, err + } + + if o.CSSBundle != "" { + p := filepath.ToSlash(strings.TrimPrefix(o.CSSBundle, outDir)) + if err := createAndAddResource(o.CSSBundle, p, group, cssMt); err != nil { + return false, err + } + } + + return true, nil + } + + for _, o := range m.Outputs { + handled, err := createAndAddResources(o) + if err != nil { + return nil, err + } + if !handled { + // Copy to destination. + p := strings.TrimPrefix(o.filename, outDir) + if err := hugio.CopyFile(hugofs.Os, o.filename, filepath.Join(absPublishDir, p)); err != nil { + return nil, fmt.Errorf("failed to copy %q to %q: %w", o.filename, absPublishDir, err) + } + } + } + + return &Package{ + origin: b, + outDir: outDir, + b: b, + id: path.Join(NsBatch, b.id), + Groups: groups, + }, nil +} + +func (b *batcher) reset() { + b.mu.Lock() + defer b.mu.Unlock() + for _, v := range b.scriptGroups { + // TODO1 check if this is complete. + v.Reset() + } +} + +func (b *batcher) removeStale() { + // We already have the lock. + for k, v := range b.scriptGroups { + if v.removeStale() { + deb("remove group", k) + delete(b.scriptGroups, k) + } + } +} + +// https://esbuild.github.io/api/#metafile +type esBuildResultMeta struct { + Outputs map[string]esBuildResultMetaOutput + + // Compiled values. + cssBundleEntryPoint map[string]esBuildResultMetaOutput +} + +func (e *esBuildResultMeta) Compile(cwd string) error { + // Rewrite the paths to be absolute. + // See https://github.com/evanw/esbuild/issues/338 + outputs := make(map[string]esBuildResultMetaOutput) + for k, v := range e.Outputs { + filename := filepath.Join(cwd, k) + if err := v.Compile(filename); err != nil { + return err + } + + if v.CSSBundle != "" { + v.CSSBundle = filepath.Join(cwd, v.CSSBundle) + } + outputs[filename] = v + } + e.Outputs = outputs + + e.cssBundleEntryPoint = make(map[string]esBuildResultMetaOutput) + for _, v := range e.Outputs { + if v.CSSBundle != "" { + e.cssBundleEntryPoint[v.CSSBundle] = v + } + } + return nil +} + +type esBuildResultMetaOutput struct { + Bytes int64 + Exports []string + Imports []esBuildResultMetaOutputImport + EntryPoint string + CSSBundle string + + // compiled values. + filename string +} + +func (e *esBuildResultMetaOutput) Compile(filename string) error { + e.filename = filename + return nil +} + +type esBuildResultMetaOutputImport struct { + Path string + Kind string +} + +type scriptID string + +func (s scriptID) String() string { + return string(s) +} + +type instanceID struct { + scriptID scriptID + instanceID string +} + +func (i instanceID) String() string { + return i.scriptID.String() + "_" + i.instanceID +} + +type optionsSetter struct { + opts optionsOptions + optsCurr map[string]any +} + +func (o *optionsSetter) isStale(optsPrev map[string]any) bool { + if optsPrev == nil { + return false + } + isStale := func() bool { + if len(o.optsCurr) != len(optsPrev) { + return true + } + if reflect.DeepEqual(o.optsCurr, optsPrev) { + return false + } + for k, v := range optsPrev { + vv, found := o.optsCurr[k] + if !found { + return true + } else { + if si, ok := vv.(resource.StaleInfo); ok { + if si.StaleVersion() > 0 { + return true + } + } else if strings.EqualFold(k, "ImportContext") { + // This is checked later. + } else { + if !reflect.DeepEqual(v, vv) { + return true + } + } + } + } + return false + }() + + return isStale +} + +func (o *optionsSetter) SetOptions(m map[string]any) string { + o.optsCurr = m + return "" +} + +type scriptBatchTemplateContext struct { + keyOpts[scriptID, scriptOptions] + Import string + Instances []scriptInstanceBatchTemplateContext +} + +func (s *scriptBatchTemplateContext) Export() string { + return s.keyOpts.compiled.Export +} + +func (c scriptBatchTemplateContext) MarshalJSON() (b []byte, err error) { + return json.Marshal(&struct { + ID string `json:"id"` + Instances []scriptInstanceBatchTemplateContext `json:"instances"` + }{ + ID: c.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.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 optionsCompiledMap[K key, T optionsCompiler[T]] map[K]*optionsCompiled[T] + +type keyOpts[K comparable, T optionsCompiler[T]] struct { + key K + *optionsCompiled[T] +} + +type key interface { + comparable + fmt.Stringer +} + +type keyOptss[K key, T optionsCompiler[T]] []keyOpts[K, T] + +func (ko keyOptss[K, T]) Filter(predicate func(K) bool) keyOptss[K, T] { + var a keyOptss[K, T] + for _, v := range ko { + if predicate(v.key) { + a = append(a, v) + } + } + return a +} + +func (o optionsCompiledMap[K, T]) Sorted() keyOptss[K, T] { + var keys []K + for k := range o { + keys = append(keys, k) + } + + sort.Slice(keys, func(i, j int) bool { + return keys[i].String() < keys[j].String() + }) + + var ko []keyOpts[K, T] + for _, k := range keys { + ko = append(ko, keyOpts[K, T]{key: k, optionsCompiled: o[k]}) + } + return ko +} + +type scriptGroup struct { + mu sync.Mutex + + id string + + b *batcher + + scriptsOptions optionsCompiledMap[scriptID, scriptOptions] + instancesOptions optionsCompiledMap[instanceID, paramsOptions] + instancesOptionsBuildID int + runnersOptions optionsCompiledMap[scriptID, scriptOptions] +} + +func (s *scriptGroup) Instance(sid, iid string) OptionsSetter { + s.mu.Lock() + defer s.mu.Unlock() + if s.instancesOptionsBuildID != s.b.buildCount { + // TODO1 + // New build, reset the instances. + // Store the old map so we can compare for equality. + /*s.instancesOptionsOld = s.instancesOptions + s.instancesOptions = make(map[instanceID]*options) + s.instances = make(map[instanceID]*ParamsOptions) + s.instancesOptionsBuildID = s.b.buildCount + */ + } + id := instanceID{scriptID: scriptID(sid), instanceID: iid} + if v, found := s.instancesOptions[id]; found { + return v.opts.Get() + } + s.instancesOptions[id] = &optionsCompiled[paramsOptions]{opts: newOptions(optionsOptions{name: "instance"})} + return s.instancesOptions[id].opts.Get() +} + +func (g *scriptGroup) Reset() { + for _, v := range g.scriptsOptions { + v.opts.Reset() + } + for _, v := range g.instancesOptions { + v.opts.Reset() + } + for _, v := range g.runnersOptions { + v.opts.Reset() + } +} + +func (g *scriptGroup) removeStale() bool { + for k, v := range g.scriptsOptions { + if v.opts.StaleVersion() > 0 { + deb("remove script", v.opts.v.opts.name, k) + delete(g.scriptsOptions, k) + for kk := range g.instancesOptions { + if kk.scriptID == k { + delete(g.scriptsOptions, kk.scriptID) + } + } + } + return len(g.scriptsOptions) == 0 + } + return false +} + +func (s *scriptGroup) Runner(id string) OptionsSetter { + s.mu.Lock() + defer s.mu.Unlock() + sid := scriptID(id) + if v, found := s.runnersOptions[sid]; found { + return v.opts.Get() + } + s.runnersOptions[sid] = &optionsCompiled[scriptOptions]{opts: newOptions(optionsOptions{name: "runner", defaultExport: "default"})} + return s.runnersOptions[sid].opts.Get() +} + +func (s *scriptGroup) Script(id string) OptionsSetter { + s.mu.Lock() + defer s.mu.Unlock() + sid := scriptID(id) + if v, found := s.scriptsOptions[sid]; found { + return v.opts.Get() + } + s.scriptsOptions[sid] = &optionsCompiled[scriptOptions]{opts: newOptions(optionsOptions{name: "script", defaultExport: "*"})} + return s.scriptsOptions[sid].opts.Get() +} + +func (s *scriptGroup) errFailedToCompile(what, id string, err error) error { + return fmt.Errorf("failed to compile %s for %q > %q: %w", what, s.id, id, err) +} + +func (s *scriptGroup) compile() error { + for k, v := range s.scriptsOptions { + if err := v.compile(); err != nil { + return s.errFailedToCompile("Script", k.String(), err) + } + } + + for k, v := range s.instancesOptions { + if err := v.compile(); err != nil { + return s.errFailedToCompile("Instance", k.instanceID, err) // TODO1 + } + } + + for k, v := range s.runnersOptions { + if err := v.compile(); err != nil { + return s.errFailedToCompile("Runner", k.String(), err) // TODO1 + } + } + + return nil +} + +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 scriptInstanceBatchTemplateContext struct { + keyOpts[instanceID, paramsOptions] +} + +func (c scriptInstanceBatchTemplateContext) ID() string { + return c.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.key.instanceID, + Params: c.compiled.Params, + }) +} + +type scriptRunnerTemplateContext struct { + keyOpts[scriptID, scriptOptions] + Import string +} + +func (s *scriptRunnerTemplateContext) Export() string { + return s.keyOpts.compiled.Export +} + +func (c scriptRunnerTemplateContext) MarshalJSON() (b []byte, err error) { + return json.Marshal(&struct { + ID string `json:"id"` + }{ + ID: c.key.String(), + }) +} + +func fromJSONToESBuildResultMeta(cwd, jsons string) esBuildResultMeta { + var m esBuildResultMeta + if err := json.Unmarshal([]byte(jsons), &m); err != nil { + panic(err) + } + if err := m.Compile(cwd); err != nil { + panic(err) + } + return m +} + +type options struct { + staleVersion atomic.Uint32 + getOnce[*optionsSetter] +} + +func (o *options) Reset() { + mu := o.once.ResetWithLock() + deb(o.v.opts.name, "reset", o.v.optsCurr) + o.staleVersion.Store(0) + mu.Unlock() +} + +func (o *options) StaleVersion() uint32 { + return o.staleVersion.Load() +} diff --git a/internal/js/esbuild/batch_integration_test.go b/internal/js/esbuild/batch_integration_test.go new file mode 100644 index 000000000..fe35e1ae9 --- /dev/null +++ b/internal/js/esbuild/batch_integration_test.go @@ -0,0 +1,428 @@ +// 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 ( + "fmt" + "strings" + "testing" + + qt "github.com/frankban/quicktest" + + "github.com/bep/logg" + "github.com/gohugoio/hugo/hugolib" +) + +// Used to test misc. error situations etc. +const jsBatchFilesTemplate = ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "section", "page"] +-- assets/js/styles.css -- +body { + background-color: red; +} +-- assets/js/main.js -- +import './styles.css'; +console.log("Hello, Main!"); +-- assets/js/runner.js -- +console.log("Hello, Runner!"); +-- layouts/index.html -- +{{ $batch := (js.Batch "mybatch" site.Home.Store) }} +{{ 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")) }} + {{ end }} +{{ end }} + +{{ with (templates.Defer (dict "key" "global")) }} + Defer: + {{ $batch := (js.Batch "mybatch" site.Home.Store) }} + {{ range $k, $v := $batch.Build.Groups }} + {{ $k }}: + {{ range . }} + RelPermalink: {{ .RelPermalink }} + Content: {{ .Content | safeHTML }} + {{ end }} + {{ end }} +{{ end }} +` + +// 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 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, `failed to build Batch "mybatch": failed to compile Script for "mygroup" > "main": resource not set`) +} + +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, `failed to build Batch "mybatch": failed to compile Runner for "mygroup" > "run": resource not set`) +} + +func TestBatchErrorScriptResourceSyntaxError(t *testing.T) { + files := strings.Replace(jsBatchFilesTemplate, `console.log("Hello, Main!");`, `console.flog("Hello, Main!"`, 1) + b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs()) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `failed to build Batch "mybatch": failed to build bundle: "/js/mybatch_mygroup_main.js:2:27": 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(modules) { + for (const module of modules) { + 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'; + +console.log('main3.params.id', params.id) + +export default function Main3() {}; + +-- layouts/_default/single.html -- +Single. +{{ $r := .Resources.GetMatch "*.jsx" }} +{{ $batch := (js.Batch "mybundle" site.Home.Store) }} +{{ $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" site.Home.Store) }} +{{ range $k, $v := $batch.Build.Groups }} + {{ $k }}: + {{ range . }} + {{ .RelPermalink }} + {{ end }} + {{ end }} +{{ end }} +{{ $myContentBundle := site.GetPage "mybundle" }} +{{ $batch := (js.Batch "mybundle" site.Home.Store) }} +{{ $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() + + fmt.Println(b.LogString()) + + 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", 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 {") +} + +// TODO1 move this. +func TestResourcesGet(t *testing.T) { + files := ` +-- hugo.toml -- +-- assets/text/txt1.txt -- +Text 1. +-- assets/text/txt2.txt -- +Text 2. +-- assets/text/sub/txt3.txt -- +Text 3. +-- assets/text/sub/txt4.txt -- +Text 4. +-- content/mybundle/index.md -- +--- +title: "My Bundle" +--- +-- content/mybundle/txt1.txt -- +Text 1. +-- content/mybundle/sub/txt2.txt -- +Text 1. +-- layouts/index.html -- +{{ $mybundle := site.GetPage "mybundle" }} +{{ $subResources := resources.Match "/text/sub/*.*" }} +{{ $subResourcesMount := $subResources.Mount "/text/sub" "/newroot" }} +resources:text/txt1.txt:{{ with resources.Get "text/txt1.txt" }}{{ .Name }}{{ end }}| +resources:text/txt2.txt:{{ with resources.Get "text/txt2.txt" }}{{ .Name }}{{ end }}| +resources:text/sub/txt3.txt:{{ with resources.Get "text/sub/txt3.txt" }}{{ .Name }}{{ end }}| +subResources.range:{{ range $subResources }}{{ .Name }}|{{ end }}| +subResources:"text/sub/txt3.txt:{{ with $subResources.Get "text/sub/txt3.txt" }}{{ .Name }}{{ end }}| +subResourcesMount:/newroot/txt3.txt:{{ with $subResourcesMount.Get "/newroot/txt3.txt" }}{{ .Name }}{{ end }}| +page:txt1.txt:{{ with $mybundle.Resources.Get "txt1.txt" }}{{ .Name }}{{ end }}| +page:./txt1.txt:{{ with $mybundle.Resources.Get "./txt1.txt" }}{{ .Name }}{{ end }}| +page:sub/txt2.txt:{{ with $mybundle.Resources.Get "sub/txt2.txt" }}{{ .Name }}{{ end }}| +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", ` +resources:text/txt1.txt:/text/txt1.txt| +resources:text/txt2.txt:/text/txt2.txt| +resources:text/sub/txt3.txt:/text/sub/txt3.txt| +subResources:"text/sub/txt3.txt:/text/sub/txt3.txt| +subResourcesMount:/newroot/txt3.txt:/text/sub/txt3.txt| +page:txt1.txt:txt1.txt| +page:./txt1.txt:txt1.txt| +page:sub/txt2.txt:sub/txt2.txt| +`) +} + +// TODO1 check .Name in bundles on renames. diff --git a/internal/js/esbuild/build.go b/internal/js/esbuild/build.go new file mode 100644 index 000000000..c3c6e3b98 --- /dev/null +++ b/internal/js/esbuild/build.go @@ -0,0 +1,181 @@ +// 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, + } +} + +type BuildClient struct { + rs *resources.Spec + sfs *filesystems.SourceFilesystem +} + +func (c *BuildClient) Build(opts Options) (api.BuildResult, error) { + dependencyManager := opts.DependencyManager + if dependencyManager == nil { + dependencyManager = identity.NopManager + } + + opts.ResolveDir = c.rs.Cfg.BaseConfig().WorkingDir // where node_modules gets resolved + opts.TsConfig = c.rs.ResolveJSConfigFile("tsconfig.json") + + if err := opts.validate(); err != nil { + return api.BuildResult{}, err + } + + buildOptions, err := toBuildOptions(opts) + if err != nil { + return api.BuildResult{}, err + } + + buildOptions.Plugins, err = createBuildPlugins(c.rs, dependencyManager, opts) + if err != nil { + return api.BuildResult{}, err + } + + if buildOptions.Sourcemap == api.SourceMapExternal && buildOptions.Outdir == "" { + buildOptions.Outdir, err = os.MkdirTemp(os.TempDir(), "compileOutput") + if err != nil { + return api.BuildResult{}, err + } + defer os.Remove(buildOptions.Outdir) + } + + 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 := resolveComponentInAssets(c.rs.Assets.Fs, impPath) + + if m == nil { + return api.BuildResult{}, fmt.Errorf("inject: file %q not found", ext) + } + + opts.Inject[i] = m.Filename + + } + + buildOptions.Inject = opts.Inject + + } + + result := api.Build(buildOptions) + + 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 = "TODO1" // transformCtx.SourcePath + } + + errorMessage = msg.Text + // TODO1 handle all namespaces, make more general + errorMessage = strings.ReplaceAll(errorMessage, NsHugoImport+":", "") + + if strings.HasPrefix(errorPath, NsHugoImport) { + errorPath = strings.TrimPrefix(errorPath, NsHugoImport+":") + 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] + } + + return result, nil +} diff --git a/internal/js/esbuild/helpers.go b/internal/js/esbuild/helpers.go new file mode 100644 index 000000000..616946b8c --- /dev/null +++ b/internal/js/esbuild/helpers.go @@ -0,0 +1,45 @@ +// 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 "github.com/gohugoio/hugo/lazy" + +// getOnce is a helper to get a value once. +// Any invocation after the first one will return T's zero value. +// This is thread safe. +// Note that once can be reset. +type getOnce[T any] struct { + v T + once lazy.OnceMore +} + +func (g *getOnce[T]) Get() T { + var v T + g.once.Do(func() { + v = g.v + }) + return v +} + +func (g *getOnce[T]) commit() T { + g.once.Do(func() { + }) + return g.v +} + +// TODO1 remove. +func deb(what string, v ...any) { + // fmt.Println(what, v) +} diff --git a/internal/js/esbuild/options.go b/internal/js/esbuild/options.go new file mode 100644 index 000000000..82d8528ef --- /dev/null +++ b/internal/js/esbuild/options.go @@ -0,0 +1,301 @@ +// 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" + "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" +) + +type Options struct { + ExternalOptions + InternalOptions +} + +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") + } + return nil +} + +// InternalOptions holds internal options for the js.Build template function. +type InternalOptions struct { + MediaType media.Type + OutDir string + Contents string + SourceDir string + ResolveDir string + + DependencyManager identity.Manager + + Write bool // Set to false to write to memory. + AllowOverwrite bool + Splitting bool + TsConfig string + EntryPoints []string + ImportOnResolveFunc func(identity.Manager, string, api.OnResolveArgs) string + ImportOnLoadFunc func(api.OnLoadArgs) string + ImportParamsOnLoadFunc func(args api.OnLoadArgs) json.RawMessage + ErrorMessageResolveFunc func(api.Message) *ErrorMessageResolved + Stdin bool +} + +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 + + // Whether to write mapfiles + SourceMap string + + // 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 + + // 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 +} + +func DecodeExternalOptions(m map[string]any) (ExternalOptions, error) { + var opts ExternalOptions + + 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 +} + +func toBuildOptions(opts Options) (buildOptions api.BuildOptions, err error) { + var target api.Target + switch opts.Target { + case "", "esnext": + target = api.ESNext + case "es5": + target = api.ES5 + case "es6", "es2015": + target = api.ES2015 + case "es2016": + target = api.ES2016 + case "es2017": + target = api.ES2017 + case "es2018": + target = api.ES2018 + case "es2019": + target = api.ES2019 + case "es2020": + target = api.ES2020 + case "es2021": + target = api.ES2021 + case "es2022": + target = api.ES2022 + case "es2023": + target = api.ES2023 + default: + err = fmt.Errorf("invalid target: %q", opts.Target) + return + } + + mediaType := opts.MediaType + if mediaType.IsZero() { + mediaType = media.Builtin.JavascriptType + } + + var loader api.Loader + switch mediaType.SubType { + // TODO(bep) ESBuild support a set of other loaders, but I currently fail + // to see the relevance. That may change as we start using this. + 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 "": + sourceMap = api.SourceMapNone + default: + err = fmt.Errorf("unsupported sourcemap type: %q", opts.SourceMap) + return + } + + buildOptions = api.BuildOptions{ + Outfile: outFile, + Bundle: true, + Metafile: true, // TODO1 batch only. + + Target: target, + Format: format, + Sourcemap: sourceMap, + + MinifyWhitespace: opts.Minify, + MinifyIdentifiers: opts.Minify, + MinifySyntax: opts.Minify, + + Outdir: outDir, + Write: opts.Write, + AllowOverwrite: opts.AllowOverwrite, + 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. + buildOptions.Stdin = &api.StdinOptions{ + Contents: opts.Contents, + ResolveDir: opts.ResolveDir, + Loader: loader, + } + } + return +} diff --git a/internal/js/esbuild/options_test.go b/internal/js/esbuild/options_test.go new file mode 100644 index 000000000..af0243152 --- /dev/null +++ b/internal/js/esbuild/options_test.go @@ -0,0 +1,220 @@ +// 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, err := toBuildOptions( + Options{ + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + Stdin: true, + }, + }, + ) + + c.Assert(err, qt.IsNil) + c.Assert(opts, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Metafile: true, + Target: api.ESNext, + Format: api.FormatIIFE, + Stdin: &api.StdinOptions{ + Loader: api.LoaderJS, + }, + }) + + opts, err = toBuildOptions( + Options{ + ExternalOptions: ExternalOptions{ + Target: "es2018", + Format: "cjs", + Minify: true, + AvoidTDZ: true, + }, + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + Stdin: true, + }, + }, + ) + c.Assert(err, qt.IsNil) + c.Assert(opts, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Metafile: true, + Target: api.ES2018, + Format: api.FormatCommonJS, + MinifyIdentifiers: true, + MinifySyntax: true, + MinifyWhitespace: true, + Stdin: &api.StdinOptions{ + Loader: api.LoaderJS, + }, + }) + + opts, err = toBuildOptions( + Options{ + ExternalOptions: ExternalOptions{ + Target: "es2018", Format: "cjs", Minify: true, + SourceMap: "inline", + }, + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + Stdin: true, + }, + }, + ) + c.Assert(err, qt.IsNil) + c.Assert(opts, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Metafile: true, + Target: api.ES2018, + Format: api.FormatCommonJS, + MinifyIdentifiers: true, + MinifySyntax: true, + MinifyWhitespace: true, + Sourcemap: api.SourceMapInline, + Stdin: &api.StdinOptions{ + Loader: api.LoaderJS, + }, + }) + + opts, err = toBuildOptions( + Options{ + ExternalOptions: ExternalOptions{ + Target: "es2018", Format: "cjs", Minify: true, + SourceMap: "inline", + }, + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + Stdin: true, + }, + }, + ) + c.Assert(err, qt.IsNil) + c.Assert(opts, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Metafile: true, + Target: api.ES2018, + Format: api.FormatCommonJS, + MinifyIdentifiers: true, + MinifySyntax: true, + MinifyWhitespace: true, + Sourcemap: api.SourceMapInline, + Stdin: &api.StdinOptions{ + Loader: api.LoaderJS, + }, + }) + + opts, err = toBuildOptions( + Options{ + ExternalOptions: ExternalOptions{ + Target: "es2018", Format: "cjs", Minify: true, + SourceMap: "external", + }, + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + Stdin: true, + }, + }, + ) + + c.Assert(err, qt.IsNil) + c.Assert(opts, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Metafile: true, + Target: api.ES2018, + Format: api.FormatCommonJS, + MinifyIdentifiers: true, + MinifySyntax: true, + MinifyWhitespace: true, + Sourcemap: api.SourceMapExternal, + Stdin: &api.StdinOptions{ + Loader: api.LoaderJS, + }, + }) + + opts, err = toBuildOptions( + Options{ + ExternalOptions: ExternalOptions{ + JSX: "automatic", JSXImportSource: "preact", + }, + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + Stdin: true, + }, + }, + ) + + c.Assert(err, qt.IsNil) + c.Assert(opts, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Metafile: true, + Target: api.ESNext, + Format: api.FormatIIFE, + 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, err := toBuildOptions( + Options{ + ExternalOptions: ExternalOptions{ + Target: test.target, + }, + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + }, + }, + ) + + c.Assert(err, qt.IsNil) + c.Assert(opts.Target, qt.Equals, test.expect) + }) + } +} diff --git a/internal/js/esbuild/resolve.go b/internal/js/esbuild/resolve.go new file mode 100644 index 000000000..32fdb095e --- /dev/null +++ b/internal/js/esbuild/resolve.go @@ -0,0 +1,275 @@ +// 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/hugofs" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/resources" + "github.com/spf13/afero" +) + +const ( + NsHugoImport = "ns-hugo-import" + NsHugoImportResolveFunc = "ns-hugo-import-resolvefunc" + nsHugoParams = "ns-hugo-params" + + stdinImporter = "" +) + +const ( + PrefixHugoVirtual = "@hugo-virtual" +) + +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, +} + +func loaderFromFilename(filename string) api.Loader { + l, found := extensionToLoaderMap[filepath.Ext(filename)] + if found { + return l + } + return api.LoaderJS +} + +func resolveComponentInAssets(fs afero.Fs, impPath string) *hugofs.FileMeta { + findFirst := func(base string) *hugofs.FileMeta { + // This is the most common sub-set of ESBuild's default extensions. + // We assume that imports of JSON, CSS etc. will be using their full + // name with extension. + for _, ext := range []string{".js", ".ts", ".tsx", ".jsx"} { + if strings.HasSuffix(impPath, ext) { + // Import of foo.js.js need the full name. + continue + } + if fi, err := fs.Stat(base + ext); err == nil { + return fi.(hugofs.FileMetaInfo).Meta() + } + } + + // Not found. + return nil + } + + var m *hugofs.FileMeta + + // 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. + m = findFirst(impPath) + if m != nil { + return m + } + + base := filepath.Base(impPath) + if base == "index" { + // try index.esm.js etc. + m = findFirst(impPath + ".esm") + if m != nil { + return m + } + } + + // Check the path as is. + fi, err := fs.Stat(impPath) + + if err == nil { + if fi.IsDir() { + m = findFirst(filepath.Join(impPath, "index")) + if m == nil { + m = findFirst(filepath.Join(impPath, "index.esm")) + } + } else { + m = fi.(hugofs.FileMetaInfo).Meta() + } + } else if strings.HasSuffix(base, ".js") { + m = findFirst(strings.TrimSuffix(impPath, ".js")) + } + + return m +} + +func createBuildPlugins(rs *resources.Spec, 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(depsManager, 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 := resolveComponentInAssets(fs.Fs, 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: 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: 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$`}, + func(args api.OnResolveArgs) (api.OnResolveResult, error) { + return api.OnResolveResult{ + Path: args.Importer, + Namespace: nsHugoParams, + }, nil + }) + build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsHugoParams}, + func(args api.OnLoadArgs) (api.OnLoadResult, error) { + bb := b + + if opts.ImportParamsOnLoadFunc != nil { + if bbb := opts.ImportParamsOnLoadFunc(args); bbb != nil { + bb = bbb + } + } + + s := string(bb) + + 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..6ea3d304b --- /dev/null +++ b/internal/js/esbuild/resolve_test.go @@ -0,0 +1,85 @@ +// 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) + + got := resolveComponentInAssets(bfs.Assets.Fs, 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/media/mediaType.go b/media/mediaType.go index a7ba1309a..97b10879c 100644 --- a/media/mediaType.go +++ b/media/mediaType.go @@ -273,9 +273,13 @@ func (t Types) GetByType(tp string) (Type, bool) { return Type{}, false } +func (t Types) normalizeSuffix(s string) string { + return strings.ToLower(strings.TrimPrefix(s, ".")) +} + // BySuffix will return all media types matching a suffix. func (t Types) BySuffix(suffix string) []Type { - suffix = strings.ToLower(suffix) + suffix = t.normalizeSuffix(suffix) var types []Type for _, tt := range t { if tt.hasSuffix(suffix) { @@ -287,7 +291,7 @@ func (t Types) BySuffix(suffix string) []Type { // GetFirstBySuffix will return the first type matching the given suffix. func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) { - suffix = strings.ToLower(suffix) + suffix = t.normalizeSuffix(suffix) for _, tt := range t { if tt.hasSuffix(suffix) { return tt, SuffixInfo{ @@ -304,7 +308,7 @@ func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) { // is ambiguous. // The lookup is case insensitive. func (t Types) GetBySuffix(suffix string) (tp Type, si SuffixInfo, found bool) { - suffix = strings.ToLower(suffix) + suffix = t.normalizeSuffix(suffix) for _, tt := range t { if tt.hasSuffix(suffix) { if found { @@ -324,7 +328,7 @@ func (t Types) GetBySuffix(suffix string) (tp Type, si SuffixInfo, found bool) { } func (t Types) IsTextSuffix(suffix string) bool { - suffix = strings.ToLower(suffix) + suffix = t.normalizeSuffix(suffix) for _, tt := range t { if tt.hasSuffix(suffix) { return tt.IsText() diff --git a/output/outputFormat.go b/output/outputFormat.go index d249c72b9..32fcef33c 100644 --- a/output/outputFormat.go +++ b/output/outputFormat.go @@ -120,6 +120,14 @@ var ( Rel: "alternate", } + GoTmplFormat = Format{ + Name: "gotmpl", + MediaType: media.Builtin.TextType, + BaseName: "index", + IsPlainText: true, + NotAlternative: true, + } + HTMLFormat = Format{ Name: "html", MediaType: media.Builtin.HTMLType, @@ -215,6 +223,7 @@ var DefaultFormats = Formats{ RobotsTxtFormat, RSSFormat, SitemapFormat, + GoTmplFormat, } func init() { diff --git a/resources/page/page.go b/resources/page/page.go index 20525669c..3ca468e03 100644 --- a/resources/page/page.go +++ b/resources/page/page.go @@ -71,8 +71,7 @@ type ChildCareProvider interface { // section. RegularPagesRecursive() Pages - // Resources returns a list of all resources. - Resources() resource.Resources + resource.ResourcesProvider } type MarkupProvider interface { diff --git a/resources/resource.go b/resources/resource.go index cc7008e5a..b32cb0bae 100644 --- a/resources/resource.go +++ b/resources/resource.go @@ -47,6 +47,7 @@ var ( _ resource.Cloner = (*genericResource)(nil) _ resource.ResourcesLanguageMerger = (*resource.Resources)(nil) _ resource.Identifier = (*genericResource)(nil) + _ resource.PathProvider = (*genericResource)(nil) _ identity.IdentityGroupProvider = (*genericResource)(nil) _ identity.DependencyManagerProvider = (*genericResource)(nil) _ identity.Identity = (*genericResource)(nil) @@ -463,6 +464,11 @@ func (l *genericResource) Key() string { return key } +// TODO1 test and document this. Consider adding it to the Resource interface. +func (l *genericResource) Path() string { + return l.paths.TargetPath() +} + func (l *genericResource) MediaType() media.Type { return l.sd.MediaType } diff --git a/resources/resource/resources.go b/resources/resource/resources.go index 32bcdbb08..8b86619b7 100644 --- a/resources/resource/resources.go +++ b/resources/resource/resources.go @@ -16,8 +16,10 @@ package resource import ( "fmt" + "path" "strings" + "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/hugofs/glob" "github.com/spf13/cast" @@ -29,6 +31,53 @@ var _ ResourceFinder = (*Resources)(nil) // I.e. both pages and images etc. type Resources []Resource +// TODO1 +// TODO1 move to a func + template func. Maybe. +func (r Resources) Mount(base, target string) ResourceGetter { + return resourceGetterFunc(func(namev any) Resource { + name1, err := cast.ToStringE(namev) + if err != nil { + panic(err) + } + + isTargetAbs := strings.HasPrefix(target, "/") + + if target != "" { + name1 = strings.TrimPrefix(name1, target) + if !isTargetAbs { + name1 = paths.TrimLeading(name1) + } + } + + if base != "" && isTargetAbs { + name1 = path.Join(base, name1) + } + + for _, res := range r { + name2 := res.Name() + + if base != "" && !isTargetAbs { + name2 = paths.TrimLeading(strings.TrimPrefix(name2, base)) + } + + // TODO1 remove. + // fmt.Println("name1", name1, "name2", name2, "base", base) + + if strings.EqualFold(name1, name2) { + return res + } + + } + + return nil + }) +} + +type ResourcesProvider interface { + // Resources returns a list of all resources. + Resources() Resources +} + // var _ resource.ResourceFinder = (*Namespace)(nil) // ResourcesConverter converts a given slice of Resource objects to Resources. type ResourcesConverter interface { @@ -63,13 +112,25 @@ func (r Resources) Get(name any) Resource { panic(err) } - namestr = paths.AddLeadingSlash(namestr) + isDotCurrent := strings.HasPrefix(namestr, "./") + if isDotCurrent { + namestr = strings.TrimPrefix(namestr, "./") + } else { + namestr = paths.AddLeadingSlash(namestr) + } + + check := func(name string) bool { + if !isDotCurrent { + name = paths.AddLeadingSlash(name) + } + return strings.EqualFold(namestr, name) + } // First check the Name. // Note that this can be modified by the user in the front matter, // also, it does not contain any language code. for _, resource := range r { - if strings.EqualFold(namestr, paths.AddLeadingSlash(resource.Name())) { + if check(resource.Name()) { return resource } } @@ -77,7 +138,7 @@ func (r Resources) Get(name any) Resource { // Finally, check the normalized name. for _, resource := range r { if nop, ok := resource.(NameNormalizedProvider); ok { - if strings.EqualFold(namestr, paths.AddLeadingSlash(nop.NameNormalized())) { + if check(nop.NameNormalized()) { return resource } } @@ -197,14 +258,35 @@ type Source interface { Publish() error } -// ResourceFinder provides methods to find Resources. -// Note that GetRemote (as found in resources.GetRemote) is -// not covered by this interface, as this is only available as a global template function. -type ResourceFinder interface { +type ResourceGetter interface { // Get locates the Resource with the given name in the current context (e.g. in .Page.Resources). // // It returns nil if no Resource could found, panics if name is invalid. Get(name any) Resource +} + +type IsProbablySameResourceGetter interface { + IsProbablySameResourceGetter(other ResourceGetter) bool +} + +// StaleInfoResourceGetter is a ResourceGetter that also provides information about +// whether the underlying resources are stale. +type StaleInfoResourceGetter interface { + StaleInfo + ResourceGetter +} + +type resourceGetterFunc func(name any) Resource + +func (f resourceGetterFunc) Get(name any) Resource { + return f(name) +} + +// ResourceFinder provides methods to find Resources. +// Note that GetRemote (as found in resources.GetRemote) is +// not covered by this interface, as this is only available as a global template function. +type ResourceFinder interface { + ResourceGetter // GetMatch finds the first Resource matching the given pattern, or nil if none found. // @@ -235,3 +317,92 @@ type ResourceFinder interface { // It returns nil if no Resources could found, panics if typ is invalid. ByType(typ any) Resources } + +// NewCachedResourceGetter creates a new ResourceGetter from the given objects. +// If multiple objects are provided, they are merged into one where +// the first match wins. +func NewCachedResourceGetter(os ...any) *cachedResourceGetter { + var getters multiResourceGetter + for _, o := range os { + if g, ok := unwrapResourceGetter(o); ok { + getters = append(getters, g) + } + } + + return &cachedResourceGetter{ + cache: maps.NewCache[string, Resource](), + delegate: getters, + } +} + +type multiResourceGetter []ResourceGetter + +func (m multiResourceGetter) Get(name any) Resource { + for _, g := range m { + if res := g.Get(name); res != nil { + return res + } + } + return nil +} + +var ( + _ ResourceGetter = (*cachedResourceGetter)(nil) + _ IsProbablySameResourceGetter = (*cachedResourceGetter)(nil) +) + +type cachedResourceGetter struct { + cache *maps.Cache[string, Resource] + delegate ResourceGetter +} + +func (c *cachedResourceGetter) Get(name any) Resource { + namestr, err := cast.ToStringE(name) + if err != nil { + panic(err) + } + v, _ := c.cache.GetOrCreate(namestr, func() (Resource, error) { + v := c.delegate.Get(name) + return v, nil + }) + return v +} + +func (c *cachedResourceGetter) IsProbablySameResourceGetter(other ResourceGetter) bool { + isProbablyEq := true + var count int + c.cache.ForEeach(func(k string, v Resource) bool { + count++ + if v != other.Get(k) { + isProbablyEq = false + return false + } + return true + }) + + return count > 0 && isProbablyEq +} + +func unwrapResourceGetter(v any) (ResourceGetter, bool) { + if v == nil { + return nil, false + } + switch vv := v.(type) { + case ResourceGetter: + return vv, true + case ResourcesProvider: + return vv.Resources(), true + case func(name any) Resource: + return resourceGetterFunc(vv), true + case []any: + var getters multiResourceGetter + for _, vv := range vv { + if g, ok := unwrapResourceGetter(vv); ok { + getters = append(getters, g) + } + } + return getters, len(getters) > 0 + } + + return nil, false +} diff --git a/resources/resource/resources_test.go b/resources/resource/resources_test.go new file mode 100644 index 000000000..645bd0e4b --- /dev/null +++ b/resources/resource/resources_test.go @@ -0,0 +1,125 @@ +// 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 resource + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestResourcesMount(t *testing.T) { + c := qt.New(t) + c.Assert(true, qt.IsTrue) + + var m ResourceGetter + var r Resources + + check := func(in, expect string) { + c.Helper() + r := m.Get(in) + c.Assert(r, qt.Not(qt.IsNil)) + c.Assert(r.Name(), qt.Equals, expect) + } + + checkNil := func(in string) { + c.Helper() + r := m.Get(in) + c.Assert(r, qt.IsNil) + } + + // Misc tests. + r = Resources{ + testResource{name: "/foo/theme.css"}, + } + + m = r.Mount("/foo", ".") + check("./theme.css", "/foo/theme.css") + + // Relative target. + r = Resources{ + testResource{name: "/a/b/c/d.txt"}, + testResource{name: "/a/b/c/e/f.txt"}, + testResource{name: "/a/b/d.txt"}, + testResource{name: "/a/b/e.txt"}, + } + + // namvev ./theme.css base /js/hugoheadlessui/components target . name /js/hugoheadlessui/components/theme.css name1 theme.css name2 . + + m = r.Mount("/a/b/c", "z") + check("z/d.txt", "/a/b/c/d.txt") + // check("./z/d.txt", "/a/b/c/d.txt") + check("z/e/f.txt", "/a/b/c/e/f.txt") + + m = r.Mount("/a/b", "") + check("d.txt", "/a/b/d.txt") + m = r.Mount("/a/b", ".") + check("d.txt", "/a/b/d.txt") + m = r.Mount("/a/b", "./") + check("d.txt", "/a/b/d.txt") + check("./d.txt", "/a/b/d.txt") + + m = r.Mount("/a/b", ".") + check("./d.txt", "/a/b/d.txt") + + // Absolute target. + m = r.Mount("/a/b/c", "/z") + check("/z/d.txt", "/a/b/c/d.txt") + check("/z/e/f.txt", "/a/b/c/e/f.txt") + checkNil("/z/f.txt") + + m = r.Mount("/a/b", "/z") + check("/z/c/d.txt", "/a/b/c/d.txt") + check("/z/c/e/f.txt", "/a/b/c/e/f.txt") + check("/z/d.txt", "/a/b/d.txt") + checkNil("/z/f.txt") + + m = r.Mount("", "") + check("/a/b/c/d.txt", "/a/b/c/d.txt") + check("/a/b/c/e/f.txt", "/a/b/c/e/f.txt") + check("/a/b/d.txt", "/a/b/d.txt") + checkNil("/a/b/f.txt") + + m = r.Mount("/a/b", "/a/b") + check("/a/b/c/d.txt", "/a/b/c/d.txt") + check("/a/b/c/e/f.txt", "/a/b/c/e/f.txt") + check("/a/b/d.txt", "/a/b/d.txt") + checkNil("/a/b/f.txt") + + // Resources with relative paths. + r = Resources{ + testResource{name: "a/b/c/d.txt"}, + testResource{name: "a/b/c/e/f.txt"}, + testResource{name: "a/b/d.txt"}, + testResource{name: "a/b/e.txt"}, + testResource{name: "n.txt"}, + } + + m = r.Mount("a/b", "z") + check("z/d.txt", "a/b/d.txt") + checkNil("/z/d.txt") +} + +type testResource struct { + Resource + name string +} + +func (r testResource) Name() string { + return r.name +} + +func (r testResource) NameNormalized() string { + return r.name +} diff --git a/resources/resource/resourcetypes.go b/resources/resource/resourcetypes.go index 0fb87f371..7684960b4 100644 --- a/resources/resource/resourcetypes.go +++ b/resources/resource/resourcetypes.go @@ -108,6 +108,14 @@ type MediaTypeProvider interface { MediaType() media.Type } +// TODO1 consider removing.s +type PathProvider interface { + // Path is the relative path to this resource. + // In most cases this will be the same as the RelPermalink(), + // but it will not trigger any lazy publishing. + Path() string +} + type ResourceLinksProvider interface { // Permalink represents the absolute link to this resource. Permalink() string @@ -244,6 +252,13 @@ type StaleInfo interface { StaleVersion() uint32 } +// StaleInfoFunc is a function that returns the StaleVersion for one or more resources. +type StaleInfoFunc func() uint32 + +func (f StaleInfoFunc) StaleVersion() uint32 { + return f() +} + // StaleVersion returns the StaleVersion for the given os, // or 0 if not set. func StaleVersion(os any) uint32 { diff --git a/resources/resource_transformers/js/build.go b/resources/resource_transformers/js/build.go index cc68d2253..882ba08bd 100644 --- a/resources/resource_transformers/js/build.go +++ b/resources/resource_transformers/js/build.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Hugo Authors. All rights reserved. +// 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. @@ -14,209 +14,64 @@ package js import ( - "errors" - "fmt" - "io" - "os" "path" - "path/filepath" "regexp" - "strings" - - "github.com/spf13/afero" - - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/media" - - "github.com/gohugoio/hugo/common/herrors" - "github.com/gohugoio/hugo/common/text" - - "github.com/gohugoio/hugo/hugolib/filesystems" - "github.com/gohugoio/hugo/resources/internal" "github.com/evanw/esbuild/pkg/api" + "github.com/gohugoio/hugo/hugolib/filesystems" + "github.com/gohugoio/hugo/internal/js/esbuild" + "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" ) // Client context for ESBuild. type Client struct { - rs *resources.Spec - sfs *filesystems.SourceFilesystem + c *esbuild.BuildClient } // New creates a new client context. func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client { return &Client{ - rs: rs, - sfs: fs, + c: esbuild.NewBuildClient(fs, rs), } } -type buildTransformation struct { - optsm map[string]any - c *Client -} - -func (t *buildTransformation) Key() internal.ResourceTransformationKey { - return internal.NewResourceTransformationKey("jsbuild", t.optsm) -} - -func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { - ctx.OutMediaType = media.Builtin.JavascriptType - - opts, err := decodeOptions(t.optsm) - if err != nil { - return err - } - - if opts.TargetPath != "" { - ctx.OutPath = opts.TargetPath - } else { - ctx.ReplaceOutPathExtension(".js") - } - - src, err := io.ReadAll(ctx.From) - if err != nil { - return err - } - - opts.sourceDir = filepath.FromSlash(path.Dir(ctx.SourcePath)) - opts.resolveDir = t.c.rs.Cfg.BaseConfig().WorkingDir // where node_modules gets resolved - opts.contents = string(src) - opts.mediaType = ctx.InMediaType - opts.tsConfig = t.c.rs.ResolveJSConfigFile("tsconfig.json") - - buildOptions, err := toBuildOptions(opts) - if err != nil { - return err - } - - buildOptions.Plugins, err = createBuildPlugins(ctx.DependencyManager, t.c, opts) - if err != nil { - return err - } - - if buildOptions.Sourcemap == api.SourceMapExternal && buildOptions.Outdir == "" { - buildOptions.Outdir, err = os.MkdirTemp(os.TempDir(), "compileOutput") - if err != nil { - return err - } - defer os.Remove(buildOptions.Outdir) - } - - if opts.Inject != nil { - // Resolve the absolute filenames. - for i, ext := range opts.Inject { - impPath := filepath.FromSlash(ext) - if filepath.IsAbs(impPath) { - return fmt.Errorf("inject: absolute paths not supported, must be relative to /assets") - } - - m := resolveComponentInAssets(t.c.rs.Assets.Fs, impPath) - - if m == nil { - return fmt.Errorf("inject: file %q not found", ext) - } - - opts.Inject[i] = m.Filename - - } - - buildOptions.Inject = opts.Inject - - } - - result := api.Build(buildOptions) - - if len(result.Errors) > 0 { - - createErr := func(msg api.Message) error { - loc := msg.Location - if loc == nil { - return errors.New(msg.Text) - } - path := loc.File - if path == stdinImporter { - path = ctx.SourcePath - } - - errorMessage := msg.Text - errorMessage = strings.ReplaceAll(errorMessage, nsImportHugo+":", "") - - var ( - f afero.File - err error - ) - - if strings.HasPrefix(path, nsImportHugo) { - path = strings.TrimPrefix(path, nsImportHugo+":") - f, err = hugofs.Os.Open(path) - } else { - var fi os.FileInfo - fi, err = t.c.sfs.Fs.Stat(path) - if err == nil { - m := fi.(hugofs.FileMetaInfo).Meta() - path = m.Filename - f, err = m.Open() - } - - } - - if err == nil { - fe := herrors. - NewFileErrorFromName(errors.New(errorMessage), path). - UpdatePosition(text.Position{Offset: -1, LineNumber: loc.Line, ColumnNumber: loc.Column}). - UpdateContent(f, nil) - - f.Close() - 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 { - t.c.rs.Logger.Errorf("js.Build failed: %s", err) - } - } - - return errors[0] - } - - if buildOptions.Sourcemap == api.SourceMapExternal { - content := string(result.OutputFiles[1].Contents) - symPath := path.Base(ctx.OutPath) + ".map" - re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`) - content = re.ReplaceAllString(content, "//# sourceMappingURL="+symPath+"\n") - - if err = ctx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil { - return err - } - _, err := ctx.To.Write([]byte(content)) - if err != nil { - return err - } - } else { - _, err := ctx.To.Write(result.OutputFiles[0].Contents) - if err != nil { - return err - } - } - return nil -} - -// Process process esbuild transform +// Process processes a resource with the user provided options. func (c *Client) Process(res resources.ResourceTransformer, opts map[string]any) (resource.Resource, error) { return res.Transform( &buildTransformation{c: c, optsm: opts}, ) } + +func (c *Client) transform(opts esbuild.Options, transformCtx *resources.ResourceTransformationCtx) (api.BuildResult, error) { + if transformCtx.DependencyManager != nil { + opts.DependencyManager = transformCtx.DependencyManager + } + result, err := c.c.Build(opts) + if err != nil { + return result, err + } + + if opts.ExternalOptions.SourceMap == "external" { + content := string(result.OutputFiles[1].Contents) + symPath := path.Base(transformCtx.OutPath) + ".map" + re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`) + content = re.ReplaceAllString(content, "//# sourceMappingURL="+symPath+"\n") + + if err = transformCtx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil { + return result, err + } + _, err := transformCtx.To.Write([]byte(content)) + if err != nil { + return result, err + } + } else { + _, err := transformCtx.To.Write(result.OutputFiles[0].Contents) + if err != nil { + return result, err + } + + } + return result, nil +} diff --git a/resources/resource_transformers/js/build_test.go b/resources/resource_transformers/js/build_test.go deleted file mode 100644 index 30a4490ed..000000000 --- a/resources/resource_transformers/js/build_test.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2020 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 diff --git a/resources/resource_transformers/js/js_integration_test.go b/resources/resource_transformers/js/js_integration_test.go index 304c51d33..69c558794 100644 --- a/resources/resource_transformers/js/js_integration_test.go +++ b/resources/resource_transformers/js/js_integration_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Hugo Authors. All rights reserved. +// 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. diff --git a/resources/resource_transformers/js/options.go b/resources/resource_transformers/js/options.go deleted file mode 100644 index 8c271d032..000000000 --- a/resources/resource_transformers/js/options.go +++ /dev/null @@ -1,461 +0,0 @@ -// Copyright 2020 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 - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/gohugoio/hugo/common/maps" - "github.com/gohugoio/hugo/common/paths" - "github.com/gohugoio/hugo/identity" - "github.com/spf13/afero" - - "github.com/evanw/esbuild/pkg/api" - - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/media" - "github.com/mitchellh/mapstructure" -) - -const ( - nsImportHugo = "ns-hugo" - nsParams = "ns-params" - - stdinImporter = "" -) - -// Options esbuild configuration -type Options 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 - - // Whether to write mapfiles - SourceMap string - - // 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 - - // 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 - - mediaType media.Type - outDir string - contents string - sourceDir string - resolveDir string - tsConfig string -} - -func decodeOptions(m map[string]any) (Options, error) { - var opts Options - - 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 -} - -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, -} - -func loaderFromFilename(filename string) api.Loader { - l, found := extensionToLoaderMap[filepath.Ext(filename)] - if found { - return l - } - return api.LoaderJS -} - -func resolveComponentInAssets(fs afero.Fs, impPath string) *hugofs.FileMeta { - findFirst := func(base string) *hugofs.FileMeta { - // This is the most common sub-set of ESBuild's default extensions. - // We assume that imports of JSON, CSS etc. will be using their full - // name with extension. - for _, ext := range []string{".js", ".ts", ".tsx", ".jsx"} { - if strings.HasSuffix(impPath, ext) { - // Import of foo.js.js need the full name. - continue - } - if fi, err := fs.Stat(base + ext); err == nil { - return fi.(hugofs.FileMetaInfo).Meta() - } - } - - // Not found. - return nil - } - - var m *hugofs.FileMeta - - // 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. - m = findFirst(impPath) - if m != nil { - return m - } - - base := filepath.Base(impPath) - if base == "index" { - // try index.esm.js etc. - m = findFirst(impPath + ".esm") - if m != nil { - return m - } - } - - // Check the path as is. - fi, err := fs.Stat(impPath) - - if err == nil { - if fi.IsDir() { - m = findFirst(filepath.Join(impPath, "index")) - if m == nil { - m = findFirst(filepath.Join(impPath, "index.esm")) - } - } else { - m = fi.(hugofs.FileMetaInfo).Meta() - } - } else if strings.HasSuffix(base, ".js") { - m = findFirst(strings.TrimSuffix(impPath, ".js")) - } - - return m -} - -func createBuildPlugins(depsManager identity.Manager, c *Client, opts Options) ([]api.Plugin, error) { - fs := c.rs.Assets - - resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) { - impPath := args.Path - if opts.Shims != nil { - override, found := opts.Shims[impPath] - if found { - impPath = override - } - } - isStdin := args.Importer == stdinImporter - var relDir string - if !isStdin { - rel, found := fs.MakePathRelative(args.Importer, true) - if !found { - // Not in any of the /assets folders. - // This is an import from a node_modules, let - // ESBuild resolve this. - return api.OnResolveResult{}, nil - } - - 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 := resolveComponentInAssets(fs.Fs, 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. - c.rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot) - return api.OnResolveResult{Path: m.Filename, Namespace: nsImportHugo}, 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: nsImportHugo}, - 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: 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) - } - bs := string(b) - paramsPlugin := api.Plugin{ - Name: "hugo-params-plugin", - Setup: func(build api.PluginBuild) { - build.OnResolve(api.OnResolveOptions{Filter: `^@params$`}, - func(args api.OnResolveArgs) (api.OnResolveResult, error) { - return api.OnResolveResult{ - Path: args.Path, - Namespace: nsParams, - }, nil - }) - build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsParams}, - func(args api.OnLoadArgs) (api.OnLoadResult, error) { - return api.OnLoadResult{ - Contents: &bs, - Loader: api.LoaderJSON, - }, nil - }) - }, - } - - return []api.Plugin{importResolver, paramsPlugin}, nil -} - -func toBuildOptions(opts Options) (buildOptions api.BuildOptions, err error) { - var target api.Target - switch opts.Target { - case "", "esnext": - target = api.ESNext - case "es5": - target = api.ES5 - case "es6", "es2015": - target = api.ES2015 - case "es2016": - target = api.ES2016 - case "es2017": - target = api.ES2017 - case "es2018": - target = api.ES2018 - case "es2019": - target = api.ES2019 - case "es2020": - target = api.ES2020 - case "es2021": - target = api.ES2021 - case "es2022": - target = api.ES2022 - case "es2023": - target = api.ES2023 - default: - err = fmt.Errorf("invalid target: %q", opts.Target) - return - } - - mediaType := opts.mediaType - if mediaType.IsZero() { - mediaType = media.Builtin.JavascriptType - } - - var loader api.Loader - switch mediaType.SubType { - // TODO(bep) ESBuild support a set of other loaders, but I currently fail - // to see the relevance. That may change as we start using this. - 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 "": - sourceMap = api.SourceMapNone - default: - err = fmt.Errorf("unsupported sourcemap type: %q", opts.SourceMap) - return - } - - buildOptions = api.BuildOptions{ - Outfile: outFile, - Bundle: true, - - Target: target, - Format: format, - Sourcemap: sourceMap, - - MinifyWhitespace: opts.Minify, - MinifyIdentifiers: opts.Minify, - MinifySyntax: opts.Minify, - - Outdir: outDir, - Define: defines, - - External: opts.Externals, - - JSXFactory: opts.JSXFactory, - JSXFragment: opts.JSXFragment, - - JSX: jsx, - JSXImportSource: opts.JSXImportSource, - - Tsconfig: opts.tsConfig, - - // Note: We're not passing Sourcefile to ESBuild. - // This makes ESBuild pass `stdin` as the Importer to the import - // resolver, which is what we need/expect. - Stdin: &api.StdinOptions{ - Contents: opts.contents, - ResolveDir: opts.resolveDir, - Loader: loader, - }, - } - return -} diff --git a/resources/resource_transformers/js/options_test.go b/resources/resource_transformers/js/options_test.go deleted file mode 100644 index 53aa9b6bb..000000000 --- a/resources/resource_transformers/js/options_test.go +++ /dev/null @@ -1,241 +0,0 @@ -// Copyright 2020 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 - -import ( - "path" - "path/filepath" - "testing" - - "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/gohugoio/hugo/media" - - "github.com/spf13/afero" - - "github.com/evanw/esbuild/pkg/api" - - qt "github.com/frankban/quicktest" -) - -// This test is added to test/warn against breaking the "stability" of the -// cache key. It's sometimes needed to break this, but should be avoided if possible. -func TestOptionKey(t *testing.T) { - c := qt.New(t) - - opts := map[string]any{ - "TargetPath": "foo", - "Target": "es2018", - } - - key := (&buildTransformation{optsm: opts}).Key() - - c.Assert(key.Value(), qt.Equals, "jsbuild_1533819657654811600") -} - -func TestToBuildOptions(t *testing.T) { - c := qt.New(t) - - opts, err := toBuildOptions(Options{mediaType: media.Builtin.JavascriptType}) - - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ESNext, - Format: api.FormatIIFE, - Stdin: &api.StdinOptions{ - Loader: api.LoaderJS, - }, - }) - - opts, err = toBuildOptions(Options{ - Target: "es2018", - Format: "cjs", - Minify: true, - mediaType: media.Builtin.JavascriptType, - AvoidTDZ: true, - }) - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ES2018, - Format: api.FormatCommonJS, - MinifyIdentifiers: true, - MinifySyntax: true, - MinifyWhitespace: true, - Stdin: &api.StdinOptions{ - Loader: api.LoaderJS, - }, - }) - - opts, err = toBuildOptions(Options{ - Target: "es2018", Format: "cjs", Minify: true, mediaType: media.Builtin.JavascriptType, - SourceMap: "inline", - }) - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ES2018, - Format: api.FormatCommonJS, - MinifyIdentifiers: true, - MinifySyntax: true, - MinifyWhitespace: true, - Sourcemap: api.SourceMapInline, - Stdin: &api.StdinOptions{ - Loader: api.LoaderJS, - }, - }) - - opts, err = toBuildOptions(Options{ - Target: "es2018", Format: "cjs", Minify: true, mediaType: media.Builtin.JavascriptType, - SourceMap: "inline", - }) - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ES2018, - Format: api.FormatCommonJS, - MinifyIdentifiers: true, - MinifySyntax: true, - MinifyWhitespace: true, - Sourcemap: api.SourceMapInline, - Stdin: &api.StdinOptions{ - Loader: api.LoaderJS, - }, - }) - - opts, err = toBuildOptions(Options{ - Target: "es2018", Format: "cjs", Minify: true, mediaType: media.Builtin.JavascriptType, - SourceMap: "external", - }) - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ES2018, - Format: api.FormatCommonJS, - MinifyIdentifiers: true, - MinifySyntax: true, - MinifyWhitespace: true, - Sourcemap: api.SourceMapExternal, - Stdin: &api.StdinOptions{ - Loader: api.LoaderJS, - }, - }) - - opts, err = toBuildOptions(Options{ - mediaType: media.Builtin.JavascriptType, - JSX: "automatic", JSXImportSource: "preact", - }) - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ESNext, - Format: api.FormatIIFE, - 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, err := toBuildOptions(Options{ - Target: test.target, - mediaType: media.Builtin.JavascriptType, - }) - c.Assert(err, qt.IsNil) - c.Assert(opts.Target, qt.Equals, test.expect) - }) - } -} - -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) - - got := resolveComponentInAssets(bfs.Assets.Fs, 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/resources/resource_transformers/js/transform.go b/resources/resource_transformers/js/transform.go new file mode 100644 index 000000000..13909e54c --- /dev/null +++ b/resources/resource_transformers/js/transform.go @@ -0,0 +1,68 @@ +// 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 + +import ( + "io" + "path" + "path/filepath" + + "github.com/gohugoio/hugo/internal/js/esbuild" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/internal" +) + +type buildTransformation struct { + optsm map[string]any + c *Client +} + +func (t *buildTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("jsbuild", t.optsm) +} + +func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { + ctx.OutMediaType = media.Builtin.JavascriptType + + var opts esbuild.Options + + if t.optsm != nil { + optsExt, err := esbuild.DecodeExternalOptions(t.optsm) + if err != nil { + return err + } + opts.ExternalOptions = optsExt + } + + if opts.TargetPath != "" { + ctx.OutPath = opts.TargetPath + } else { + ctx.ReplaceOutPathExtension(".js") + } + + src, err := io.ReadAll(ctx.From) + if err != nil { + return err + } + + opts.SourceDir = filepath.FromSlash(path.Dir(ctx.SourcePath)) + opts.Contents = string(src) + opts.MediaType = ctx.InMediaType + opts.Stdin = true + + _, err = t.c.transform(opts, ctx) + + return err +} diff --git a/resources/transform.go b/resources/transform.go index 336495e6d..9781ea6c3 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -52,6 +52,7 @@ var ( _ identity.IdentityGroupProvider = (*resourceAdapterInner)(nil) _ resource.Source = (*resourceAdapter)(nil) _ resource.Identifier = (*resourceAdapter)(nil) + _ resource.PathProvider = (*resourceAdapter)(nil) _ resource.ResourceNameTitleProvider = (*resourceAdapter)(nil) _ resource.WithResourceMetaProvider = (*resourceAdapter)(nil) _ identity.DependencyManagerProvider = (*resourceAdapter)(nil) @@ -277,6 +278,11 @@ func (r *resourceAdapter) Key() string { return r.target.(resource.Identifier).Key() } +func (r *resourceAdapter) Path() string { + r.init(false, false) + return r.target.(resource.PathProvider).Path() +} + func (r *resourceAdapter) MediaType() media.Type { r.init(false, false) return r.target.MediaType() diff --git a/tpl/js/init.go b/tpl/js/init.go index 69d7c7275..97e76c33d 100644 --- a/tpl/js/init.go +++ b/tpl/js/init.go @@ -24,7 +24,10 @@ const name = "js" func init() { f := func(d *deps.Deps) *internal.TemplateFuncsNamespace { - ctx := New(d) + ctx, err := New(d) + if err != nil { + panic(err) + } ns := &internal.TemplateFuncsNamespace{ Name: name, diff --git a/tpl/js/js.go b/tpl/js/js.go index c68e0af92..50152160a 100644 --- a/tpl/js/js.go +++ b/tpl/js/js.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Hugo Authors. All rights reserved. +// 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. @@ -16,30 +16,47 @@ package js import ( "errors" + "path" + "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/internal/js/esbuild" "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" + "github.com/gohugoio/hugo/resources/resource_factories/create" "github.com/gohugoio/hugo/resources/resource_transformers/babel" - "github.com/gohugoio/hugo/resources/resource_transformers/js" + jstransform "github.com/gohugoio/hugo/resources/resource_transformers/js" "github.com/gohugoio/hugo/tpl/internal/resourcehelpers" ) // New returns a new instance of the js-namespaced template functions. -func New(deps *deps.Deps) *Namespace { +func New(deps *deps.Deps) (*Namespace, error) { if deps.ResourceSpec == nil { - return &Namespace{} + return &Namespace{}, nil } + + batcherClient, err := esbuild.NewBatcherClient(deps) + if err != nil { + return nil, err + } + return &Namespace{ - client: js.New(deps.BaseFs.Assets, deps.ResourceSpec), - babelClient: babel.New(deps.ResourceSpec), - } + d: deps, + jsTransformClient: jstransform.New(deps.BaseFs.Assets, deps.ResourceSpec), + jsBatcherClient: batcherClient, + createClient: create.New(deps.ResourceSpec), + babelClient: babel.New(deps.ResourceSpec), + }, nil } // Namespace provides template functions for the "js" namespace. type Namespace struct { - client *js.Client - babelClient *babel.Client + d *deps.Deps + + jsTransformClient *jstransform.Client + createClient *create.Client + babelClient *babel.Client + jsBatcherClient *esbuild.BatcherClient } // Build processes the given Resource with ESBuild. @@ -65,7 +82,18 @@ func (ns *Namespace) Build(args ...any) (resource.Resource, error) { m = map[string]any{"targetPath": targetPath} } - return ns.client.Process(r, m) + return ns.jsTransformClient.Process(r, m) +} + +func (ns *Namespace) Batch(id string, store *maps.Scratch) (esbuild.Batcher, error) { + key := path.Join(esbuild.NsBatch, id) + b, err := store.GetOrCreate(key, func() (any, error) { + return ns.jsBatcherClient.New(id) + }) + if err != nil { + return nil, err + } + return b.(esbuild.Batcher), nil } // Babel processes the given Resource with Babel. diff --git a/tpl/tplimpl/embedded/templates/_hugo/build/js/batch-esm-runner.gotmpl b/tpl/tplimpl/embedded/templates/_hugo/build/js/batch-esm-runner.gotmpl new file mode 100644 index 000000000..e1b3b9bc3 --- /dev/null +++ b/tpl/tplimpl/embedded/templates/_hugo/build/js/batch-esm-runner.gotmpl @@ -0,0 +1,16 @@ +{{ range $i, $e := .Scripts -}} + {{ printf "import { %s as Script%d } from %q;" .Export $i .Import }} +{{ end -}} +{{ range $i, $e := .Runners }} + {{ printf "import { %s as Run%d } from %q;" .Export $i .Import }} +{{ end }} +{{/* */}} +let scripts = []; +{{ range $i, $e := .Scripts -}} + scripts.push({{ .RunnerJSON $i }}); +{{ end -}} +{{/* */}} +{{ range $i, $e := .Runners }} + {{ $id := printf "Run%d" $i }} + {{ $id }}(scripts); +{{ end }}