Add js.Batch

Fixes #12626
Closes #7499
Closes #9978
Closes #12879
Closes #13113
Fixes #13116
This commit is contained in:
Bjørn Erik Pedersen 2024-12-10 16:22:08 +01:00
parent 157d86414d
commit e293e7ca6d
61 changed files with 4520 additions and 1003 deletions

View file

@ -920,7 +920,11 @@ func (c *hugoBuilder) handleEvents(watcher *watcher.Batcher,
changed := c.changeDetector.changed() changed := c.changeDetector.changed()
if c.changeDetector != nil { if c.changeDetector != nil {
if len(changed) >= 10 {
lrl.Logf("build changed %d files", len(changed)) lrl.Logf("build changed %d files", len(changed))
} else {
lrl.Logf("build changed %d files: %q", len(changed), changed)
}
if len(changed) == 0 { if len(changed) == 0 {
// Nothing has changed. // Nothing has changed.
return return

View file

@ -32,6 +32,7 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -210,16 +211,17 @@ func (f *fileChangeDetector) changed() []string {
} }
} }
return f.filterIrrelevant(c) return f.filterIrrelevantAndSort(c)
} }
func (f *fileChangeDetector) filterIrrelevant(in []string) []string { func (f *fileChangeDetector) filterIrrelevantAndSort(in []string) []string {
var filtered []string var filtered []string
for _, v := range in { for _, v := range in {
if !f.irrelevantRe.MatchString(v) { if !f.irrelevantRe.MatchString(v) {
filtered = append(filtered, v) filtered = append(filtered, v)
} }
} }
sort.Strings(filtered)
return filtered return filtered
} }

View file

@ -133,6 +133,21 @@ func IsNotExist(err error) bool {
return false 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`) var nilPointerErrRe = regexp.MustCompile(`at <(.*)>: error calling (.*?): runtime error: invalid memory address or nil pointer dereference`)
const deferredPrefix = "__hdeferred/" const deferredPrefix = "__hdeferred/"

View file

@ -384,7 +384,7 @@ func extractPosition(e error) (pos text.Position) {
case godartsass.SassError: case godartsass.SassError:
span := v.Span span := v.Span
start := span.Start start := span.Start
filename, _ := paths.UrlToFilename(span.Url) filename, _ := paths.UrlStringToFilename(span.Url)
pos.Filename = filename pos.Filename = filename
pos.Offset = start.Offset pos.Offset = start.Offset
pos.ColumnNumber = start.Column pos.ColumnNumber = start.Column

View file

@ -223,6 +223,27 @@ func AsTime(v reflect.Value, loc *time.Location) (time.Time, bool) {
return time.Time{}, false return time.Time{}, false
} }
// ToSliceAny converts the given value to a slice of any if possible.
func ToSliceAny(v any) ([]any, bool) {
if v == nil {
return nil, false
}
switch vv := v.(type) {
case []any:
return vv, true
default:
vvv := reflect.ValueOf(v)
if vvv.Kind() == reflect.Slice {
out := make([]any, vvv.Len())
for i := 0; i < vvv.Len(); i++ {
out[i] = vvv.Index(i).Interface()
}
return out, true
}
}
return nil, false
}
func CallMethodByName(cxt context.Context, name string, v reflect.Value) []reflect.Value { func CallMethodByName(cxt context.Context, name string, v reflect.Value) []reflect.Value {
fn := v.MethodByName(name) fn := v.MethodByName(name)
var args []reflect.Value var args []reflect.Value

View file

@ -50,6 +50,19 @@ func TestIsContextType(t *testing.T) {
c.Assert(IsContextType(reflect.TypeOf(valueCtx)), qt.IsTrue) c.Assert(IsContextType(reflect.TypeOf(valueCtx)), qt.IsTrue)
} }
func TestToSliceAny(t *testing.T) {
c := qt.New(t)
checkOK := func(in any, expected []any) {
out, ok := ToSliceAny(in)
c.Assert(ok, qt.Equals, true)
c.Assert(out, qt.DeepEquals, expected)
}
checkOK([]any{1, 2, 3}, []any{1, 2, 3})
checkOK([]int{1, 2, 3}, []any{1, 2, 3})
}
func BenchmarkIsContextType(b *testing.B) { func BenchmarkIsContextType(b *testing.B) {
type k string type k string
b.Run("value", func(b *testing.B) { b.Run("value", func(b *testing.B) {

View file

@ -113,11 +113,14 @@ func (c *Cache[K, T]) set(key K, value T) {
} }
// ForEeach calls the given function for each key/value pair in the cache. // 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() c.RLock()
defer c.RUnlock() defer c.RUnlock()
for k, v := range c.m { for k, v := range c.m {
f(k, v) if !f(k, v) {
return
}
} }
} }

View file

@ -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"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. // you may not use this file except in compliance with the License.
@ -18,6 +18,7 @@ import (
"net/url" "net/url"
"path" "path"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
) )
@ -159,31 +160,6 @@ func Uglify(in string) string {
return path.Clean(in) return path.Clean(in)
} }
// UrlToFilename converts the URL s to a filename.
// If ParseRequestURI fails, the input is just converted to OS specific slashes and returned.
func UrlToFilename(s string) (string, bool) {
u, err := url.ParseRequestURI(s)
if err != nil {
return filepath.FromSlash(s), false
}
p := u.Path
if p == "" {
p, _ = url.QueryUnescape(u.Opaque)
return filepath.FromSlash(p), true
}
p = filepath.FromSlash(p)
if u.Host != "" {
// C:\data\file.txt
p = strings.ToUpper(u.Host) + ":" + p
}
return p, true
}
// URLEscape escapes unicode letters. // URLEscape escapes unicode letters.
func URLEscape(uri string) string { func URLEscape(uri string) string {
// escape unicode letters // escape unicode letters
@ -193,3 +169,105 @@ func URLEscape(uri string) string {
} }
return u.String() return u.String()
} }
// TrimExt trims the extension from a path..
func TrimExt(in string) string {
return strings.TrimSuffix(in, path.Ext(in))
}
// From https://github.com/golang/go/blob/e0c76d95abfc1621259864adb3d101cf6f1f90fc/src/cmd/go/internal/web/url.go#L45
func UrlFromFilename(filename string) (*url.URL, error) {
if !filepath.IsAbs(filename) {
return nil, fmt.Errorf("filepath must be absolute")
}
// If filename has a Windows volume name, convert the volume to a host and prefix
// per https://blogs.msdn.microsoft.com/ie/2006/12/06/file-uris-in-windows/.
if vol := filepath.VolumeName(filename); vol != "" {
if strings.HasPrefix(vol, `\\`) {
filename = filepath.ToSlash(filename[2:])
i := strings.IndexByte(filename, '/')
if i < 0 {
// A degenerate case.
// \\host.example.com (without a share name)
// becomes
// file://host.example.com/
return &url.URL{
Scheme: "file",
Host: filename,
Path: "/",
}, nil
}
// \\host.example.com\Share\path\to\file
// becomes
// file://host.example.com/Share/path/to/file
return &url.URL{
Scheme: "file",
Host: filename[:i],
Path: filepath.ToSlash(filename[i:]),
}, nil
}
// C:\path\to\file
// becomes
// file:///C:/path/to/file
return &url.URL{
Scheme: "file",
Path: "/" + filepath.ToSlash(filename),
}, nil
}
// /path/to/file
// becomes
// file:///path/to/file
return &url.URL{
Scheme: "file",
Path: filepath.ToSlash(filename),
}, nil
}
// UrlToFilename converts the URL s to a filename.
// If ParseRequestURI fails, the input is just converted to OS specific slashes and returned.
func UrlStringToFilename(s string) (string, bool) {
u, err := url.ParseRequestURI(s)
if err != nil {
return filepath.FromSlash(s), false
}
p := u.Path
if p == "" {
p, _ = url.QueryUnescape(u.Opaque)
return filepath.FromSlash(p), false
}
if runtime.GOOS != "windows" {
return p, true
}
if len(p) == 0 || p[0] != '/' {
return filepath.FromSlash(p), false
}
p = filepath.FromSlash(p)
if len(u.Host) == 1 {
// file://c/Users/...
return strings.ToUpper(u.Host) + ":" + p, true
}
if u.Host != "" && u.Host != "localhost" {
if filepath.VolumeName(u.Host) != "" {
return "", false
}
return `\\` + u.Host + p, true
}
if vol := filepath.VolumeName(p[1:]); vol == "" || strings.HasPrefix(vol, `\\`) {
return "", false
}
return p[1:], true
}

View file

@ -19,6 +19,13 @@ type Closer interface {
Close() error 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 { type CloseAdder interface {
Add(Closer) Add(Closer)
} }

View file

@ -137,11 +137,11 @@ func (c ConfigLanguage) Watching() bool {
return c.m.Base.Internal.Watch 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() { if !c.Watching() {
return identity.NopManager return identity.NopManager
} }
return identity.NewManager(name) return identity.NewManager(name, opts...)
} }
func (c ConfigLanguage) ContentTypes() config.ContentTypesProvider { func (c ConfigLanguage) ContentTypes() config.ContentTypesProvider {

View file

@ -58,7 +58,7 @@ type AllProvider interface {
BuildDrafts() bool BuildDrafts() bool
Running() bool Running() bool
Watching() bool Watching() bool
NewIdentityManager(name string) identity.Manager NewIdentityManager(name string, opts ...identity.ManagerOption) identity.Manager
FastRenderMode() bool FastRenderMode() bool
PrintUnusedTemplates() bool PrintUnusedTemplates() bool
EnableMissingTranslationPlaceholders() bool EnableMissingTranslationPlaceholders() bool

51
deps/deps.go vendored
View file

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@ -47,6 +48,9 @@ type Deps struct {
// The templates to use. This will usually implement the full tpl.TemplateManager. // The templates to use. This will usually implement the full tpl.TemplateManager.
tmplHandlers *tpl.TemplateHandlers tmplHandlers *tpl.TemplateHandlers
// The template funcs.
TmplFuncMap map[string]any
// The file systems to use. // The file systems to use.
Fs *hugofs.Fs `json:"-"` Fs *hugofs.Fs `json:"-"`
@ -83,10 +87,13 @@ type Deps struct {
Metrics metrics.Provider Metrics metrics.Provider
// BuildStartListeners will be notified before a build starts. // BuildStartListeners will be notified before a build starts.
BuildStartListeners *Listeners BuildStartListeners *Listeners[any]
// BuildEndListeners will be notified after a build finishes. // BuildEndListeners will be notified after a build finishes.
BuildEndListeners *Listeners BuildEndListeners *Listeners[any]
// OnChangeListeners will be notified when something changes.
OnChangeListeners *Listeners[identity.Identity]
// Resources that gets closed when the build is done or the server shuts down. // Resources that gets closed when the build is done or the server shuts down.
BuildClosers *types.Closers BuildClosers *types.Closers
@ -154,17 +161,21 @@ func (d *Deps) Init() error {
} }
if d.BuildStartListeners == nil { if d.BuildStartListeners == nil {
d.BuildStartListeners = &Listeners{} d.BuildStartListeners = &Listeners[any]{}
} }
if d.BuildEndListeners == nil { if d.BuildEndListeners == nil {
d.BuildEndListeners = &Listeners{} d.BuildEndListeners = &Listeners[any]{}
} }
if d.BuildClosers == nil { if d.BuildClosers == nil {
d.BuildClosers = &types.Closers{} d.BuildClosers = &types.Closers{}
} }
if d.OnChangeListeners == nil {
d.OnChangeListeners = &Listeners[identity.Identity]{}
}
if d.Metrics == nil && d.Conf.TemplateMetrics() { if d.Metrics == nil && d.Conf.TemplateMetrics() {
d.Metrics = metrics.NewProvider(d.Conf.TemplateMetricsHints()) d.Metrics = metrics.NewProvider(d.Conf.TemplateMetricsHints())
} }
@ -268,6 +279,23 @@ func (d *Deps) Compile(prototype *Deps) error {
return nil 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 { type globalErrHandler struct {
logger loggers.Logger logger loggers.Logger
@ -306,15 +334,16 @@ func (e *globalErrHandler) StopErrorCollector() {
} }
// Listeners represents an event listener. // Listeners represents an event listener.
type Listeners struct { type Listeners[T any] struct {
sync.Mutex sync.Mutex
// A list of funcs to be notified about an event. // A list of funcs to be notified about an event.
listeners []func() // If the return value is true, the listener will be removed.
listeners []func(...T) bool
} }
// Add adds a function to a Listeners instance. // Add adds a function to a Listeners instance.
func (b *Listeners) Add(f func()) { func (b *Listeners[T]) Add(f func(...T) bool) {
if b == nil { if b == nil {
return return
} }
@ -324,13 +353,17 @@ func (b *Listeners) Add(f func()) {
} }
// Notify executes all listener functions. // Notify executes all listener functions.
func (b *Listeners) Notify() { func (b *Listeners[T]) Notify(vs ...T) {
b.Lock() b.Lock()
defer b.Unlock() defer b.Unlock()
temp := b.listeners[:0]
for _, notify := range b.listeners { for _, notify := range b.listeners {
notify() if !notify(vs...) {
temp = append(temp, notify)
} }
} }
b.listeners = temp
}
// ResourceProvider is used to create and refresh, and clone resources needed. // ResourceProvider is used to create and refresh, and clone resources needed.
type ResourceProvider interface { type ResourceProvider interface {

View file

@ -1754,6 +1754,11 @@ func (sa *sitePagesAssembler) assembleResources() error {
mt = rs.rc.ContentMediaType mt = rs.rc.ContentMediaType
} }
var filename string
if rs.fi != nil {
filename = rs.fi.Meta().Filename
}
rd := resources.ResourceSourceDescriptor{ rd := resources.ResourceSourceDescriptor{
OpenReadSeekCloser: rs.opener, OpenReadSeekCloser: rs.opener,
Path: rs.path, Path: rs.path,
@ -1762,6 +1767,7 @@ func (sa *sitePagesAssembler) assembleResources() error {
TargetBasePaths: targetBasePaths, TargetBasePaths: targetBasePaths,
BasePathRelPermalink: targetPaths.SubResourceBaseLink, BasePathRelPermalink: targetPaths.SubResourceBaseLink,
BasePathTargetPath: baseTarget, BasePathTargetPath: baseTarget,
SourceFilenameOrPath: filename,
NameNormalized: relPath, NameNormalized: relPath,
NameOriginal: relPathOriginal, NameOriginal: relPathOriginal,
MediaType: mt, MediaType: mt,

View file

@ -111,6 +111,10 @@ func (h *HugoSites) ShouldSkipFileChangeEvent(ev fsnotify.Event) bool {
return h.skipRebuildForFilenames[ev.Name] return h.skipRebuildForFilenames[ev.Name]
} }
func (h *HugoSites) Close() error {
return h.Deps.Close()
}
func (h *HugoSites) isRebuild() bool { func (h *HugoSites) isRebuild() bool {
return h.buildCounter.Load() > 0 return h.buildCounter.Load() > 0
} }

View file

@ -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) g.Enqueue(filename)
return true
}) })
return g.Wait() return g.Wait()
@ -1058,6 +1059,8 @@ func (h *HugoSites) processPartialFileEvents(ctx context.Context, l logg.LevelLo
} }
} }
h.Deps.OnChangeListeners.Notify(changed.Changes()...)
if err := h.resolveAndClearStateForIdentities(ctx, l, cacheBusterOr, changed.Drain()); err != nil { if err := h.resolveAndClearStateForIdentities(ctx, l, cacheBusterOr, changed.Drain()); err != nil {
return err return err
} }

View file

@ -554,8 +554,6 @@ toc line 3
toc line 4 toc line 4
` `
t.Run("base template", func(t *testing.T) { t.Run("base template", func(t *testing.T) {
@ -569,7 +567,7 @@ toc line 4
).BuildE() ).BuildE()
b.Assert(err, qt.IsNotNil) b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`render of "home" failed: "/layouts/baseof.html:4:6"`)) b.Assert(err.Error(), qt.Contains, `baseof.html:4:6`)
}) })
t.Run("index template", func(t *testing.T) { t.Run("index template", func(t *testing.T) {
@ -583,7 +581,7 @@ toc line 4
).BuildE() ).BuildE()
b.Assert(err, qt.IsNotNil) b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`render of "home" failed: "/layouts/index.html:3:7"`)) b.Assert(err.Error(), qt.Contains, `index.html:3:7"`)
}) })
t.Run("partial from define", func(t *testing.T) { t.Run("partial from define", func(t *testing.T) {
@ -597,8 +595,7 @@ toc line 4
).BuildE() ).BuildE()
b.Assert(err, qt.IsNotNil) b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`render of "home" failed: "/layouts/index.html:7:8": execute of template failed`)) b.Assert(err.Error(), qt.Contains, `toc.html:2:8"`)
b.Assert(err.Error(), qt.Contains, `execute of template failed: template: partials/toc.html:2:8: executing "partials/toc.html"`)
}) })
} }

View file

@ -69,6 +69,13 @@ func TestOptDebug() TestOpt {
} }
} }
// TestOptInfo will enable info logging in integration tests.
func TestOptInfo() TestOpt {
return func(c *IntegrationTestConfig) {
c.LogLevel = logg.LevelInfo
}
}
// TestOptWarn will enable warn logging in integration tests. // TestOptWarn will enable warn logging in integration tests.
func TestOptWarn() TestOpt { func TestOptWarn() TestOpt {
return func(c *IntegrationTestConfig) { return func(c *IntegrationTestConfig) {
@ -90,6 +97,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. // TestOptWithWorkingDir allows setting any config optiona as a function al option.
func TestOptWithConfig(fn func(c *IntegrationTestConfig)) TestOpt { func TestOptWithConfig(fn func(c *IntegrationTestConfig)) TestOpt {
return func(c *IntegrationTestConfig) { return func(c *IntegrationTestConfig) {
@ -284,8 +298,9 @@ func (s *IntegrationTestBuilder) negate(match string) (string, bool) {
func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...string) { func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...string) {
s.Helper() s.Helper()
content := strings.TrimSpace(s.FileContent(filename)) content := strings.TrimSpace(s.FileContent(filename))
for _, m := range matches { 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") lines := strings.Split(m, "\n")
for _, match := range lines { for _, match := range lines {
match = strings.TrimSpace(match) match = strings.TrimSpace(match)
@ -313,7 +328,8 @@ func (s *IntegrationTestBuilder) AssertFileContentExact(filename string, matches
s.Helper() s.Helper()
content := s.FileContent(filename) content := s.FileContent(filename)
for _, m := range matches { 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)
} }
} }
@ -450,6 +466,11 @@ func (s *IntegrationTestBuilder) Build() *IntegrationTestBuilder {
return s return s
} }
func (s *IntegrationTestBuilder) Close() {
s.Helper()
s.Assert(s.H.Close(), qt.IsNil)
}
func (s *IntegrationTestBuilder) LogString() string { func (s *IntegrationTestBuilder) LogString() string {
return s.lastBuildLog return s.lastBuildLog
} }

View file

@ -143,6 +143,10 @@ func (p *pageState) GetDependencyManagerForScope(scope int) identity.Manager {
} }
} }
func (p *pageState) GetDependencyManagerForScopesAll() []identity.Manager {
return []identity.Manager{p.dependencyManager, p.dependencyManagerOutput}
}
func (p *pageState) Key() string { func (p *pageState) Key() string {
return "page-" + strconv.FormatUint(p.pid, 10) return "page-" + strconv.FormatUint(p.pid, 10)
} }

View file

@ -143,13 +143,29 @@ func (c *pagesCollector) Collect() (collectErr error) {
s.pageMap.cfg.isRebuild = true s.pageMap.cfg.isRebuild = true
} }
var hasStructuralChange bool
for _, id := range c.ids {
if id.isStructuralChange() {
hasStructuralChange = true
break
}
}
for _, id := range c.ids { for _, id := range c.ids {
if id.p.IsLeafBundle() { if id.p.IsLeafBundle() {
collectErr = c.collectDir( collectErr = c.collectDir(
id.p, id.p,
false, false,
func(fim hugofs.FileMetaInfo) bool { func(fim hugofs.FileMetaInfo) bool {
if hasStructuralChange {
return true return true
}
fimp := fim.Meta().PathInfo
if fimp == nil {
return true
}
return fimp.Path() == id.p.Path()
}, },
) )
} else if id.p.IsBranchBundle() { } else if id.p.IsBranchBundle() {

View file

@ -245,10 +245,11 @@ func (b *BuildState) resolveDeletedPaths() {
return return
} }
var paths []string 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 { if _, found := b.sourceInfosCurrent.Get(k); !found {
paths = append(paths, k) paths = append(paths, k)
} }
return true
}) })
b.DeletedPaths = paths b.DeletedPaths = paths
@ -287,6 +288,10 @@ func (p *PagesFromTemplate) GetDependencyManagerForScope(scope int) identity.Man
return p.DependencyManager return p.DependencyManager
} }
func (p *PagesFromTemplate) GetDependencyManagerForScopesAll() []identity.Manager {
return []identity.Manager{p.DependencyManager}
}
func (p *PagesFromTemplate) Execute(ctx context.Context) (BuildInfo, error) { func (p *PagesFromTemplate) Execute(ctx context.Context) (BuildInfo, error) {
defer func() { defer func() {
p.buildState.PrepareNextBuild() p.buildState.PrepareNextBuild()

View file

@ -98,6 +98,18 @@ My Other Text: {{ $r.Content }}|{{ $r.Permalink }}|
` `
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(2) // home (rss) + bundle.
b.AssertRenderCountContent(1)
}
func TestRebuildEditTextFileInLeafBundle(t *testing.T) { func TestRebuildEditTextFileInLeafBundle(t *testing.T) {
b := TestRunning(t, rebuildFilesSimple) b := TestRunning(t, rebuildFilesSimple)
b.AssertFileContent("public/mysection/mysectionbundle/index.html", b.AssertFileContent("public/mysection/mysectionbundle/index.html",
@ -962,7 +974,7 @@ Single. {{ partial "head.html" . }}$
RelPermalink: {{ $js.RelPermalink }}| RelPermalink: {{ $js.RelPermalink }}|
` `
b := TestRunning(t, files) b := TestRunning(t, files, TestOptOsFs())
b.AssertFileContent("public/p1/index.html", "/js/main.712a50b59d0f0dedb4e3606eaa3860b1f1a5305f6c42da30a2985e473ba314eb.js") b.AssertFileContent("public/p1/index.html", "/js/main.712a50b59d0f0dedb4e3606eaa3860b1f1a5305f6c42da30a2985e473ba314eb.js")
b.AssertFileContent("public/index.html", "/js/main.712a50b59d0f0dedb4e3606eaa3860b1f1a5305f6c42da30a2985e473ba314eb.js") b.AssertFileContent("public/index.html", "/js/main.712a50b59d0f0dedb4e3606eaa3860b1f1a5305f6c42da30a2985e473ba314eb.js")
@ -998,7 +1010,7 @@ Base. {{ partial "common/head.html" . }}$
RelPermalink: {{ $js.RelPermalink }}| RelPermalink: {{ $js.RelPermalink }}|
` `
b := TestRunning(t, files) b := TestRunning(t, files, TestOptOsFs())
b.AssertFileContent("public/index.html", "/js/main.712a50b59d0f0dedb4e3606eaa3860b1f1a5305f6c42da30a2985e473ba314eb.js") b.AssertFileContent("public/index.html", "/js/main.712a50b59d0f0dedb4e3606eaa3860b1f1a5305f6c42da30a2985e473ba314eb.js")

View file

@ -1493,7 +1493,11 @@ func (s *Site) renderForTemplate(ctx context.Context, name, outputFormat string,
} }
if err = s.Tmpl().ExecuteWithContext(ctx, templ, w, d); err != nil { 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.String()
}
return fmt.Errorf("render of %q failed: %w", filename, err)
} }
return return
} }

View file

@ -82,9 +82,8 @@ func FirstIdentity(v any) Identity {
var result Identity = Anonymous var result Identity = Anonymous
WalkIdentitiesShallow(v, func(level int, id Identity) bool { WalkIdentitiesShallow(v, func(level int, id Identity) bool {
result = id result = id
return true return result != Anonymous
}) })
return result return result
} }
@ -146,6 +145,7 @@ func (d DependencyManagerProviderFunc) GetDependencyManager() Manager {
// DependencyManagerScopedProvider provides a manager for dependencies with a given scope. // DependencyManagerScopedProvider provides a manager for dependencies with a given scope.
type DependencyManagerScopedProvider interface { type DependencyManagerScopedProvider interface {
GetDependencyManagerForScope(scope int) Manager GetDependencyManagerForScope(scope int) Manager
GetDependencyManagerForScopesAll() []Manager
} }
// ForEeachIdentityProvider provides a way iterate over identities. // ForEeachIdentityProvider provides a way iterate over identities.
@ -308,11 +308,13 @@ type identityManager struct {
func (im *identityManager) AddIdentity(ids ...Identity) { func (im *identityManager) AddIdentity(ids ...Identity) {
im.mu.Lock() im.mu.Lock()
defer im.mu.Unlock()
for _, id := range ids { for _, id := range ids {
if id == nil || id == Anonymous { if id == nil || id == Anonymous {
continue continue
} }
if _, found := im.ids[id]; !found { if _, found := im.ids[id]; !found {
if im.onAddIdentity != nil { if im.onAddIdentity != nil {
im.onAddIdentity(id) im.onAddIdentity(id)
@ -320,7 +322,6 @@ func (im *identityManager) AddIdentity(ids ...Identity) {
im.ids[id] = true im.ids[id] = true
} }
} }
im.mu.Unlock()
} }
func (im *identityManager) AddIdentityForEach(ids ...ForEeachIdentityProvider) { func (im *identityManager) AddIdentityForEach(ids ...ForEeachIdentityProvider) {
@ -355,6 +356,10 @@ func (im *identityManager) GetDependencyManagerForScope(int) Manager {
return im return im
} }
func (im *identityManager) GetDependencyManagerForScopesAll() []Manager {
return []Manager{im}
}
func (im *identityManager) String() string { func (im *identityManager) String() string {
return fmt.Sprintf("IdentityManager(%s)", im.name) return fmt.Sprintf("IdentityManager(%s)", im.name)
} }

View file

@ -0,0 +1,20 @@
{{ range $i, $e := .Scripts -}}
{{ if eq .Export "*" }}
{{- printf "import %s as Script%d from %q;" .Export $i .Import -}}
{{ else -}}
{{- printf "import { %s as Script%d } from %q;" .Export $i .Import -}}
{{ end -}}
{{ end -}}
{{ range $i, $e := .Runners }}
{{- printf "import { %s as Run%d } from %q;" .Export $i .Import -}}
{{ end -}}
{{ if .Runners -}}
let group = { id: "{{ $.ID }}", scripts: [] }
{{ range $i, $e := .Scripts -}}
group.scripts.push({{ .RunnerJSON $i }});
{{ end -}}
{{ range $i, $e := .Runners -}}
{{ $id := printf "Run%d" $i }}
{{ $id }}(group);
{{ end -}}
{{ end -}}

1437
internal/js/esbuild/batch.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,686 @@
// Copyright 2024 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package js provides functions for building JavaScript resources
package esbuild_test
import (
"os"
"path/filepath"
"strings"
"testing"
qt "github.com/frankban/quicktest"
"github.com/bep/logg"
"github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/hugolib"
"github.com/gohugoio/hugo/internal/js/esbuild"
)
// Used to test misc. error situations etc.
const jsBatchFilesTemplate = `
-- hugo.toml --
disableKinds = ["taxonomy", "term", "section"]
disableLiveReload = true
-- assets/js/styles.css --
body {
background-color: red;
}
-- assets/js/main.js --
import './styles.css';
import * as params from '@params';
import * as foo from 'mylib';
console.log("Hello, Main!");
console.log("params.p1", params.p1);
export default function Main() {};
-- assets/js/runner.js --
console.log("Hello, Runner!");
-- node_modules/mylib/index.js --
console.log("Hello, My Lib!");
-- layouts/shortcodes/hdx.html --
{{ $path := .Get "r" }}
{{ $r := or (.Page.Resources.Get $path) (resources.Get $path) }}
{{ $batch := (js.Batch "mybatch") }}
{{ $scriptID := $path | anchorize }}
{{ $instanceID := .Ordinal | string }}
{{ $group := .Page.RelPermalink | anchorize }}
{{ $params := .Params | default dict }}
{{ $export := .Get "export" | default "default" }}
{{ with $batch.Group $group }}
{{ with .Runner "create-elements" }}
{{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }}
{{ end }}
{{ with .Script $scriptID }}
{{ .SetOptions (dict
"resource" $r
"export" $export
"importContext" (slice $.Page)
)
}}
{{ end }}
{{ with .Instance $scriptID $instanceID }}
{{ .SetOptions (dict "params" $params) }}
{{ end }}
{{ end }}
hdx-instance: {{ $scriptID }}: {{ $instanceID }}|
-- layouts/_default/baseof.html --
Base.
{{ $batch := (js.Batch "mybatch") }}
{{ with $batch.Config }}
{{ .SetOptions (dict
"params" (dict "id" "config")
"sourceMap" ""
)
}}
{{ end }}
{{ with (templates.Defer (dict "key" "global")) }}
Defer:
{{ $batch := (js.Batch "mybatch") }}
{{ range $k, $v := $batch.Build.Groups }}
{{ range $kk, $vv := . -}}
{{ $k }}: {{ .RelPermalink }}
{{ end }}
{{ end -}}
{{ end }}
{{ block "main" . }}Main{{ end }}
End.
-- layouts/_default/single.html --
{{ define "main" }}
==> Single Template Content: {{ .Content }}$
{{ $batch := (js.Batch "mybatch") }}
{{ with $batch.Group "mygroup" }}
{{ with .Runner "run" }}
{{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }}
{{ end }}
{{ with .Script "main" }}
{{ .SetOptions (dict "resource" (resources.Get "js/main.js") "params" (dict "p1" "param-p1-main" )) }}
{{ end }}
{{ with .Instance "main" "i1" }}
{{ .SetOptions (dict "params" (dict "title" "Instance 1")) }}
{{ end }}
{{ end }}
{{ end }}
-- layouts/index.html --
{{ define "main" }}
Home.
{{ end }}
-- content/p1/index.md --
---
title: "P1"
---
Some content.
{{< hdx r="p1script.js" myparam="p1-param-1" >}}
{{< hdx r="p1script.js" myparam="p1-param-2" >}}
-- content/p1/p1script.js --
console.log("P1 Script");
`
// Just to verify that the above file setup works.
func TestBatchTemplateOKBuild(t *testing.T) {
b := hugolib.Test(t, jsBatchFilesTemplate, hugolib.TestOptWithOSFs())
b.AssertPublishDir("mybatch/mygroup.js", "mybatch/mygroup.css")
}
func TestBatchRemoveAllInGroup(t *testing.T) {
files := jsBatchFilesTemplate
b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs())
b.AssertFileContent("public/p1/index.html", "p1: /mybatch/p1.js")
b.EditFiles("content/p1/index.md", `
---
title: "P1"
---
Empty.
`)
b.Build()
b.AssertFileContent("public/p1/index.html", "! p1: /mybatch/p1.js")
// Add one script back.
b.EditFiles("content/p1/index.md", `
---
title: "P1"
---
{{< hdx r="p1script.js" myparam="p1-param-1-new" >}}
`)
b.Build()
b.AssertFileContent("public/mybatch/p1.js",
"p1-param-1-new",
"p1script.js")
}
func TestBatchEditInstance(t *testing.T) {
files := jsBatchFilesTemplate
b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs())
b.AssertFileContent("public/mybatch/mygroup.js", "Instance 1")
b.EditFileReplaceAll("layouts/_default/single.html", "Instance 1", "Instance 1 Edit").Build()
b.AssertFileContent("public/mybatch/mygroup.js", "Instance 1 Edit")
}
func TestBatchEditScriptParam(t *testing.T) {
files := jsBatchFilesTemplate
b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs())
b.AssertFileContent("public/mybatch/mygroup.js", "param-p1-main")
b.EditFileReplaceAll("layouts/_default/single.html", "param-p1-main", "param-p1-main-edited").Build()
b.AssertFileContent("public/mybatch/mygroup.js", "param-p1-main-edited")
}
func TestBatchErrorScriptResourceNotSet(t *testing.T) {
files := strings.Replace(jsBatchFilesTemplate, `(resources.Get "js/main.js")`, `(resources.Get "js/doesnotexist.js")`, 1)
b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, `error calling SetOptions: resource not set`)
}
func TestBatchSlashInBatchID(t *testing.T) {
files := strings.ReplaceAll(jsBatchFilesTemplate, `"mybatch"`, `"my/batch"`)
b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
b.Assert(err, qt.IsNil)
b.AssertPublishDir("my/batch/mygroup.js")
}
func TestBatchSourceMaps(t *testing.T) {
filesTemplate := `
-- hugo.toml --
disableKinds = ["taxonomy", "term", "section"]
disableLiveReload = true
-- assets/js/styles.css --
body {
background-color: red;
}
-- assets/js/main.js --
import * as foo from 'mylib';
console.log("Hello, Main!");
-- assets/js/runner.js --
console.log("Hello, Runner!");
-- node_modules/mylib/index.js --
console.log("Hello, My Lib!");
-- layouts/shortcodes/hdx.html --
{{ $path := .Get "r" }}
{{ $r := or (.Page.Resources.Get $path) (resources.Get $path) }}
{{ $batch := (js.Batch "mybatch") }}
{{ $scriptID := $path | anchorize }}
{{ $instanceID := .Ordinal | string }}
{{ $group := .Page.RelPermalink | anchorize }}
{{ $params := .Params | default dict }}
{{ $export := .Get "export" | default "default" }}
{{ with $batch.Group $group }}
{{ with .Runner "create-elements" }}
{{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }}
{{ end }}
{{ with .Script $scriptID }}
{{ .SetOptions (dict
"resource" $r
"export" $export
"importContext" (slice $.Page)
)
}}
{{ end }}
{{ with .Instance $scriptID $instanceID }}
{{ .SetOptions (dict "params" $params) }}
{{ end }}
{{ end }}
hdx-instance: {{ $scriptID }}: {{ $instanceID }}|
-- layouts/_default/baseof.html --
Base.
{{ $batch := (js.Batch "mybatch") }}
{{ with $batch.Config }}
{{ .SetOptions (dict
"params" (dict "id" "config")
"sourceMap" ""
)
}}
{{ end }}
{{ with (templates.Defer (dict "key" "global")) }}
Defer:
{{ $batch := (js.Batch "mybatch") }}
{{ range $k, $v := $batch.Build.Groups }}
{{ range $kk, $vv := . -}}
{{ $k }}: {{ .RelPermalink }}
{{ end }}
{{ end -}}
{{ end }}
{{ block "main" . }}Main{{ end }}
End.
-- layouts/_default/single.html --
{{ define "main" }}
==> Single Template Content: {{ .Content }}$
{{ $batch := (js.Batch "mybatch") }}
{{ with $batch.Group "mygroup" }}
{{ with .Runner "run" }}
{{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }}
{{ end }}
{{ with .Script "main" }}
{{ .SetOptions (dict "resource" (resources.Get "js/main.js") "params" (dict "p1" "param-p1-main" )) }}
{{ end }}
{{ with .Instance "main" "i1" }}
{{ .SetOptions (dict "params" (dict "title" "Instance 1")) }}
{{ end }}
{{ end }}
{{ end }}
-- layouts/index.html --
{{ define "main" }}
Home.
{{ end }}
-- content/p1/index.md --
---
title: "P1"
---
Some content.
{{< hdx r="p1script.js" myparam="p1-param-1" >}}
{{< hdx r="p1script.js" myparam="p1-param-2" >}}
-- content/p1/p1script.js --
import * as foo from 'mylib';
console.lg("Foo", foo);
console.log("P1 Script");
export default function P1Script() {};
`
files := strings.Replace(filesTemplate, `"sourceMap" ""`, `"sourceMap" "linked"`, 1)
b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs())
b.AssertFileContent("public/mybatch/mygroup.js.map", "main.js", "! ns-hugo")
b.AssertFileContent("public/mybatch/mygroup.js", "sourceMappingURL=mygroup.js.map")
b.AssertFileContent("public/mybatch/p1.js", "sourceMappingURL=p1.js.map")
b.AssertFileContent("public/mybatch/mygroup_run_runner.js", "sourceMappingURL=mygroup_run_runner.js.map")
b.AssertFileContent("public/mybatch/chunk-UQKPPNA6.js", "sourceMappingURL=chunk-UQKPPNA6.js.map")
checkMap := func(p string, expectLen int) {
s := b.FileContent(p)
sources := esbuild.SourcesFromSourceMap(s)
b.Assert(sources, qt.HasLen, expectLen)
// Check that all source files exist.
for _, src := range sources {
filename, ok := paths.UrlStringToFilename(src)
b.Assert(ok, qt.IsTrue)
_, err := os.Stat(filename)
b.Assert(err, qt.IsNil)
}
}
checkMap("public/mybatch/mygroup.js.map", 1)
checkMap("public/mybatch/p1.js.map", 1)
checkMap("public/mybatch/mygroup_run_runner.js.map", 0)
checkMap("public/mybatch/chunk-UQKPPNA6.js.map", 1)
}
func TestBatchErrorRunnerResourceNotSet(t *testing.T) {
files := strings.Replace(jsBatchFilesTemplate, `(resources.Get "js/runner.js")`, `(resources.Get "js/doesnotexist.js")`, 1)
b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, `resource not set`)
}
func TestBatchErrorScriptResourceInAssetsSyntaxError(t *testing.T) {
// Introduce JS syntax error in assets/js/main.js
files := strings.Replace(jsBatchFilesTemplate, `console.log("Hello, Main!");`, `console.log("Hello, Main!"`, 1)
b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`assets/js/main.js:5:0": Expected ")" but found "console"`))
}
func TestBatchErrorScriptResourceInBundleSyntaxError(t *testing.T) {
// Introduce JS syntax error in content/p1/p1script.js
files := strings.Replace(jsBatchFilesTemplate, `console.log("P1 Script");`, `console.log("P1 Script"`, 1)
b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`/content/p1/p1script.js:3:0": Expected ")" but found end of file`))
}
func TestBatch(t *testing.T) {
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term"]
disableLiveReload = true
baseURL = "https://example.com"
-- package.json --
{
"devDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
}
-- assets/js/shims/react.js --
-- assets/js/shims/react-dom.js --
module.exports = window.ReactDOM;
module.exports = window.React;
-- content/mybundle/index.md --
---
title: "My Bundle"
---
-- content/mybundle/mybundlestyles.css --
@import './foo.css';
@import './bar.css';
@import './otherbundlestyles.css';
.mybundlestyles {
background-color: blue;
}
-- content/mybundle/bundlereact.jsx --
import * as React from "react";
import './foo.css';
import './mybundlestyles.css';
window.React1 = React;
let text = 'Click me, too!'
export default function MyBundleButton() {
return (
<button>${text}</button>
)
}
-- assets/js/reactrunner.js --
import * as ReactDOM from 'react-dom/client';
import * as React from 'react';
export default function Run(group) {
for (const module of group.scripts) {
for (const instance of module.instances) {
/* This is a convention in this project. */
let elId = §§${module.id}-${instance.id}§§;
let el = document.getElementById(elId);
if (!el) {
console.warn(§§Element with id ${elId} not found§§);
continue;
}
const root = ReactDOM.createRoot(el);
const reactEl = React.createElement(module.mod, instance.params);
root.render(reactEl);
}
}
}
-- assets/other/otherbundlestyles.css --
.otherbundlestyles {
background-color: red;
}
-- assets/other/foo.css --
@import './bar.css';
.foo {
background-color: blue;
}
-- assets/other/bar.css --
.bar {
background-color: red;
}
-- assets/js/button.css --
button {
background-color: red;
}
-- assets/js/bar.css --
.bar-assets {
background-color: red;
}
-- assets/js/helper.js --
import './bar.css'
export function helper() {
console.log('helper');
}
-- assets/js/react1styles_nested.css --
.react1styles_nested {
background-color: red;
}
-- assets/js/react1styles.css --
@import './react1styles_nested.css';
.react1styles {
background-color: red;
}
-- assets/js/react1.jsx --
import * as React from "react";
import './button.css'
import './foo.css'
import './react1styles.css'
window.React1 = React;
let text = 'Click me'
export default function MyButton() {
return (
<button>${text}</button>
)
}
-- assets/js/react2.jsx --
import * as React from "react";
import { helper } from './helper.js'
import './foo.css'
window.React2 = React;
let text = 'Click me, too!'
export function MyOtherButton() {
return (
<button>${text}</button>
)
}
-- assets/js/main1.js --
import * as React from "react";
import * as params from '@params';
console.log('main1.React', React)
console.log('main1.params.id', params.id)
-- assets/js/main2.js --
import * as React from "react";
import * as params from '@params';
console.log('main2.React', React)
console.log('main2.params.id', params.id)
export default function Main2() {};
-- assets/js/main3.js --
import * as React from "react";
import * as params from '@params';
import * as config from '@params/config';
console.log('main3.params.id', params.id)
console.log('config.params.id', config.id)
export default function Main3() {};
-- layouts/_default/single.html --
Single.
{{ $r := .Resources.GetMatch "*.jsx" }}
{{ $batch := (js.Batch "mybundle") }}
{{ $otherCSS := (resources.Match "/other/*.css").Mount "/other" "." }}
{{ with $batch.Config }}
{{ $shims := dict "react" "js/shims/react.js" "react-dom/client" "js/shims/react-dom.js" }}
{{ .SetOptions (dict
"target" "es2018"
"params" (dict "id" "config")
"shims" $shims
)
}}
{{ end }}
{{ with $batch.Group "reactbatch" }}
{{ with .Script "r3" }}
{{ .SetOptions (dict
"resource" $r
"importContext" (slice $ $otherCSS)
"params" (dict "id" "r3")
)
}}
{{ end }}
{{ with .Instance "r3" "r2i1" }}
{{ .SetOptions (dict "title" "r2 instance 1")}}
{{ end }}
{{ end }}
-- layouts/index.html --
Home.
{{ with (templates.Defer (dict "key" "global")) }}
{{ $batch := (js.Batch "mybundle") }}
{{ range $k, $v := $batch.Build.Groups }}
{{ range $kk, $vv := . }}
{{ $k }}: {{ $kk }}: {{ .RelPermalink }}
{{ end }}
{{ end }}
{{ end }}
{{ $myContentBundle := site.GetPage "mybundle" }}
{{ $batch := (js.Batch "mybundle") }}
{{ $otherCSS := (resources.Match "/other/*.css").Mount "/other" "." }}
{{ with $batch.Group "mains" }}
{{ with .Script "main1" }}
{{ .SetOptions (dict
"resource" (resources.Get "js/main1.js")
"params" (dict "id" "main1")
)
}}
{{ end }}
{{ with .Script "main2" }}
{{ .SetOptions (dict
"resource" (resources.Get "js/main2.js")
"params" (dict "id" "main2")
)
}}
{{ end }}
{{ with .Script "main3" }}
{{ .SetOptions (dict
"resource" (resources.Get "js/main3.js")
)
}}
{{ end }}
{{ with .Instance "main1" "m1i1" }}{{ .SetOptions (dict "params" (dict "title" "Main1 Instance 1"))}}{{ end }}
{{ with .Instance "main1" "m1i2" }}{{ .SetOptions (dict "params" (dict "title" "Main1 Instance 2"))}}{{ end }}
{{ end }}
{{ with $batch.Group "reactbatch" }}
{{ with .Runner "reactrunner" }}
{{ .SetOptions ( dict "resource" (resources.Get "js/reactrunner.js") )}}
{{ end }}
{{ with .Script "r1" }}
{{ .SetOptions (dict
"resource" (resources.Get "js/react1.jsx")
"importContext" (slice $myContentBundle $otherCSS)
"params" (dict "id" "r1")
)
}}
{{ end }}
{{ with .Instance "r1" "i1" }}{{ .SetOptions (dict "params" (dict "title" "Instance 1"))}}{{ end }}
{{ with .Instance "r1" "i2" }}{{ .SetOptions (dict "params" (dict "title" "Instance 2"))}}{{ end }}
{{ with .Script "r2" }}
{{ .SetOptions (dict
"resource" (resources.Get "js/react2.jsx")
"export" "MyOtherButton"
"importContext" $otherCSS
"params" (dict "id" "r2")
)
}}
{{ end }}
{{ with .Instance "r2" "i1" }}{{ .SetOptions (dict "params" (dict "title" "Instance 2-1"))}}{{ end }}
{{ end }}
`
b := hugolib.NewIntegrationTestBuilder(
hugolib.IntegrationTestConfig{
T: t,
NeedsOsFS: true,
NeedsNpmInstall: true,
TxtarString: files,
Running: true,
LogLevel: logg.LevelWarn,
// PrintAndKeepTempDir: true,
}).Build()
b.AssertFileContent("public/index.html",
"mains: 0: /mybundle/mains.js",
"reactbatch: 2: /mybundle/reactbatch.css",
)
b.AssertFileContent("public/mybundle/reactbatch.css",
".bar {",
)
// Verify params resolution.
b.AssertFileContent("public/mybundle/mains.js",
`
var id = "main1";
console.log("main1.params.id", id);
var id2 = "main2";
console.log("main2.params.id", id2);
# Params from top level config.
var id3 = "config";
console.log("main3.params.id", void 0);
console.log("config.params.id", id3);
`)
b.EditFileReplaceAll("content/mybundle/mybundlestyles.css", ".mybundlestyles", ".mybundlestyles-edit").Build()
b.AssertFileContent("public/mybundle/reactbatch.css", ".mybundlestyles-edit {")
b.EditFileReplaceAll("assets/other/bar.css", ".bar {", ".bar-edit {").Build()
b.AssertFileContent("public/mybundle/reactbatch.css", ".bar-edit {")
b.EditFileReplaceAll("assets/other/bar.css", ".bar-edit {", ".bar-edit2 {").Build()
b.AssertFileContent("public/mybundle/reactbatch.css", ".bar-edit2 {")
}
func TestEditBaseofManyTimes(t *testing.T) {
files := `
-- hugo.toml --
baseURL = "https://example.com"
disableLiveReload = true
disableKinds = ["taxonomy", "term"]
-- layouts/_default/baseof.html --
Baseof.
{{ block "main" . }}{{ end }}
{{ with (templates.Defer (dict "key" "global")) }}
Now. {{ now }}
{{ end }}
-- layouts/_default/single.html --
{{ define "main" }}
Single.
{{ end }}
--
-- layouts/_default/list.html --
{{ define "main" }}
List.
{{ end }}
-- content/mybundle/index.md --
---
title: "My Bundle"
---
-- content/_index.md --
---
title: "Home"
---
`
b := hugolib.TestRunning(t, files)
b.AssertFileContent("public/index.html", "Baseof.")
for i := 0; i < 100; i++ {
b.EditFileReplaceAll("layouts/_default/baseof.html", "Now", "Now.").Build()
b.AssertFileContent("public/index.html", "Now..")
}
}

View file

@ -0,0 +1,236 @@
// Copyright 2024 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package esbuild provides functions for building JavaScript resources.
package esbuild
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/evanw/esbuild/pkg/api"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/common/hugio"
"github.com/gohugoio/hugo/common/text"
"github.com/gohugoio/hugo/hugofs"
"github.com/gohugoio/hugo/hugolib/filesystems"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/resources"
)
// NewBuildClient creates a new BuildClient.
func NewBuildClient(fs *filesystems.SourceFilesystem, rs *resources.Spec) *BuildClient {
return &BuildClient{
rs: rs,
sfs: fs,
}
}
// BuildClient is a client for building JavaScript resources using esbuild.
type BuildClient struct {
rs *resources.Spec
sfs *filesystems.SourceFilesystem
}
// Build builds the given JavaScript resources using esbuild with the given options.
func (c *BuildClient) Build(opts Options) (api.BuildResult, error) {
dependencyManager := opts.DependencyManager
if dependencyManager == nil {
dependencyManager = identity.NopManager
}
opts.OutDir = c.rs.AbsPublishDir
opts.ResolveDir = c.rs.Cfg.BaseConfig().WorkingDir // where node_modules gets resolved
opts.AbsWorkingDir = opts.ResolveDir
opts.TsConfig = c.rs.ResolveJSConfigFile("tsconfig.json")
assetsResolver := newFSResolver(c.rs.Assets.Fs)
if err := opts.validate(); err != nil {
return api.BuildResult{}, err
}
if err := opts.compile(); err != nil {
return api.BuildResult{}, err
}
var err error
opts.compiled.Plugins, err = createBuildPlugins(c.rs, assetsResolver, dependencyManager, opts)
if err != nil {
return api.BuildResult{}, err
}
if opts.Inject != nil {
// Resolve the absolute filenames.
for i, ext := range opts.Inject {
impPath := filepath.FromSlash(ext)
if filepath.IsAbs(impPath) {
return api.BuildResult{}, fmt.Errorf("inject: absolute paths not supported, must be relative to /assets")
}
m := assetsResolver.resolveComponent(impPath)
if m == nil {
return api.BuildResult{}, fmt.Errorf("inject: file %q not found", ext)
}
opts.Inject[i] = m.Filename
}
opts.compiled.Inject = opts.Inject
}
result := api.Build(opts.compiled)
if len(result.Errors) > 0 {
createErr := func(msg api.Message) error {
if msg.Location == nil {
return errors.New(msg.Text)
}
var (
contentr hugio.ReadSeekCloser
errorMessage string
loc = msg.Location
errorPath = loc.File
err error
)
var resolvedError *ErrorMessageResolved
if opts.ErrorMessageResolveFunc != nil {
resolvedError = opts.ErrorMessageResolveFunc(msg)
}
if resolvedError == nil {
if errorPath == stdinImporter {
errorPath = opts.StdinSourcePath
}
errorMessage = msg.Text
var namespace string
for _, ns := range hugoNamespaces {
if strings.HasPrefix(errorPath, ns) {
namespace = ns
break
}
}
if namespace != "" {
namespace += ":"
errorMessage = strings.ReplaceAll(errorMessage, namespace, "")
errorPath = strings.TrimPrefix(errorPath, namespace)
contentr, err = hugofs.Os.Open(errorPath)
} else {
var fi os.FileInfo
fi, err = c.sfs.Fs.Stat(errorPath)
if err == nil {
m := fi.(hugofs.FileMetaInfo).Meta()
errorPath = m.Filename
contentr, err = m.Open()
}
}
} else {
contentr = resolvedError.Content
errorPath = resolvedError.Path
errorMessage = resolvedError.Message
}
if contentr != nil {
defer contentr.Close()
}
if err == nil {
fe := herrors.
NewFileErrorFromName(errors.New(errorMessage), errorPath).
UpdatePosition(text.Position{Offset: -1, LineNumber: loc.Line, ColumnNumber: loc.Column}).
UpdateContent(contentr, nil)
return fe
}
return fmt.Errorf("%s", errorMessage)
}
var errors []error
for _, msg := range result.Errors {
errors = append(errors, createErr(msg))
}
// Return 1, log the rest.
for i, err := range errors {
if i > 0 {
c.rs.Logger.Errorf("js.Build failed: %s", err)
}
}
return result, errors[0]
}
inOutputPathToAbsFilename := opts.ResolveSourceMapSource
opts.ResolveSourceMapSource = func(s string) string {
if inOutputPathToAbsFilename != nil {
if filename := inOutputPathToAbsFilename(s); filename != "" {
return filename
}
}
if m := assetsResolver.resolveComponent(s); m != nil {
return m.Filename
}
return ""
}
for i, o := range result.OutputFiles {
if err := fixOutputFile(&o, func(s string) string {
if s == "<stdin>" {
return opts.ResolveSourceMapSource(opts.StdinSourcePath)
}
var isNsHugo bool
if strings.HasPrefix(s, "ns-hugo") {
isNsHugo = true
idxColon := strings.Index(s, ":")
s = s[idxColon+1:]
}
if !strings.HasPrefix(s, PrefixHugoVirtual) {
if !filepath.IsAbs(s) {
s = filepath.Join(opts.OutDir, s)
}
}
if isNsHugo {
if ss := opts.ResolveSourceMapSource(s); ss != "" {
if strings.HasPrefix(ss, PrefixHugoMemory) {
// File not on disk, mark it for removal from the sources slice.
return ""
}
return ss
}
return ""
}
return s
}); err != nil {
return result, err
}
result.OutputFiles[i] = o
}
return result, nil
}

View file

@ -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"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. // you may not use this file except in compliance with the License.
@ -11,4 +11,5 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package js // Package esbuild provides functions for building JavaScript resources.
package esbuild

View file

@ -0,0 +1,375 @@
// Copyright 2024 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package esbuild
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/gohugoio/hugo/common/hugio"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/identity"
"github.com/evanw/esbuild/pkg/api"
"github.com/gohugoio/hugo/media"
"github.com/mitchellh/mapstructure"
)
var (
nameTarget = map[string]api.Target{
"": api.ESNext,
"esnext": api.ESNext,
"es5": api.ES5,
"es6": api.ES2015,
"es2015": api.ES2015,
"es2016": api.ES2016,
"es2017": api.ES2017,
"es2018": api.ES2018,
"es2019": api.ES2019,
"es2020": api.ES2020,
"es2021": api.ES2021,
"es2022": api.ES2022,
"es2023": api.ES2023,
}
// source names: https://github.com/evanw/esbuild/blob/9eca46464ed5615cb36a3beb3f7a7b9a8ffbe7cf/internal/config/config.go#L208
nameLoader = map[string]api.Loader{
"none": api.LoaderNone,
"base64": api.LoaderBase64,
"binary": api.LoaderBinary,
"copy": api.LoaderFile,
"css": api.LoaderCSS,
"dataurl": api.LoaderDataURL,
"default": api.LoaderDefault,
"empty": api.LoaderEmpty,
"file": api.LoaderFile,
"global-css": api.LoaderGlobalCSS,
"js": api.LoaderJS,
"json": api.LoaderJSON,
"jsx": api.LoaderJSX,
"local-css": api.LoaderLocalCSS,
"text": api.LoaderText,
"ts": api.LoaderTS,
"tsx": api.LoaderTSX,
}
)
// DecodeExternalOptions decodes the given map into ExternalOptions.
func DecodeExternalOptions(m map[string]any) (ExternalOptions, error) {
opts := ExternalOptions{
SourcesContent: true,
}
if err := mapstructure.WeakDecode(m, &opts); err != nil {
return opts, err
}
if opts.TargetPath != "" {
opts.TargetPath = paths.ToSlashTrimLeading(opts.TargetPath)
}
opts.Target = strings.ToLower(opts.Target)
opts.Format = strings.ToLower(opts.Format)
return opts, nil
}
// ErrorMessageResolved holds a resolved error message.
type ErrorMessageResolved struct {
Path string
Message string
Content hugio.ReadSeekCloser
}
// ExternalOptions holds user facing options for the js.Build template function.
type ExternalOptions struct {
// If not set, the source path will be used as the base target path.
// Note that the target path's extension may change if the target MIME type
// is different, e.g. when the source is TypeScript.
TargetPath string
// Whether to minify to output.
Minify bool
// One of "inline", "external", "linked" or "none".
SourceMap string
SourcesContent bool
// The language target.
// One of: es2015, es2016, es2017, es2018, es2019, es2020 or esnext.
// Default is esnext.
Target string
// The output format.
// One of: iife, cjs, esm
// Default is to esm.
Format string
// External dependencies, e.g. "react".
Externals []string
// This option allows you to automatically replace a global variable with an import from another file.
// The filenames must be relative to /assets.
// See https://esbuild.github.io/api/#inject
Inject []string
// User defined symbols.
Defines map[string]any
// Maps a component import to another.
Shims map[string]string
// Configuring a loader for a given file type lets you load that file type with an
// import statement or a require call. For example, configuring the .png file extension
// to use the data URL loader means importing a .png file gives you a data URL
// containing the contents of that image
//
// See https://esbuild.github.io/api/#loader
Loaders map[string]string
// User defined params. Will be marshaled to JSON and available as "@params", e.g.
// import * as params from '@params';
Params any
// What to use instead of React.createElement.
JSXFactory string
// What to use instead of React.Fragment.
JSXFragment string
// What to do about JSX syntax.
// See https://esbuild.github.io/api/#jsx
JSX string
// Which library to use to automatically import JSX helper functions from. Only works if JSX is set to automatic.
// See https://esbuild.github.io/api/#jsx-import-source
JSXImportSource string
// There is/was a bug in WebKit with severe performance issue with the tracking
// of TDZ checks in JavaScriptCore.
//
// Enabling this flag removes the TDZ and `const` assignment checks and
// may improve performance of larger JS codebases until the WebKit fix
// is in widespread use.
//
// See https://bugs.webkit.org/show_bug.cgi?id=199866
// Deprecated: This no longer have any effect and will be removed.
// TODO(bep) remove. See https://github.com/evanw/esbuild/commit/869e8117b499ca1dbfc5b3021938a53ffe934dba
AvoidTDZ bool
}
// InternalOptions holds internal options for the js.Build template function.
type InternalOptions struct {
MediaType media.Type
OutDir string
Contents string
SourceDir string
ResolveDir string
AbsWorkingDir string
Metafile bool
StdinSourcePath string
DependencyManager identity.Manager
Stdin bool // Set to true to pass in the entry point as a byte slice.
Splitting bool
TsConfig string
EntryPoints []string
ImportOnResolveFunc func(string, api.OnResolveArgs) string
ImportOnLoadFunc func(api.OnLoadArgs) string
ImportParamsOnLoadFunc func(args api.OnLoadArgs) json.RawMessage
ErrorMessageResolveFunc func(api.Message) *ErrorMessageResolved
ResolveSourceMapSource func(string) string // Used to resolve paths in error source maps.
}
// Options holds the options passed to Build.
type Options struct {
ExternalOptions
InternalOptions
compiled api.BuildOptions
}
func (opts *Options) compile() (err error) {
target, found := nameTarget[opts.Target]
if !found {
err = fmt.Errorf("invalid target: %q", opts.Target)
return
}
var loaders map[string]api.Loader
if opts.Loaders != nil {
loaders = make(map[string]api.Loader)
for k, v := range opts.Loaders {
loader, found := nameLoader[v]
if !found {
err = fmt.Errorf("invalid loader: %q", v)
return
}
loaders[k] = loader
}
}
mediaType := opts.MediaType
if mediaType.IsZero() {
mediaType = media.Builtin.JavascriptType
}
var loader api.Loader
switch mediaType.SubType {
case media.Builtin.JavascriptType.SubType:
loader = api.LoaderJS
case media.Builtin.TypeScriptType.SubType:
loader = api.LoaderTS
case media.Builtin.TSXType.SubType:
loader = api.LoaderTSX
case media.Builtin.JSXType.SubType:
loader = api.LoaderJSX
default:
err = fmt.Errorf("unsupported Media Type: %q", opts.MediaType)
return
}
var format api.Format
// One of: iife, cjs, esm
switch opts.Format {
case "", "iife":
format = api.FormatIIFE
case "esm":
format = api.FormatESModule
case "cjs":
format = api.FormatCommonJS
default:
err = fmt.Errorf("unsupported script output format: %q", opts.Format)
return
}
var jsx api.JSX
switch opts.JSX {
case "", "transform":
jsx = api.JSXTransform
case "preserve":
jsx = api.JSXPreserve
case "automatic":
jsx = api.JSXAutomatic
default:
err = fmt.Errorf("unsupported jsx type: %q", opts.JSX)
return
}
var defines map[string]string
if opts.Defines != nil {
defines = maps.ToStringMapString(opts.Defines)
}
// By default we only need to specify outDir and no outFile
outDir := opts.OutDir
outFile := ""
var sourceMap api.SourceMap
switch opts.SourceMap {
case "inline":
sourceMap = api.SourceMapInline
case "external":
sourceMap = api.SourceMapExternal
case "linked":
sourceMap = api.SourceMapLinked
case "", "none":
sourceMap = api.SourceMapNone
default:
err = fmt.Errorf("unsupported sourcemap type: %q", opts.SourceMap)
return
}
sourcesContent := api.SourcesContentInclude
if !opts.SourcesContent {
sourcesContent = api.SourcesContentExclude
}
opts.compiled = api.BuildOptions{
Outfile: outFile,
Bundle: true,
Metafile: opts.Metafile,
AbsWorkingDir: opts.AbsWorkingDir,
Target: target,
Format: format,
Sourcemap: sourceMap,
SourcesContent: sourcesContent,
Loader: loaders,
MinifyWhitespace: opts.Minify,
MinifyIdentifiers: opts.Minify,
MinifySyntax: opts.Minify,
Outdir: outDir,
Splitting: opts.Splitting,
Define: defines,
External: opts.Externals,
JSXFactory: opts.JSXFactory,
JSXFragment: opts.JSXFragment,
JSX: jsx,
JSXImportSource: opts.JSXImportSource,
Tsconfig: opts.TsConfig,
EntryPoints: opts.EntryPoints,
}
if opts.Stdin {
// This makes ESBuild pass `stdin` as the Importer to the import.
opts.compiled.Stdin = &api.StdinOptions{
Contents: opts.Contents,
ResolveDir: opts.ResolveDir,
Loader: loader,
}
}
return
}
func (o Options) loaderFromFilename(filename string) api.Loader {
ext := filepath.Ext(filename)
if optsLoaders := o.compiled.Loader; optsLoaders != nil {
if l, found := optsLoaders[ext]; found {
return l
}
}
l, found := extensionToLoaderMap[ext]
if found {
return l
}
return api.LoaderJS
}
func (opts *Options) validate() error {
if opts.ImportOnResolveFunc != nil && opts.ImportOnLoadFunc == nil {
return fmt.Errorf("ImportOnLoadFunc must be set if ImportOnResolveFunc is set")
}
if opts.ImportOnResolveFunc == nil && opts.ImportOnLoadFunc != nil {
return fmt.Errorf("ImportOnResolveFunc must be set if ImportOnLoadFunc is set")
}
if opts.AbsWorkingDir == "" {
return fmt.Errorf("AbsWorkingDir must be set")
}
return nil
}

View file

@ -0,0 +1,219 @@
// Copyright 2024 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package esbuild
import (
"testing"
"github.com/gohugoio/hugo/media"
"github.com/evanw/esbuild/pkg/api"
qt "github.com/frankban/quicktest"
)
func TestToBuildOptions(t *testing.T) {
c := qt.New(t)
opts := Options{
InternalOptions: InternalOptions{
MediaType: media.Builtin.JavascriptType,
Stdin: true,
},
}
c.Assert(opts.compile(), qt.IsNil)
c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{
Bundle: true,
Target: api.ESNext,
Format: api.FormatIIFE,
SourcesContent: 1,
Stdin: &api.StdinOptions{
Loader: api.LoaderJS,
},
})
opts = Options{
ExternalOptions: ExternalOptions{
Target: "es2018",
Format: "cjs",
Minify: true,
AvoidTDZ: true,
},
InternalOptions: InternalOptions{
MediaType: media.Builtin.JavascriptType,
Stdin: true,
},
}
c.Assert(opts.compile(), qt.IsNil)
c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{
Bundle: true,
Target: api.ES2018,
Format: api.FormatCommonJS,
SourcesContent: 1,
MinifyIdentifiers: true,
MinifySyntax: true,
MinifyWhitespace: true,
Stdin: &api.StdinOptions{
Loader: api.LoaderJS,
},
})
opts = Options{
ExternalOptions: ExternalOptions{
Target: "es2018", Format: "cjs", Minify: true,
SourceMap: "inline",
},
InternalOptions: InternalOptions{
MediaType: media.Builtin.JavascriptType,
Stdin: true,
},
}
c.Assert(opts.compile(), qt.IsNil)
c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{
Bundle: true,
Target: api.ES2018,
Format: api.FormatCommonJS,
MinifyIdentifiers: true,
MinifySyntax: true,
MinifyWhitespace: true,
SourcesContent: 1,
Sourcemap: api.SourceMapInline,
Stdin: &api.StdinOptions{
Loader: api.LoaderJS,
},
})
opts = Options{
ExternalOptions: ExternalOptions{
Target: "es2018", Format: "cjs", Minify: true,
SourceMap: "inline",
},
InternalOptions: InternalOptions{
MediaType: media.Builtin.JavascriptType,
Stdin: true,
},
}
c.Assert(opts.compile(), qt.IsNil)
c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{
Bundle: true,
Target: api.ES2018,
Format: api.FormatCommonJS,
MinifyIdentifiers: true,
MinifySyntax: true,
MinifyWhitespace: true,
Sourcemap: api.SourceMapInline,
SourcesContent: 1,
Stdin: &api.StdinOptions{
Loader: api.LoaderJS,
},
})
opts = Options{
ExternalOptions: ExternalOptions{
Target: "es2018", Format: "cjs", Minify: true,
SourceMap: "external",
},
InternalOptions: InternalOptions{
MediaType: media.Builtin.JavascriptType,
Stdin: true,
},
}
c.Assert(opts.compile(), qt.IsNil)
c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{
Bundle: true,
Target: api.ES2018,
Format: api.FormatCommonJS,
MinifyIdentifiers: true,
MinifySyntax: true,
MinifyWhitespace: true,
Sourcemap: api.SourceMapExternal,
SourcesContent: 1,
Stdin: &api.StdinOptions{
Loader: api.LoaderJS,
},
})
opts = Options{
ExternalOptions: ExternalOptions{
JSX: "automatic", JSXImportSource: "preact",
},
InternalOptions: InternalOptions{
MediaType: media.Builtin.JavascriptType,
Stdin: true,
},
}
c.Assert(opts.compile(), qt.IsNil)
c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{
Bundle: true,
Target: api.ESNext,
Format: api.FormatIIFE,
SourcesContent: 1,
Stdin: &api.StdinOptions{
Loader: api.LoaderJS,
},
JSX: api.JSXAutomatic,
JSXImportSource: "preact",
})
}
func TestToBuildOptionsTarget(t *testing.T) {
c := qt.New(t)
for _, test := range []struct {
target string
expect api.Target
}{
{"es2015", api.ES2015},
{"es2016", api.ES2016},
{"es2017", api.ES2017},
{"es2018", api.ES2018},
{"es2019", api.ES2019},
{"es2020", api.ES2020},
{"es2021", api.ES2021},
{"es2022", api.ES2022},
{"es2023", api.ES2023},
{"", api.ESNext},
{"esnext", api.ESNext},
} {
c.Run(test.target, func(c *qt.C) {
opts := Options{
ExternalOptions: ExternalOptions{
Target: test.target,
},
InternalOptions: InternalOptions{
MediaType: media.Builtin.JavascriptType,
},
}
c.Assert(opts.compile(), qt.IsNil)
c.Assert(opts.compiled.Target, qt.Equals, test.expect)
})
}
}
func TestDecodeExternalOptions(t *testing.T) {
c := qt.New(t)
m := map[string]any{}
opts, err := DecodeExternalOptions(m)
c.Assert(err, qt.IsNil)
c.Assert(opts, qt.DeepEquals, ExternalOptions{
SourcesContent: true,
})
}

View file

@ -0,0 +1,315 @@
// Copyright 2024 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package esbuild
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/evanw/esbuild/pkg/api"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/hugofs"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/resources"
"github.com/gohugoio/hugo/resources/resource"
"github.com/spf13/afero"
)
const (
NsHugoImport = "ns-hugo-imp"
NsHugoImportResolveFunc = "ns-hugo-imp-func"
nsHugoParams = "ns-hugo-params"
pathHugoConfigParams = "@params/config"
stdinImporter = "<stdin>"
)
var hugoNamespaces = []string{NsHugoImport, NsHugoImportResolveFunc, nsHugoParams}
const (
PrefixHugoVirtual = "__hu_v"
PrefixHugoMemory = "__hu_m"
)
var extensionToLoaderMap = map[string]api.Loader{
".js": api.LoaderJS,
".mjs": api.LoaderJS,
".cjs": api.LoaderJS,
".jsx": api.LoaderJSX,
".ts": api.LoaderTS,
".tsx": api.LoaderTSX,
".css": api.LoaderCSS,
".json": api.LoaderJSON,
".txt": api.LoaderText,
}
// This is a common sub-set of ESBuild's default extensions.
// We assume that imports of JSON, CSS etc. will be using their full
// name with extension.
var commonExtensions = []string{".js", ".ts", ".tsx", ".jsx"}
// ResolveComponent resolves a component using the given resolver.
func ResolveComponent[T any](impPath string, resolve func(string) (v T, found, isDir bool)) (v T, found bool) {
findFirst := func(base string) (v T, found, isDir bool) {
for _, ext := range commonExtensions {
if strings.HasSuffix(impPath, ext) {
// Import of foo.js.js need the full name.
continue
}
if v, found, isDir = resolve(base + ext); found {
return
}
}
// Not found.
return
}
// We need to check if this is a regular file imported without an extension.
// There may be ambiguous situations where both foo.js and foo/index.js exists.
// This import order is in line with both how Node and ESBuild's native
// import resolver works.
// It may be a regular file imported without an extension, e.g.
// foo or foo/index.
v, found, _ = findFirst(impPath)
if found {
return v, found
}
base := filepath.Base(impPath)
if base == "index" {
// try index.esm.js etc.
v, found, _ = findFirst(impPath + ".esm")
if found {
return v, found
}
}
// Check the path as is.
var isDir bool
v, found, isDir = resolve(impPath)
if found && isDir {
v, found, _ = findFirst(filepath.Join(impPath, "index"))
if !found {
v, found, _ = findFirst(filepath.Join(impPath, "index.esm"))
}
}
if !found && strings.HasSuffix(base, ".js") {
v, found, _ = findFirst(strings.TrimSuffix(impPath, ".js"))
}
return
}
// ResolveResource resolves a resource using the given resourceGetter.
func ResolveResource(impPath string, resourceGetter resource.ResourceGetter) (r resource.Resource) {
resolve := func(name string) (v resource.Resource, found, isDir bool) {
r := resourceGetter.Get(name)
return r, r != nil, false
}
r, found := ResolveComponent(impPath, resolve)
if !found {
return nil
}
return r
}
func newFSResolver(fs afero.Fs) *fsResolver {
return &fsResolver{fs: fs, resolved: maps.NewCache[string, *hugofs.FileMeta]()}
}
type fsResolver struct {
fs afero.Fs
resolved *maps.Cache[string, *hugofs.FileMeta]
}
func (r *fsResolver) resolveComponent(impPath string) *hugofs.FileMeta {
v, _ := r.resolved.GetOrCreate(impPath, func() (*hugofs.FileMeta, error) {
resolve := func(name string) (*hugofs.FileMeta, bool, bool) {
if fi, err := r.fs.Stat(name); err == nil {
return fi.(hugofs.FileMetaInfo).Meta(), true, fi.IsDir()
}
return nil, false, false
}
v, _ := ResolveComponent(impPath, resolve)
return v, nil
})
return v
}
func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsManager identity.Manager, opts Options) ([]api.Plugin, error) {
fs := rs.Assets
resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
impPath := args.Path
shimmed := false
if opts.Shims != nil {
override, found := opts.Shims[impPath]
if found {
impPath = override
shimmed = true
}
}
if opts.ImportOnResolveFunc != nil {
if s := opts.ImportOnResolveFunc(impPath, args); s != "" {
return api.OnResolveResult{Path: s, Namespace: NsHugoImportResolveFunc}, nil
}
}
importer := args.Importer
isStdin := importer == stdinImporter
var relDir string
if !isStdin {
if strings.HasPrefix(importer, PrefixHugoVirtual) {
relDir = filepath.Dir(strings.TrimPrefix(importer, PrefixHugoVirtual))
} else {
rel, found := fs.MakePathRelative(importer, true)
if !found {
if shimmed {
relDir = opts.SourceDir
} else {
// Not in any of the /assets folders.
// This is an import from a node_modules, let
// ESBuild resolve this.
return api.OnResolveResult{}, nil
}
} else {
relDir = filepath.Dir(rel)
}
}
} else {
relDir = opts.SourceDir
}
// Imports not starting with a "." is assumed to live relative to /assets.
// Hugo makes no assumptions about the directory structure below /assets.
if relDir != "" && strings.HasPrefix(impPath, ".") {
impPath = filepath.Join(relDir, impPath)
}
m := assetsResolver.resolveComponent(impPath)
if m != nil {
depsManager.AddIdentity(m.PathInfo)
// Store the source root so we can create a jsconfig.json
// to help IntelliSense when the build is done.
// This should be a small number of elements, and when
// in server mode, we may get stale entries on renames etc.,
// but that shouldn't matter too much.
rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)
return api.OnResolveResult{Path: m.Filename, Namespace: NsHugoImport}, nil
}
// Fall back to ESBuild's resolve.
return api.OnResolveResult{}, nil
}
importResolver := api.Plugin{
Name: "hugo-import-resolver",
Setup: func(build api.PluginBuild) {
build.OnResolve(api.OnResolveOptions{Filter: `.*`},
func(args api.OnResolveArgs) (api.OnResolveResult, error) {
return resolveImport(args)
})
build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: NsHugoImport},
func(args api.OnLoadArgs) (api.OnLoadResult, error) {
b, err := os.ReadFile(args.Path)
if err != nil {
return api.OnLoadResult{}, fmt.Errorf("failed to read %q: %w", args.Path, err)
}
c := string(b)
return api.OnLoadResult{
// See https://github.com/evanw/esbuild/issues/502
// This allows all modules to resolve dependencies
// in the main project's node_modules.
ResolveDir: opts.ResolveDir,
Contents: &c,
Loader: opts.loaderFromFilename(args.Path),
}, nil
})
build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: NsHugoImportResolveFunc},
func(args api.OnLoadArgs) (api.OnLoadResult, error) {
c := opts.ImportOnLoadFunc(args)
if c == "" {
return api.OnLoadResult{}, fmt.Errorf("ImportOnLoadFunc failed to resolve %q", args.Path)
}
return api.OnLoadResult{
ResolveDir: opts.ResolveDir,
Contents: &c,
Loader: opts.loaderFromFilename(args.Path),
}, nil
})
},
}
params := opts.Params
if params == nil {
// This way @params will always resolve to something.
params = make(map[string]any)
}
b, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("failed to marshal params: %w", err)
}
paramsPlugin := api.Plugin{
Name: "hugo-params-plugin",
Setup: func(build api.PluginBuild) {
build.OnResolve(api.OnResolveOptions{Filter: `^@params(/config)?$`},
func(args api.OnResolveArgs) (api.OnResolveResult, error) {
resolvedPath := args.Importer
if args.Path == pathHugoConfigParams {
resolvedPath = pathHugoConfigParams
}
return api.OnResolveResult{
Path: resolvedPath,
Namespace: nsHugoParams,
}, nil
})
build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsHugoParams},
func(args api.OnLoadArgs) (api.OnLoadResult, error) {
bb := b
if args.Path != pathHugoConfigParams && opts.ImportParamsOnLoadFunc != nil {
bb = opts.ImportParamsOnLoadFunc(args)
}
s := string(bb)
if s == "" {
s = "{}"
}
return api.OnLoadResult{
Contents: &s,
Loader: api.LoaderJSON,
}, nil
})
},
}
return []api.Plugin{importResolver, paramsPlugin}, nil
}

View file

@ -0,0 +1,86 @@
// Copyright 2024 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package esbuild
import (
"path"
"path/filepath"
"testing"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/config"
"github.com/gohugoio/hugo/config/testconfig"
"github.com/gohugoio/hugo/hugofs"
"github.com/gohugoio/hugo/hugolib/filesystems"
"github.com/gohugoio/hugo/hugolib/paths"
"github.com/spf13/afero"
)
func TestResolveComponentInAssets(t *testing.T) {
c := qt.New(t)
for _, test := range []struct {
name string
files []string
impPath string
expect string
}{
{"Basic, extension", []string{"foo.js", "bar.js"}, "foo.js", "foo.js"},
{"Basic, no extension", []string{"foo.js", "bar.js"}, "foo", "foo.js"},
{"Basic, no extension, typescript", []string{"foo.ts", "bar.js"}, "foo", "foo.ts"},
{"Not found", []string{"foo.js", "bar.js"}, "moo.js", ""},
{"Not found, double js extension", []string{"foo.js.js", "bar.js"}, "foo.js", ""},
{"Index file, folder only", []string{"foo/index.js", "bar.js"}, "foo", "foo/index.js"},
{"Index file, folder and index", []string{"foo/index.js", "bar.js"}, "foo/index", "foo/index.js"},
{"Index file, folder and index and suffix", []string{"foo/index.js", "bar.js"}, "foo/index.js", "foo/index.js"},
{"Index ESM file, folder only", []string{"foo/index.esm.js", "bar.js"}, "foo", "foo/index.esm.js"},
{"Index ESM file, folder and index", []string{"foo/index.esm.js", "bar.js"}, "foo/index", "foo/index.esm.js"},
{"Index ESM file, folder and index and suffix", []string{"foo/index.esm.js", "bar.js"}, "foo/index.esm.js", "foo/index.esm.js"},
// We added these index.esm.js cases in v0.101.0. The case below is unlikely to happen in the wild, but add a test
// to document Hugo's behavior. We pick the file with the name index.js; anything else would be breaking.
{"Index and Index ESM file, folder only", []string{"foo/index.esm.js", "foo/index.js", "bar.js"}, "foo", "foo/index.js"},
// Issue #8949
{"Check file before directory", []string{"foo.js", "foo/index.js"}, "foo", "foo.js"},
} {
c.Run(test.name, func(c *qt.C) {
baseDir := "assets"
mfs := afero.NewMemMapFs()
for _, filename := range test.files {
c.Assert(afero.WriteFile(mfs, filepath.Join(baseDir, filename), []byte("let foo='bar';"), 0o777), qt.IsNil)
}
conf := testconfig.GetTestConfig(mfs, config.New())
fs := hugofs.NewFrom(mfs, conf.BaseConfig())
p, err := paths.New(fs, conf)
c.Assert(err, qt.IsNil)
bfs, err := filesystems.NewBase(p, nil)
c.Assert(err, qt.IsNil)
resolver := newFSResolver(bfs.Assets.Fs)
got := resolver.resolveComponent(test.impPath)
gotPath := ""
expect := test.expect
if got != nil {
gotPath = filepath.ToSlash(got.Filename)
expect = path.Join(baseDir, test.expect)
}
c.Assert(gotPath, qt.Equals, expect)
})
}
}

View file

@ -0,0 +1,80 @@
// Copyright 2024 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package esbuild
import (
"encoding/json"
"strings"
"github.com/evanw/esbuild/pkg/api"
"github.com/gohugoio/hugo/common/paths"
)
type sourceMap struct {
Version int `json:"version"`
Sources []string `json:"sources"`
SourcesContent []string `json:"sourcesContent"`
Mappings string `json:"mappings"`
Names []string `json:"names"`
}
func fixOutputFile(o *api.OutputFile, resolve func(string) string) error {
if strings.HasSuffix(o.Path, ".map") {
b, err := fixSourceMap(o.Contents, resolve)
if err != nil {
return err
}
o.Contents = b
}
return nil
}
func fixSourceMap(s []byte, resolve func(string) string) ([]byte, error) {
var sm sourceMap
if err := json.Unmarshal([]byte(s), &sm); err != nil {
return nil, err
}
sm.Sources = fixSourceMapSources(sm.Sources, resolve)
b, err := json.Marshal(sm)
if err != nil {
return nil, err
}
return b, nil
}
func fixSourceMapSources(s []string, resolve func(string) string) []string {
var result []string
for _, src := range s {
if s := resolve(src); s != "" {
// Absolute filenames works fine on U*ix (tested in Chrome on MacOs), but works very poorly on Windows (again Chrome).
// So, convert it to a URL.
if u, err := paths.UrlFromFilename(s); err == nil {
result = append(result, u.String())
}
}
}
return result
}
// Used in tests.
func SourcesFromSourceMap(s string) []string {
var sm sourceMap
if err := json.Unmarshal([]byte(s), &sm); err != nil {
return nil
}
return sm.Sources
}

View file

@ -36,7 +36,7 @@ type Init struct {
prev *Init prev *Init
children []*Init children []*Init
init onceMore init OnceMore
out any out any
err error err error
f func(context.Context) (any, error) f func(context.Context) (any, error)

View file

@ -24,13 +24,13 @@ import (
// * it can be reset, so the action can be repeated if needed // * it can be reset, so the action can be repeated if needed
// * it has methods to check if it's done or in progress // * it has methods to check if it's done or in progress
type onceMore struct { type OnceMore struct {
mu sync.Mutex mu sync.Mutex
lock uint32 lock uint32
done uint32 done uint32
} }
func (t *onceMore) Do(f func()) { func (t *OnceMore) Do(f func()) {
if atomic.LoadUint32(&t.done) == 1 { if atomic.LoadUint32(&t.done) == 1 {
return return
} }
@ -53,15 +53,15 @@ func (t *onceMore) Do(f func()) {
f() f()
} }
func (t *onceMore) InProgress() bool { func (t *OnceMore) InProgress() bool {
return atomic.LoadUint32(&t.lock) == 1 return atomic.LoadUint32(&t.lock) == 1
} }
func (t *onceMore) Done() bool { func (t *OnceMore) Done() bool {
return atomic.LoadUint32(&t.done) == 1 return atomic.LoadUint32(&t.done) == 1
} }
func (t *onceMore) ResetWithLock() *sync.Mutex { func (t *OnceMore) ResetWithLock() *sync.Mutex {
t.mu.Lock() t.mu.Lock()
defer atomic.StoreUint32(&t.done, 0) defer atomic.StoreUint32(&t.done, 0)
return &t.mu return &t.mu

View file

@ -273,9 +273,13 @@ func (t Types) GetByType(tp string) (Type, bool) {
return Type{}, false 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. // BySuffix will return all media types matching a suffix.
func (t Types) BySuffix(suffix string) []Type { func (t Types) BySuffix(suffix string) []Type {
suffix = strings.ToLower(suffix) suffix = t.normalizeSuffix(suffix)
var types []Type var types []Type
for _, tt := range t { for _, tt := range t {
if tt.hasSuffix(suffix) { 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. // GetFirstBySuffix will return the first type matching the given suffix.
func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) { func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) {
suffix = strings.ToLower(suffix) suffix = t.normalizeSuffix(suffix)
for _, tt := range t { for _, tt := range t {
if tt.hasSuffix(suffix) { if tt.hasSuffix(suffix) {
return tt, SuffixInfo{ return tt, SuffixInfo{
@ -304,7 +308,7 @@ func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) {
// is ambiguous. // is ambiguous.
// The lookup is case insensitive. // The lookup is case insensitive.
func (t Types) GetBySuffix(suffix string) (tp Type, si SuffixInfo, found bool) { func (t Types) GetBySuffix(suffix string) (tp Type, si SuffixInfo, found bool) {
suffix = strings.ToLower(suffix) suffix = t.normalizeSuffix(suffix)
for _, tt := range t { for _, tt := range t {
if tt.hasSuffix(suffix) { if tt.hasSuffix(suffix) {
if found { 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 { func (t Types) IsTextSuffix(suffix string) bool {
suffix = strings.ToLower(suffix) suffix = t.normalizeSuffix(suffix)
for _, tt := range t { for _, tt := range t {
if tt.hasSuffix(suffix) { if tt.hasSuffix(suffix) {
return tt.IsText() return tt.IsText()

View file

@ -51,6 +51,7 @@ var (
_ resource.Source = (*imageResource)(nil) _ resource.Source = (*imageResource)(nil)
_ resource.Cloner = (*imageResource)(nil) _ resource.Cloner = (*imageResource)(nil)
_ resource.NameNormalizedProvider = (*imageResource)(nil) _ resource.NameNormalizedProvider = (*imageResource)(nil)
_ targetPathProvider = (*imageResource)(nil)
) )
// imageResource represents an image resource. // imageResource represents an image resource.
@ -160,6 +161,10 @@ func (i *imageResource) Colors() ([]images.Color, error) {
return i.dominantColors, nil return i.dominantColors, nil
} }
func (i *imageResource) targetPath() string {
return i.TargetPath()
}
// Clone is for internal use. // Clone is for internal use.
func (i *imageResource) Clone() resource.Resource { func (i *imageResource) Clone() resource.Resource {
gr := i.baseResource.Clone().(baseResource) gr := i.baseResource.Clone().(baseResource)

View file

@ -63,8 +63,7 @@ type ChildCareProvider interface {
// section. // section.
RegularPagesRecursive() Pages RegularPagesRecursive() Pages
// Resources returns a list of all resources. resource.ResourcesProvider
Resources() resource.Resources
} }
type MarkupProvider interface { type MarkupProvider interface {

View file

@ -47,6 +47,8 @@ var (
_ resource.Cloner = (*genericResource)(nil) _ resource.Cloner = (*genericResource)(nil)
_ resource.ResourcesLanguageMerger = (*resource.Resources)(nil) _ resource.ResourcesLanguageMerger = (*resource.Resources)(nil)
_ resource.Identifier = (*genericResource)(nil) _ resource.Identifier = (*genericResource)(nil)
_ targetPathProvider = (*genericResource)(nil)
_ sourcePathProvider = (*genericResource)(nil)
_ identity.IdentityGroupProvider = (*genericResource)(nil) _ identity.IdentityGroupProvider = (*genericResource)(nil)
_ identity.DependencyManagerProvider = (*genericResource)(nil) _ identity.DependencyManagerProvider = (*genericResource)(nil)
_ identity.Identity = (*genericResource)(nil) _ identity.Identity = (*genericResource)(nil)
@ -79,6 +81,7 @@ type ResourceSourceDescriptor struct {
TargetPath string TargetPath string
BasePathRelPermalink string BasePathRelPermalink string
BasePathTargetPath string BasePathTargetPath string
SourceFilenameOrPath string // Used for error logging.
// The Data to associate with this resource. // The Data to associate with this resource.
Data map[string]any Data map[string]any
@ -463,6 +466,17 @@ func (l *genericResource) Key() string {
return key return key
} }
func (l *genericResource) targetPath() string {
return l.paths.TargetPath()
}
func (l *genericResource) sourcePath() string {
if p := l.sd.SourceFilenameOrPath; p != "" {
return p
}
return ""
}
func (l *genericResource) MediaType() media.Type { func (l *genericResource) MediaType() media.Type {
return l.sd.MediaType return l.sd.MediaType
} }
@ -660,3 +674,43 @@ func (r *resourceHash) init(l hugio.ReadSeekCloserProvider) error {
func hashImage(r io.ReadSeeker) (uint64, int64, error) { func hashImage(r io.ReadSeeker) (uint64, int64, error) {
return hashing.XXHashFromReader(r) return hashing.XXHashFromReader(r)
} }
// InternalResourceTargetPath is used internally to get the target path for a Resource.
func InternalResourceTargetPath(r resource.Resource) string {
return r.(targetPathProvider).targetPath()
}
// InternalResourceSourcePathBestEffort is used internally to get the source path for a Resource.
// It returns an empty string if the source path is not available.
func InternalResourceSourcePath(r resource.Resource) string {
if sp, ok := r.(sourcePathProvider); ok {
if p := sp.sourcePath(); p != "" {
return p
}
}
return ""
}
// InternalResourceSourcePathBestEffort is used internally to get the source path for a Resource.
// Used for error messages etc.
// It will fall back to the target path if the source path is not available.
func InternalResourceSourcePathBestEffort(r resource.Resource) string {
if s := InternalResourceSourcePath(r); s != "" {
return s
}
return InternalResourceTargetPath(r)
}
type targetPathProvider interface {
// targetPath 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.
targetPath() string
}
// Optional interface implemented by resources that can provide the source path.
type sourcePathProvider interface {
// sourcePath is the source path to this resource's source.
// This is used in error messages etc.
sourcePath() string
}

View file

@ -14,10 +14,11 @@
package resource package resource
import ( import (
"github.com/gohugoio/hugo/common/maps"
"strings" "strings"
"time" "time"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/helpers"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"

View file

@ -16,8 +16,11 @@ package resource
import ( import (
"fmt" "fmt"
"path"
"strings" "strings"
"github.com/gohugoio/hugo/common/hreflect"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/hugofs/glob" "github.com/gohugoio/hugo/hugofs/glob"
"github.com/spf13/cast" "github.com/spf13/cast"
@ -29,6 +32,51 @@ var _ ResourceFinder = (*Resources)(nil)
// I.e. both pages and images etc. // I.e. both pages and images etc.
type Resources []Resource type Resources []Resource
// Mount mounts the given resources from base to the given target path.
// Note that leading slashes in target marks an absolute path.
// This method is currently only useful in js.Batch.
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))
}
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) // var _ resource.ResourceFinder = (*Namespace)(nil)
// ResourcesConverter converts a given slice of Resource objects to Resources. // ResourcesConverter converts a given slice of Resource objects to Resources.
type ResourcesConverter interface { type ResourcesConverter interface {
@ -63,13 +111,25 @@ func (r Resources) Get(name any) Resource {
panic(err) panic(err)
} }
isDotCurrent := strings.HasPrefix(namestr, "./")
if isDotCurrent {
namestr = strings.TrimPrefix(namestr, "./")
} else {
namestr = paths.AddLeadingSlash(namestr) namestr = paths.AddLeadingSlash(namestr)
}
check := func(name string) bool {
if !isDotCurrent {
name = paths.AddLeadingSlash(name)
}
return strings.EqualFold(namestr, name)
}
// First check the Name. // First check the Name.
// Note that this can be modified by the user in the front matter, // Note that this can be modified by the user in the front matter,
// also, it does not contain any language code. // also, it does not contain any language code.
for _, resource := range r { for _, resource := range r {
if strings.EqualFold(namestr, paths.AddLeadingSlash(resource.Name())) { if check(resource.Name()) {
return resource return resource
} }
} }
@ -77,7 +137,7 @@ func (r Resources) Get(name any) Resource {
// Finally, check the normalized name. // Finally, check the normalized name.
for _, resource := range r { for _, resource := range r {
if nop, ok := resource.(NameNormalizedProvider); ok { if nop, ok := resource.(NameNormalizedProvider); ok {
if strings.EqualFold(namestr, paths.AddLeadingSlash(nop.NameNormalized())) { if check(nop.NameNormalized()) {
return resource return resource
} }
} }
@ -197,14 +257,35 @@ type Source interface {
Publish() error Publish() error
} }
// ResourceFinder provides methods to find Resources. type ResourceGetter interface {
// 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 {
// Get locates the Resource with the given name in the current context (e.g. in .Page.Resources). // 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. // It returns nil if no Resource could found, panics if name is invalid.
Get(name any) Resource 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. // GetMatch finds the first Resource matching the given pattern, or nil if none found.
// //
@ -235,3 +316,92 @@ type ResourceFinder interface {
// It returns nil if no Resources could found, panics if typ is invalid. // It returns nil if no Resources could found, panics if typ is invalid.
ByType(typ any) Resources 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
c.cache.ForEeach(func(k string, v Resource) bool {
if v != other.Get(k) {
isProbablyEq = false
return false
}
return true
})
return 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
default:
vvv, ok := hreflect.ToSliceAny(v)
if !ok {
return nil, false
}
var getters multiResourceGetter
for _, vv := range vvv {
if g, ok := unwrapResourceGetter(vv); ok {
getters = append(getters, g)
}
}
return getters, len(getters) > 0
}
}

View file

@ -0,0 +1,105 @@
// 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_test
import (
"testing"
"github.com/gohugoio/hugo/hugolib"
)
func TestResourcesMount(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|
`)
}
func TestResourcesMountOnRename(t *testing.T) {
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term", "home", "sitemap"]
-- content/mybundle/index.md --
---
title: "My Bundle"
resources:
- name: /foo/bars.txt
src: foo/txt1.txt
- name: foo/bars2.txt
src: foo/txt2.txt
---
-- content/mybundle/foo/txt1.txt --
Text 1.
-- content/mybundle/foo/txt2.txt --
Text 2.
-- layouts/_default/single.html --
Single.
{{ $mybundle := site.GetPage "mybundle" }}
Resources:{{ range $mybundle.Resources }}Name: {{ .Name }}|{{ end }}$
{{ $subResourcesMount := $mybundle.Resources.Mount "/foo" "/newroot" }}
{{ $subResourcesMount2 := $mybundle.Resources.Mount "foo" "/newroot" }}
{{ $subResourcesMount3 := $mybundle.Resources.Mount "foo" "." }}
subResourcesMount:/newroot/bars.txt:{{ with $subResourcesMount.Get "/newroot/bars.txt" }}{{ .Name }}{{ end }}|
subResourcesMount:/newroot/bars2.txt:{{ with $subResourcesMount.Get "/newroot/bars2.txt" }}{{ .Name }}{{ end }}|
subResourcesMount2:/newroot/bars2.txt:{{ with $subResourcesMount2.Get "/newroot/bars2.txt" }}{{ .Name }}{{ end }}|
subResourcesMount3:bars2.txt:{{ with $subResourcesMount3.Get "bars2.txt" }}{{ .Name }}{{ end }}|
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/mybundle/index.html",
"Resources:Name: foo/bars.txt|Name: foo/bars2.txt|$",
"subResourcesMount:/newroot/bars.txt:|\nsubResourcesMount:/newroot/bars2.txt:|",
"subResourcesMount2:/newroot/bars2.txt:foo/bars2.txt|",
"subResourcesMount3:bars2.txt:foo/bars2.txt|",
)
}

View file

@ -0,0 +1,122 @@
// 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"},
}
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")
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
}

View file

@ -143,7 +143,8 @@ func (c *Client) Get(pathname string) (resource.Resource, error) {
return nil, err return nil, err
} }
pi := fi.(hugofs.FileMetaInfo).Meta().PathInfo meta := fi.(hugofs.FileMetaInfo).Meta()
pi := meta.PathInfo
return c.rs.NewResource(resources.ResourceSourceDescriptor{ return c.rs.NewResource(resources.ResourceSourceDescriptor{
LazyPublish: true, LazyPublish: true,
@ -153,6 +154,7 @@ func (c *Client) Get(pathname string) (resource.Resource, error) {
Path: pi, Path: pi,
GroupIdentity: pi, GroupIdentity: pi,
TargetPath: pathname, TargetPath: pathname,
SourceFilenameOrPath: meta.Filename,
}) })
}) })
} }
@ -200,6 +202,7 @@ func (c *Client) match(name, pattern string, matchFunc func(r resource.Resource)
NameOriginal: meta.PathInfo.Unnormalized().Path(), NameOriginal: meta.PathInfo.Unnormalized().Path(),
GroupIdentity: meta.PathInfo, GroupIdentity: meta.PathInfo,
TargetPath: meta.PathInfo.Unnormalized().Path(), TargetPath: meta.PathInfo.Unnormalized().Path(),
SourceFilenameOrPath: meta.Filename,
}) })
if err != nil { if err != nil {
return true, err return true, err

View file

@ -15,6 +15,7 @@ package resources
import ( import (
"fmt" "fmt"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@ -26,6 +27,7 @@ import (
"github.com/spf13/cast" "github.com/spf13/cast"
"github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/common/paths"
) )
var ( var (
@ -172,6 +174,8 @@ func assignMetadata(metadata []map[string]any, ma *metaResource) error {
name, found := meta["name"] name, found := meta["name"]
if found { if found {
name := cast.ToString(name) name := cast.ToString(name)
// Bundled resources in sub folders are relative paths with forward slashes. Make sure any renames also matches that format:
name = paths.TrimLeading(filepath.ToSlash(name))
if !nameCounterFound { if !nameCounterFound {
nameCounterFound = strings.Contains(name, counterPlaceHolder) nameCounterFound = strings.Contains(name, counterPlaceHolder)
} }

View file

@ -16,8 +16,26 @@ package resources
import ( import (
"os" "os"
"testing" "testing"
qt "github.com/frankban/quicktest"
) )
func TestAtomicStaler(t *testing.T) {
c := qt.New(t)
type test struct {
AtomicStaler
}
var v test
c.Assert(v.StaleVersion(), qt.Equals, uint32(0))
v.MarkStale()
c.Assert(v.StaleVersion(), qt.Equals, uint32(1))
v.MarkStale()
c.Assert(v.StaleVersion(), qt.Equals, uint32(2))
}
func BenchmarkHashImage(b *testing.B) { func BenchmarkHashImage(b *testing.B) {
f, err := os.Open("testdata/sunset.jpg") f, err := os.Open("testdata/sunset.jpg")
if err != nil { if err != nil {

View file

@ -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"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. // you may not use this file except in compliance with the License.
@ -14,209 +14,69 @@
package js package js
import ( import (
"errors"
"fmt"
"io"
"os"
"path" "path"
"path/filepath"
"regexp" "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/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"
"github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/resources/resource"
) )
// Client context for ESBuild. // Client context for ESBuild.
type Client struct { type Client struct {
rs *resources.Spec c *esbuild.BuildClient
sfs *filesystems.SourceFilesystem
} }
// New creates a new client context. // New creates a new client context.
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client { func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client {
return &Client{ return &Client{
rs: rs, c: esbuild.NewBuildClient(fs, rs),
sfs: fs,
} }
} }
type buildTransformation struct { // Process processes a resource with the user provided options.
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
func (c *Client) Process(res resources.ResourceTransformer, opts map[string]any) (resource.Resource, error) { func (c *Client) Process(res resources.ResourceTransformer, opts map[string]any) (resource.Resource, error) {
return res.Transform( return res.Transform(
&buildTransformation{c: c, optsm: opts}, &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
}
opts.StdinSourcePath = transformCtx.SourcePath
result, err := c.c.Build(opts)
if err != nil {
return result, err
}
if opts.ExternalOptions.SourceMap == "linked" || opts.ExternalOptions.SourceMap == "external" {
content := string(result.OutputFiles[1].Contents)
if opts.ExternalOptions.SourceMap == "linked" {
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
}

View file

@ -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"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. // you may not use this file except in compliance with the License.
@ -14,13 +14,16 @@
package js_test package js_test
import ( import (
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
qt "github.com/frankban/quicktest" qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/htesting" "github.com/gohugoio/hugo/htesting"
"github.com/gohugoio/hugo/hugolib" "github.com/gohugoio/hugo/hugolib"
"github.com/gohugoio/hugo/internal/js/esbuild"
) )
func TestBuildVariants(t *testing.T) { func TestBuildVariants(t *testing.T) {
@ -173,7 +176,7 @@ hello:
hello: hello:
other: "Bonjour" other: "Bonjour"
-- layouts/index.html -- -- layouts/index.html --
{{ $options := dict "minify" false "externals" (slice "react" "react-dom") }} {{ $options := dict "minify" false "externals" (slice "react" "react-dom") "sourcemap" "linked" }}
{{ $js := resources.Get "js/main.js" | js.Build $options }} {{ $js := resources.Get "js/main.js" | js.Build $options }}
JS: {{ template "print" $js }} JS: {{ template "print" $js }}
{{ $jsx := resources.Get "js/myjsx.jsx" | js.Build $options }} {{ $jsx := resources.Get "js/myjsx.jsx" | js.Build $options }}
@ -201,14 +204,31 @@ TS2: {{ template "print" $ts2 }}
TxtarString: files, TxtarString: files,
}).Build() }).Build()
b.AssertFileContent("public/js/myts.js", `//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJz`) b.AssertFileContent("public/js/main.js", `//# sourceMappingURL=main.js.map`)
b.AssertFileContent("public/js/myts2.js.map", `"version": 3,`) b.AssertFileContent("public/js/main.js.map", `"version":3`, "! ns-hugo") // linked
b.AssertFileContent("public/js/myts.js", `//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJz`) // inline
b.AssertFileContent("public/index.html", ` b.AssertFileContent("public/index.html", `
console.log(&#34;included&#34;); console.log(&#34;included&#34;);
if (hasSpace.test(string)) if (hasSpace.test(string))
var React = __toESM(__require(&#34;react&#34;)); var React = __toESM(__require(&#34;react&#34;));
function greeter(person) { function greeter(person) {
`) `)
checkMap := func(p string, expectLen int) {
s := b.FileContent(p)
sources := esbuild.SourcesFromSourceMap(s)
b.Assert(sources, qt.HasLen, expectLen)
// Check that all source files exist.
for _, src := range sources {
filename, ok := paths.UrlStringToFilename(src)
b.Assert(ok, qt.IsTrue)
_, err := os.Stat(filename)
b.Assert(err, qt.IsNil, qt.Commentf("src: %q", src))
}
}
checkMap("public/js/main.js.map", 4)
} }
func TestBuildError(t *testing.T) { func TestBuildError(t *testing.T) {

View file

@ -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 = "<stdin>"
)
// 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
}

View file

@ -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)
})
}
}

View file

@ -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
}

View file

@ -139,7 +139,7 @@ func (t importResolver) CanonicalizeURL(url string) (string, error) {
return url, nil return url, nil
} }
filePath, isURL := paths.UrlToFilename(url) filePath, isURL := paths.UrlStringToFilename(url)
var prevDir string var prevDir string
var pathDir string var pathDir string
if isURL { if isURL {
@ -195,7 +195,7 @@ func (t importResolver) Load(url string) (godartsass.Import, error) {
if url == sass.HugoVarsNamespace { if url == sass.HugoVarsNamespace {
return t.varsStylesheet, nil return t.varsStylesheet, nil
} }
filename, _ := paths.UrlToFilename(url) filename, _ := paths.UrlStringToFilename(url)
b, err := afero.ReadFile(hugofs.Os, filename) b, err := afero.ReadFile(hugofs.Os, filename)
sourceSyntax := godartsass.SourceSyntaxSCSS sourceSyntax := godartsass.SourceSyntaxSCSS

View file

@ -52,6 +52,8 @@ var (
_ identity.IdentityGroupProvider = (*resourceAdapterInner)(nil) _ identity.IdentityGroupProvider = (*resourceAdapterInner)(nil)
_ resource.Source = (*resourceAdapter)(nil) _ resource.Source = (*resourceAdapter)(nil)
_ resource.Identifier = (*resourceAdapter)(nil) _ resource.Identifier = (*resourceAdapter)(nil)
_ targetPathProvider = (*resourceAdapter)(nil)
_ sourcePathProvider = (*resourceAdapter)(nil)
_ resource.ResourceNameTitleProvider = (*resourceAdapter)(nil) _ resource.ResourceNameTitleProvider = (*resourceAdapter)(nil)
_ resource.WithResourceMetaProvider = (*resourceAdapter)(nil) _ resource.WithResourceMetaProvider = (*resourceAdapter)(nil)
_ identity.DependencyManagerProvider = (*resourceAdapter)(nil) _ identity.DependencyManagerProvider = (*resourceAdapter)(nil)
@ -277,6 +279,19 @@ func (r *resourceAdapter) Key() string {
return r.target.(resource.Identifier).Key() return r.target.(resource.Identifier).Key()
} }
func (r *resourceAdapter) targetPath() string {
r.init(false, false)
return r.target.(targetPathProvider).targetPath()
}
func (r *resourceAdapter) sourcePath() string {
r.init(false, false)
if sp, ok := r.target.(sourcePathProvider); ok {
return sp.sourcePath()
}
return ""
}
func (r *resourceAdapter) MediaType() media.Type { func (r *resourceAdapter) MediaType() media.Type {
r.init(false, false) r.init(false, false)
return r.target.MediaType() return r.target.MediaType()

View file

@ -41,7 +41,7 @@ func New(d *deps.Deps) *Namespace {
l := d.Log.InfoCommand("timer") l := d.Log.InfoCommand("timer")
d.BuildEndListeners.Add(func() { d.BuildEndListeners.Add(func(...any) bool {
type data struct { type data struct {
Name string Name string
Count int Count int
@ -84,6 +84,8 @@ func New(d *deps.Deps) *Namespace {
} }
ns.timers = make(map[string][]*timer) ns.timers = make(map[string][]*timer)
return false
}) })
return ns return ns

View file

@ -30,8 +30,9 @@ func New(d *deps.Deps) *Namespace {
logger: d.Log, logger: d.Log,
} }
d.BuildStartListeners.Add(func() { d.BuildStartListeners.Add(func(...any) bool {
ns.logger.Reset() ns.logger.Reset()
return false
}) })
return ns return ns

View file

@ -24,7 +24,10 @@ const name = "js"
func init() { func init() {
f := func(d *deps.Deps) *internal.TemplateFuncsNamespace { f := func(d *deps.Deps) *internal.TemplateFuncsNamespace {
ctx := New(d) ctx, err := New(d)
if err != nil {
panic(err)
}
ns := &internal.TemplateFuncsNamespace{ ns := &internal.TemplateFuncsNamespace{
Name: name, Name: name,

View file

@ -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"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. // you may not use this file except in compliance with the License.
@ -17,29 +17,47 @@ package js
import ( import (
"errors" "errors"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/deps"
"github.com/gohugoio/hugo/internal/js/esbuild"
"github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources"
"github.com/gohugoio/hugo/resources/resource" "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/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" "github.com/gohugoio/hugo/tpl/internal/resourcehelpers"
) )
// New returns a new instance of the js-namespaced template functions. // New returns a new instance of the js-namespaced template functions.
func New(deps *deps.Deps) *Namespace { func New(d *deps.Deps) (*Namespace, error) {
if deps.ResourceSpec == nil { if d.ResourceSpec == nil {
return &Namespace{} return &Namespace{}, nil
} }
batcherClient, err := esbuild.NewBatcherClient(d)
if err != nil {
return nil, err
}
return &Namespace{ return &Namespace{
client: js.New(deps.BaseFs.Assets, deps.ResourceSpec), d: d,
babelClient: babel.New(deps.ResourceSpec), jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec),
} jsBatcherClient: batcherClient,
jsBatcherStore: maps.NewCache[string, esbuild.Batcher](),
createClient: create.New(d.ResourceSpec),
babelClient: babel.New(d.ResourceSpec),
}, nil
} }
// Namespace provides template functions for the "js" namespace. // Namespace provides template functions for the "js" namespace.
type Namespace struct { type Namespace struct {
client *js.Client d *deps.Deps
jsTransformClient *jstransform.Client
createClient *create.Client
babelClient *babel.Client babelClient *babel.Client
jsBatcherClient *esbuild.BatcherClient
jsBatcherStore *maps.Cache[string, esbuild.Batcher]
} }
// Build processes the given Resource with ESBuild. // Build processes the given Resource with ESBuild.
@ -65,7 +83,24 @@ func (ns *Namespace) Build(args ...any) (resource.Resource, error) {
m = map[string]any{"targetPath": targetPath} m = map[string]any{"targetPath": targetPath}
} }
return ns.client.Process(r, m) return ns.jsTransformClient.Process(r, m)
}
// Batch creates a new Batcher with the given ID.
// Repeated calls with the same ID will return the same Batcher.
// The ID will be used to name the root directory of the batch.
// Forward slashes in the ID is allowed.
func (ns *Namespace) Batch(id string) (esbuild.Batcher, error) {
if err := esbuild.ValidateBatchID(id, true); err != nil {
return nil, err
}
b, err := ns.jsBatcherStore.GetOrCreate(id, func() (esbuild.Batcher, error) {
return ns.jsBatcherClient.New(id)
})
if err != nil {
return nil, err
}
return b, nil
} }
// Babel processes the given Resource with Babel. // Babel processes the given Resource with Babel.

View file

@ -81,8 +81,9 @@ func New(deps *deps.Deps) *Namespace {
cache := &partialCache{cache: lru} cache := &partialCache{cache: lru}
deps.BuildStartListeners.Add( deps.BuildStartListeners.Add(
func() { func(...any) bool {
cache.clear() cache.clear()
return false
}) })
return &Namespace{ return &Namespace{

View file

@ -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 }}

View file

@ -695,13 +695,13 @@ func (t *templateHandler) applyBaseTemplate(overlay, base templateInfo) (tpl.Tem
if !base.IsZero() { if !base.IsZero() {
templ, err = templ.Parse(base.template) templ, err = templ.Parse(base.template)
if err != nil { if err != nil {
return nil, base.errWithFileContext("parse failed", err) return nil, base.errWithFileContext("text: base: parse failed", err)
} }
} }
templ, err = texttemplate.Must(templ.Clone()).Parse(overlay.template) templ, err = texttemplate.Must(templ.Clone()).Parse(overlay.template)
if err != nil { if err != nil {
return nil, overlay.errWithFileContext("parse failed", err) return nil, overlay.errWithFileContext("text: overlay: parse failed", err)
} }
// The extra lookup is a workaround, see // The extra lookup is a workaround, see
@ -720,13 +720,13 @@ func (t *templateHandler) applyBaseTemplate(overlay, base templateInfo) (tpl.Tem
if !base.IsZero() { if !base.IsZero() {
templ, err = templ.Parse(base.template) templ, err = templ.Parse(base.template)
if err != nil { if err != nil {
return nil, base.errWithFileContext("parse failed", err) return nil, base.errWithFileContext("html: base: parse failed", err)
} }
} }
templ, err = htmltemplate.Must(templ.Clone()).Parse(overlay.template) templ, err = htmltemplate.Must(templ.Clone()).Parse(overlay.template)
if err != nil { if err != nil {
return nil, overlay.errWithFileContext("parse failed", err) return nil, overlay.errWithFileContext("html: overlay: parse failed", err)
} }
// The extra lookup is a workaround, see // The extra lookup is a workaround, see

View file

@ -251,6 +251,9 @@ func newTemplateExecuter(d *deps.Deps) (texttemplate.Executer, map[string]reflec
} }
func createFuncMap(d *deps.Deps) map[string]any { func createFuncMap(d *deps.Deps) map[string]any {
if d.TmplFuncMap != nil {
return d.TmplFuncMap
}
funcMap := template.FuncMap{} funcMap := template.FuncMap{}
nsMap := make(map[string]any) nsMap := make(map[string]any)
@ -292,5 +295,7 @@ func createFuncMap(d *deps.Deps) map[string]any {
} }
} }
return funcMap d.TmplFuncMap = funcMap
return d.TmplFuncMap
} }