Add js.Batch
Fixes #12626 Closes #7499 Closes #9978 Closes #12879 Closes #13113 Fixes #13116
This commit is contained in:
parent
157d86414d
commit
e293e7ca6d
61 changed files with 4520 additions and 1003 deletions
|
@ -920,7 +920,11 @@ func (c *hugoBuilder) handleEvents(watcher *watcher.Batcher,
|
|||
|
||||
changed := c.changeDetector.changed()
|
||||
if c.changeDetector != nil {
|
||||
lrl.Logf("build changed %d files", len(changed))
|
||||
if len(changed) >= 10 {
|
||||
lrl.Logf("build changed %d files", len(changed))
|
||||
} else {
|
||||
lrl.Logf("build changed %d files: %q", len(changed), changed)
|
||||
}
|
||||
if len(changed) == 0 {
|
||||
// Nothing has changed.
|
||||
return
|
||||
|
|
|
@ -32,6 +32,7 @@ import (
|
|||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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
|
||||
for _, v := range in {
|
||||
if !f.irrelevantRe.MatchString(v) {
|
||||
filtered = append(filtered, v)
|
||||
}
|
||||
}
|
||||
sort.Strings(filtered)
|
||||
return filtered
|
||||
}
|
||||
|
||||
|
|
|
@ -133,6 +133,21 @@ func IsNotExist(err error) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// IsExist returns true if the error is a file exists error.
|
||||
// Unlike os.IsExist, this also considers wrapped errors.
|
||||
func IsExist(err error) bool {
|
||||
if os.IsExist(err) {
|
||||
return true
|
||||
}
|
||||
|
||||
// os.IsExist does not consider wrapped errors.
|
||||
if os.IsExist(errors.Unwrap(err)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var nilPointerErrRe = regexp.MustCompile(`at <(.*)>: error calling (.*?): runtime error: invalid memory address or nil pointer dereference`)
|
||||
|
||||
const deferredPrefix = "__hdeferred/"
|
||||
|
|
|
@ -384,7 +384,7 @@ func extractPosition(e error) (pos text.Position) {
|
|||
case godartsass.SassError:
|
||||
span := v.Span
|
||||
start := span.Start
|
||||
filename, _ := paths.UrlToFilename(span.Url)
|
||||
filename, _ := paths.UrlStringToFilename(span.Url)
|
||||
pos.Filename = filename
|
||||
pos.Offset = start.Offset
|
||||
pos.ColumnNumber = start.Column
|
||||
|
|
|
@ -223,6 +223,27 @@ func AsTime(v reflect.Value, loc *time.Location) (time.Time, bool) {
|
|||
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 {
|
||||
fn := v.MethodByName(name)
|
||||
var args []reflect.Value
|
||||
|
|
|
@ -50,6 +50,19 @@ func TestIsContextType(t *testing.T) {
|
|||
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) {
|
||||
type k string
|
||||
b.Run("value", func(b *testing.B) {
|
||||
|
|
|
@ -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.
|
||||
func (c *Cache[K, T]) ForEeach(f func(K, T)) {
|
||||
// If the function returns false, the iteration stops.
|
||||
func (c *Cache[K, T]) ForEeach(f func(K, T) bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
for k, v := range c.m {
|
||||
f(k, v)
|
||||
if !f(k, v) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2021 The Hugo Authors. All rights reserved.
|
||||
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
@ -18,6 +18,7 @@ import (
|
|||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
@ -159,31 +160,6 @@ func Uglify(in string) string {
|
|||
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.
|
||||
func URLEscape(uri string) string {
|
||||
// escape unicode letters
|
||||
|
@ -193,3 +169,105 @@ func URLEscape(uri string) 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
|
||||
}
|
||||
|
|
|
@ -19,6 +19,13 @@ type Closer interface {
|
|||
Close() error
|
||||
}
|
||||
|
||||
// CloserFunc is a convenience type to create a Closer from a function.
|
||||
type CloserFunc func() error
|
||||
|
||||
func (f CloserFunc) Close() error {
|
||||
return f()
|
||||
}
|
||||
|
||||
type CloseAdder interface {
|
||||
Add(Closer)
|
||||
}
|
||||
|
|
|
@ -137,11 +137,11 @@ func (c ConfigLanguage) Watching() bool {
|
|||
return c.m.Base.Internal.Watch
|
||||
}
|
||||
|
||||
func (c ConfigLanguage) NewIdentityManager(name string) identity.Manager {
|
||||
func (c ConfigLanguage) NewIdentityManager(name string, opts ...identity.ManagerOption) identity.Manager {
|
||||
if !c.Watching() {
|
||||
return identity.NopManager
|
||||
}
|
||||
return identity.NewManager(name)
|
||||
return identity.NewManager(name, opts...)
|
||||
}
|
||||
|
||||
func (c ConfigLanguage) ContentTypes() config.ContentTypesProvider {
|
||||
|
|
|
@ -58,7 +58,7 @@ type AllProvider interface {
|
|||
BuildDrafts() bool
|
||||
Running() bool
|
||||
Watching() bool
|
||||
NewIdentityManager(name string) identity.Manager
|
||||
NewIdentityManager(name string, opts ...identity.ManagerOption) identity.Manager
|
||||
FastRenderMode() bool
|
||||
PrintUnusedTemplates() bool
|
||||
EnableMissingTranslationPlaceholders() bool
|
||||
|
|
51
deps/deps.go
vendored
51
deps/deps.go
vendored
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
@ -47,6 +48,9 @@ type Deps struct {
|
|||
// The templates to use. This will usually implement the full tpl.TemplateManager.
|
||||
tmplHandlers *tpl.TemplateHandlers
|
||||
|
||||
// The template funcs.
|
||||
TmplFuncMap map[string]any
|
||||
|
||||
// The file systems to use.
|
||||
Fs *hugofs.Fs `json:"-"`
|
||||
|
||||
|
@ -83,10 +87,13 @@ type Deps struct {
|
|||
Metrics metrics.Provider
|
||||
|
||||
// BuildStartListeners will be notified before a build starts.
|
||||
BuildStartListeners *Listeners
|
||||
BuildStartListeners *Listeners[any]
|
||||
|
||||
// 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.
|
||||
BuildClosers *types.Closers
|
||||
|
@ -154,17 +161,21 @@ func (d *Deps) Init() error {
|
|||
}
|
||||
|
||||
if d.BuildStartListeners == nil {
|
||||
d.BuildStartListeners = &Listeners{}
|
||||
d.BuildStartListeners = &Listeners[any]{}
|
||||
}
|
||||
|
||||
if d.BuildEndListeners == nil {
|
||||
d.BuildEndListeners = &Listeners{}
|
||||
d.BuildEndListeners = &Listeners[any]{}
|
||||
}
|
||||
|
||||
if d.BuildClosers == nil {
|
||||
d.BuildClosers = &types.Closers{}
|
||||
}
|
||||
|
||||
if d.OnChangeListeners == nil {
|
||||
d.OnChangeListeners = &Listeners[identity.Identity]{}
|
||||
}
|
||||
|
||||
if d.Metrics == nil && d.Conf.TemplateMetrics() {
|
||||
d.Metrics = metrics.NewProvider(d.Conf.TemplateMetricsHints())
|
||||
}
|
||||
|
@ -268,6 +279,23 @@ func (d *Deps) Compile(prototype *Deps) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// MkdirTemp returns a temporary directory path that will be cleaned up on exit.
|
||||
func (d Deps) MkdirTemp(pattern string) (string, error) {
|
||||
filename, err := os.MkdirTemp("", pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d.BuildClosers.Add(
|
||||
types.CloserFunc(
|
||||
func() error {
|
||||
return os.RemoveAll(filename)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
type globalErrHandler struct {
|
||||
logger loggers.Logger
|
||||
|
||||
|
@ -306,15 +334,16 @@ func (e *globalErrHandler) StopErrorCollector() {
|
|||
}
|
||||
|
||||
// Listeners represents an event listener.
|
||||
type Listeners struct {
|
||||
type Listeners[T any] struct {
|
||||
sync.Mutex
|
||||
|
||||
// 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.
|
||||
func (b *Listeners) Add(f func()) {
|
||||
func (b *Listeners[T]) Add(f func(...T) bool) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
|
@ -324,12 +353,16 @@ func (b *Listeners) Add(f func()) {
|
|||
}
|
||||
|
||||
// Notify executes all listener functions.
|
||||
func (b *Listeners) Notify() {
|
||||
func (b *Listeners[T]) Notify(vs ...T) {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
temp := b.listeners[:0]
|
||||
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.
|
||||
|
|
|
@ -1754,6 +1754,11 @@ func (sa *sitePagesAssembler) assembleResources() error {
|
|||
mt = rs.rc.ContentMediaType
|
||||
}
|
||||
|
||||
var filename string
|
||||
if rs.fi != nil {
|
||||
filename = rs.fi.Meta().Filename
|
||||
}
|
||||
|
||||
rd := resources.ResourceSourceDescriptor{
|
||||
OpenReadSeekCloser: rs.opener,
|
||||
Path: rs.path,
|
||||
|
@ -1762,6 +1767,7 @@ func (sa *sitePagesAssembler) assembleResources() error {
|
|||
TargetBasePaths: targetBasePaths,
|
||||
BasePathRelPermalink: targetPaths.SubResourceBaseLink,
|
||||
BasePathTargetPath: baseTarget,
|
||||
SourceFilenameOrPath: filename,
|
||||
NameNormalized: relPath,
|
||||
NameOriginal: relPathOriginal,
|
||||
MediaType: mt,
|
||||
|
|
|
@ -111,6 +111,10 @@ func (h *HugoSites) ShouldSkipFileChangeEvent(ev fsnotify.Event) bool {
|
|||
return h.skipRebuildForFilenames[ev.Name]
|
||||
}
|
||||
|
||||
func (h *HugoSites) Close() error {
|
||||
return h.Deps.Close()
|
||||
}
|
||||
|
||||
func (h *HugoSites) isRebuild() bool {
|
||||
return h.buildCounter.Load() > 0
|
||||
}
|
||||
|
|
|
@ -520,8 +520,9 @@ func (s *Site) executeDeferredTemplates(de *deps.DeferredExecutions) error {
|
|||
},
|
||||
})
|
||||
|
||||
de.FilenamesWithPostPrefix.ForEeach(func(filename string, _ bool) {
|
||||
de.FilenamesWithPostPrefix.ForEeach(func(filename string, _ bool) bool {
|
||||
g.Enqueue(filename)
|
||||
return true
|
||||
})
|
||||
|
||||
return g.Wait()
|
||||
|
@ -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 {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -554,8 +554,6 @@ toc line 3
|
|||
toc line 4
|
||||
|
||||
|
||||
|
||||
|
||||
`
|
||||
|
||||
t.Run("base template", func(t *testing.T) {
|
||||
|
@ -569,7 +567,7 @@ toc line 4
|
|||
).BuildE()
|
||||
|
||||
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) {
|
||||
|
@ -583,7 +581,7 @@ toc line 4
|
|||
).BuildE()
|
||||
|
||||
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) {
|
||||
|
@ -597,8 +595,7 @@ toc line 4
|
|||
).BuildE()
|
||||
|
||||
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, `execute of template failed: template: partials/toc.html:2:8: executing "partials/toc.html"`)
|
||||
b.Assert(err.Error(), qt.Contains, `toc.html:2:8"`)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
func TestOptWarn() TestOpt {
|
||||
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.
|
||||
func TestOptWithConfig(fn func(c *IntegrationTestConfig)) TestOpt {
|
||||
return func(c *IntegrationTestConfig) {
|
||||
|
@ -284,8 +298,9 @@ func (s *IntegrationTestBuilder) negate(match string) (string, bool) {
|
|||
func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...string) {
|
||||
s.Helper()
|
||||
content := strings.TrimSpace(s.FileContent(filename))
|
||||
|
||||
for _, m := range matches {
|
||||
cm := qt.Commentf("File: %s Match %s", filename, m)
|
||||
cm := qt.Commentf("File: %s Match %s\nContent:\n%s", filename, m, content)
|
||||
lines := strings.Split(m, "\n")
|
||||
for _, match := range lines {
|
||||
match = strings.TrimSpace(match)
|
||||
|
@ -313,7 +328,8 @@ func (s *IntegrationTestBuilder) AssertFileContentExact(filename string, matches
|
|||
s.Helper()
|
||||
content := s.FileContent(filename)
|
||||
for _, m := range matches {
|
||||
s.Assert(content, qt.Contains, m, qt.Commentf(m))
|
||||
cm := qt.Commentf("File: %s Match %s\nContent:\n%s", filename, m, content)
|
||||
s.Assert(content, qt.Contains, m, cm)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -450,6 +466,11 @@ func (s *IntegrationTestBuilder) Build() *IntegrationTestBuilder {
|
|||
return s
|
||||
}
|
||||
|
||||
func (s *IntegrationTestBuilder) Close() {
|
||||
s.Helper()
|
||||
s.Assert(s.H.Close(), qt.IsNil)
|
||||
}
|
||||
|
||||
func (s *IntegrationTestBuilder) LogString() string {
|
||||
return s.lastBuildLog
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
return "page-" + strconv.FormatUint(p.pid, 10)
|
||||
}
|
||||
|
|
|
@ -143,13 +143,29 @@ func (c *pagesCollector) Collect() (collectErr error) {
|
|||
s.pageMap.cfg.isRebuild = true
|
||||
}
|
||||
|
||||
var hasStructuralChange bool
|
||||
for _, id := range c.ids {
|
||||
if id.isStructuralChange() {
|
||||
hasStructuralChange = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range c.ids {
|
||||
if id.p.IsLeafBundle() {
|
||||
collectErr = c.collectDir(
|
||||
id.p,
|
||||
false,
|
||||
func(fim hugofs.FileMetaInfo) bool {
|
||||
return true
|
||||
if hasStructuralChange {
|
||||
return true
|
||||
}
|
||||
fimp := fim.Meta().PathInfo
|
||||
if fimp == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return fimp.Path() == id.p.Path()
|
||||
},
|
||||
)
|
||||
} else if id.p.IsBranchBundle() {
|
||||
|
|
|
@ -245,10 +245,11 @@ func (b *BuildState) resolveDeletedPaths() {
|
|||
return
|
||||
}
|
||||
var paths []string
|
||||
b.sourceInfosPrevious.ForEeach(func(k string, _ *sourceInfo) {
|
||||
b.sourceInfosPrevious.ForEeach(func(k string, _ *sourceInfo) bool {
|
||||
if _, found := b.sourceInfosCurrent.Get(k); !found {
|
||||
paths = append(paths, k)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
b.DeletedPaths = paths
|
||||
|
@ -287,6 +288,10 @@ func (p *PagesFromTemplate) GetDependencyManagerForScope(scope int) identity.Man
|
|||
return p.DependencyManager
|
||||
}
|
||||
|
||||
func (p *PagesFromTemplate) GetDependencyManagerForScopesAll() []identity.Manager {
|
||||
return []identity.Manager{p.DependencyManager}
|
||||
}
|
||||
|
||||
func (p *PagesFromTemplate) Execute(ctx context.Context) (BuildInfo, error) {
|
||||
defer func() {
|
||||
p.buildState.PrepareNextBuild()
|
||||
|
|
|
@ -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) {
|
||||
b := TestRunning(t, rebuildFilesSimple)
|
||||
b.AssertFileContent("public/mysection/mysectionbundle/index.html",
|
||||
|
@ -962,7 +974,7 @@ Single. {{ partial "head.html" . }}$
|
|||
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/index.html", "/js/main.712a50b59d0f0dedb4e3606eaa3860b1f1a5305f6c42da30a2985e473ba314eb.js")
|
||||
|
@ -998,7 +1010,7 @@ Base. {{ partial "common/head.html" . }}$
|
|||
RelPermalink: {{ $js.RelPermalink }}|
|
||||
`
|
||||
|
||||
b := TestRunning(t, files)
|
||||
b := TestRunning(t, files, TestOptOsFs())
|
||||
|
||||
b.AssertFileContent("public/index.html", "/js/main.712a50b59d0f0dedb4e3606eaa3860b1f1a5305f6c42da30a2985e473ba314eb.js")
|
||||
|
||||
|
|
|
@ -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 {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -82,9 +82,8 @@ func FirstIdentity(v any) Identity {
|
|||
var result Identity = Anonymous
|
||||
WalkIdentitiesShallow(v, func(level int, id Identity) bool {
|
||||
result = id
|
||||
return true
|
||||
return result != Anonymous
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
@ -146,6 +145,7 @@ func (d DependencyManagerProviderFunc) GetDependencyManager() Manager {
|
|||
// DependencyManagerScopedProvider provides a manager for dependencies with a given scope.
|
||||
type DependencyManagerScopedProvider interface {
|
||||
GetDependencyManagerForScope(scope int) Manager
|
||||
GetDependencyManagerForScopesAll() []Manager
|
||||
}
|
||||
|
||||
// ForEeachIdentityProvider provides a way iterate over identities.
|
||||
|
@ -308,11 +308,13 @@ type identityManager struct {
|
|||
|
||||
func (im *identityManager) AddIdentity(ids ...Identity) {
|
||||
im.mu.Lock()
|
||||
defer im.mu.Unlock()
|
||||
|
||||
for _, id := range ids {
|
||||
if id == nil || id == Anonymous {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, found := im.ids[id]; !found {
|
||||
if im.onAddIdentity != nil {
|
||||
im.onAddIdentity(id)
|
||||
|
@ -320,7 +322,6 @@ func (im *identityManager) AddIdentity(ids ...Identity) {
|
|||
im.ids[id] = true
|
||||
}
|
||||
}
|
||||
im.mu.Unlock()
|
||||
}
|
||||
|
||||
func (im *identityManager) AddIdentityForEach(ids ...ForEeachIdentityProvider) {
|
||||
|
@ -355,6 +356,10 @@ func (im *identityManager) GetDependencyManagerForScope(int) Manager {
|
|||
return im
|
||||
}
|
||||
|
||||
func (im *identityManager) GetDependencyManagerForScopesAll() []Manager {
|
||||
return []Manager{im}
|
||||
}
|
||||
|
||||
func (im *identityManager) String() string {
|
||||
return fmt.Sprintf("IdentityManager(%s)", im.name)
|
||||
}
|
||||
|
|
20
internal/js/esbuild/batch-esm-runner.gotmpl
Normal file
20
internal/js/esbuild/batch-esm-runner.gotmpl
Normal 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
1437
internal/js/esbuild/batch.go
Normal file
File diff suppressed because it is too large
Load diff
686
internal/js/esbuild/batch_integration_test.go
Normal file
686
internal/js/esbuild/batch_integration_test.go
Normal 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..")
|
||||
}
|
||||
}
|
236
internal/js/esbuild/build.go
Normal file
236
internal/js/esbuild/build.go
Normal 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
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 The Hugo Authors. All rights reserved.
|
||||
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
@ -11,4 +11,5 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package js
|
||||
// Package esbuild provides functions for building JavaScript resources.
|
||||
package esbuild
|
375
internal/js/esbuild/options.go
Normal file
375
internal/js/esbuild/options.go
Normal 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
|
||||
}
|
219
internal/js/esbuild/options_test.go
Normal file
219
internal/js/esbuild/options_test.go
Normal 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,
|
||||
})
|
||||
}
|
315
internal/js/esbuild/resolve.go
Normal file
315
internal/js/esbuild/resolve.go
Normal 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
|
||||
}
|
86
internal/js/esbuild/resolve_test.go
Normal file
86
internal/js/esbuild/resolve_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
80
internal/js/esbuild/sourcemap.go
Normal file
80
internal/js/esbuild/sourcemap.go
Normal 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
|
||||
}
|
|
@ -36,7 +36,7 @@ type Init struct {
|
|||
prev *Init
|
||||
children []*Init
|
||||
|
||||
init onceMore
|
||||
init OnceMore
|
||||
out any
|
||||
err error
|
||||
f func(context.Context) (any, error)
|
||||
|
|
10
lazy/once.go
10
lazy/once.go
|
@ -24,13 +24,13 @@ import (
|
|||
// * it can be reset, so the action can be repeated if needed
|
||||
// * it has methods to check if it's done or in progress
|
||||
|
||||
type onceMore struct {
|
||||
type OnceMore struct {
|
||||
mu sync.Mutex
|
||||
lock uint32
|
||||
done uint32
|
||||
}
|
||||
|
||||
func (t *onceMore) Do(f func()) {
|
||||
func (t *OnceMore) Do(f func()) {
|
||||
if atomic.LoadUint32(&t.done) == 1 {
|
||||
return
|
||||
}
|
||||
|
@ -53,15 +53,15 @@ func (t *onceMore) Do(f func()) {
|
|||
f()
|
||||
}
|
||||
|
||||
func (t *onceMore) InProgress() bool {
|
||||
func (t *OnceMore) InProgress() bool {
|
||||
return atomic.LoadUint32(&t.lock) == 1
|
||||
}
|
||||
|
||||
func (t *onceMore) Done() bool {
|
||||
func (t *OnceMore) Done() bool {
|
||||
return atomic.LoadUint32(&t.done) == 1
|
||||
}
|
||||
|
||||
func (t *onceMore) ResetWithLock() *sync.Mutex {
|
||||
func (t *OnceMore) ResetWithLock() *sync.Mutex {
|
||||
t.mu.Lock()
|
||||
defer atomic.StoreUint32(&t.done, 0)
|
||||
return &t.mu
|
||||
|
|
|
@ -273,9 +273,13 @@ func (t Types) GetByType(tp string) (Type, bool) {
|
|||
return Type{}, false
|
||||
}
|
||||
|
||||
func (t Types) normalizeSuffix(s string) string {
|
||||
return strings.ToLower(strings.TrimPrefix(s, "."))
|
||||
}
|
||||
|
||||
// BySuffix will return all media types matching a suffix.
|
||||
func (t Types) BySuffix(suffix string) []Type {
|
||||
suffix = strings.ToLower(suffix)
|
||||
suffix = t.normalizeSuffix(suffix)
|
||||
var types []Type
|
||||
for _, tt := range t {
|
||||
if tt.hasSuffix(suffix) {
|
||||
|
@ -287,7 +291,7 @@ func (t Types) BySuffix(suffix string) []Type {
|
|||
|
||||
// GetFirstBySuffix will return the first type matching the given suffix.
|
||||
func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) {
|
||||
suffix = strings.ToLower(suffix)
|
||||
suffix = t.normalizeSuffix(suffix)
|
||||
for _, tt := range t {
|
||||
if tt.hasSuffix(suffix) {
|
||||
return tt, SuffixInfo{
|
||||
|
@ -304,7 +308,7 @@ func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) {
|
|||
// is ambiguous.
|
||||
// The lookup is case insensitive.
|
||||
func (t Types) GetBySuffix(suffix string) (tp Type, si SuffixInfo, found bool) {
|
||||
suffix = strings.ToLower(suffix)
|
||||
suffix = t.normalizeSuffix(suffix)
|
||||
for _, tt := range t {
|
||||
if tt.hasSuffix(suffix) {
|
||||
if found {
|
||||
|
@ -324,7 +328,7 @@ func (t Types) GetBySuffix(suffix string) (tp Type, si SuffixInfo, found bool) {
|
|||
}
|
||||
|
||||
func (t Types) IsTextSuffix(suffix string) bool {
|
||||
suffix = strings.ToLower(suffix)
|
||||
suffix = t.normalizeSuffix(suffix)
|
||||
for _, tt := range t {
|
||||
if tt.hasSuffix(suffix) {
|
||||
return tt.IsText()
|
||||
|
|
|
@ -51,6 +51,7 @@ var (
|
|||
_ resource.Source = (*imageResource)(nil)
|
||||
_ resource.Cloner = (*imageResource)(nil)
|
||||
_ resource.NameNormalizedProvider = (*imageResource)(nil)
|
||||
_ targetPathProvider = (*imageResource)(nil)
|
||||
)
|
||||
|
||||
// imageResource represents an image resource.
|
||||
|
@ -160,6 +161,10 @@ func (i *imageResource) Colors() ([]images.Color, error) {
|
|||
return i.dominantColors, nil
|
||||
}
|
||||
|
||||
func (i *imageResource) targetPath() string {
|
||||
return i.TargetPath()
|
||||
}
|
||||
|
||||
// Clone is for internal use.
|
||||
func (i *imageResource) Clone() resource.Resource {
|
||||
gr := i.baseResource.Clone().(baseResource)
|
||||
|
|
|
@ -63,8 +63,7 @@ type ChildCareProvider interface {
|
|||
// section.
|
||||
RegularPagesRecursive() Pages
|
||||
|
||||
// Resources returns a list of all resources.
|
||||
Resources() resource.Resources
|
||||
resource.ResourcesProvider
|
||||
}
|
||||
|
||||
type MarkupProvider interface {
|
||||
|
|
|
@ -47,6 +47,8 @@ var (
|
|||
_ resource.Cloner = (*genericResource)(nil)
|
||||
_ resource.ResourcesLanguageMerger = (*resource.Resources)(nil)
|
||||
_ resource.Identifier = (*genericResource)(nil)
|
||||
_ targetPathProvider = (*genericResource)(nil)
|
||||
_ sourcePathProvider = (*genericResource)(nil)
|
||||
_ identity.IdentityGroupProvider = (*genericResource)(nil)
|
||||
_ identity.DependencyManagerProvider = (*genericResource)(nil)
|
||||
_ identity.Identity = (*genericResource)(nil)
|
||||
|
@ -79,6 +81,7 @@ type ResourceSourceDescriptor struct {
|
|||
TargetPath string
|
||||
BasePathRelPermalink string
|
||||
BasePathTargetPath string
|
||||
SourceFilenameOrPath string // Used for error logging.
|
||||
|
||||
// The Data to associate with this resource.
|
||||
Data map[string]any
|
||||
|
@ -463,6 +466,17 @@ func (l *genericResource) Key() string {
|
|||
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 {
|
||||
return l.sd.MediaType
|
||||
}
|
||||
|
@ -660,3 +674,43 @@ func (r *resourceHash) init(l hugio.ReadSeekCloserProvider) error {
|
|||
func hashImage(r io.ReadSeeker) (uint64, int64, error) {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -14,10 +14,11 @@
|
|||
package resource
|
||||
|
||||
import (
|
||||
"github.com/gohugoio/hugo/common/maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gohugoio/hugo/common/maps"
|
||||
|
||||
"github.com/gohugoio/hugo/helpers"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
|
||||
|
|
|
@ -16,8 +16,11 @@ package resource
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gohugoio/hugo/common/hreflect"
|
||||
"github.com/gohugoio/hugo/common/maps"
|
||||
"github.com/gohugoio/hugo/common/paths"
|
||||
"github.com/gohugoio/hugo/hugofs/glob"
|
||||
"github.com/spf13/cast"
|
||||
|
@ -29,6 +32,51 @@ var _ ResourceFinder = (*Resources)(nil)
|
|||
// I.e. both pages and images etc.
|
||||
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)
|
||||
// ResourcesConverter converts a given slice of Resource objects to Resources.
|
||||
type ResourcesConverter interface {
|
||||
|
@ -63,13 +111,25 @@ func (r Resources) Get(name any) Resource {
|
|||
panic(err)
|
||||
}
|
||||
|
||||
namestr = paths.AddLeadingSlash(namestr)
|
||||
isDotCurrent := strings.HasPrefix(namestr, "./")
|
||||
if isDotCurrent {
|
||||
namestr = strings.TrimPrefix(namestr, "./")
|
||||
} else {
|
||||
namestr = paths.AddLeadingSlash(namestr)
|
||||
}
|
||||
|
||||
check := func(name string) bool {
|
||||
if !isDotCurrent {
|
||||
name = paths.AddLeadingSlash(name)
|
||||
}
|
||||
return strings.EqualFold(namestr, name)
|
||||
}
|
||||
|
||||
// First check the Name.
|
||||
// Note that this can be modified by the user in the front matter,
|
||||
// also, it does not contain any language code.
|
||||
for _, resource := range r {
|
||||
if strings.EqualFold(namestr, paths.AddLeadingSlash(resource.Name())) {
|
||||
if check(resource.Name()) {
|
||||
return resource
|
||||
}
|
||||
}
|
||||
|
@ -77,7 +137,7 @@ func (r Resources) Get(name any) Resource {
|
|||
// Finally, check the normalized name.
|
||||
for _, resource := range r {
|
||||
if nop, ok := resource.(NameNormalizedProvider); ok {
|
||||
if strings.EqualFold(namestr, paths.AddLeadingSlash(nop.NameNormalized())) {
|
||||
if check(nop.NameNormalized()) {
|
||||
return resource
|
||||
}
|
||||
}
|
||||
|
@ -197,14 +257,35 @@ type Source interface {
|
|||
Publish() error
|
||||
}
|
||||
|
||||
// ResourceFinder provides methods to find Resources.
|
||||
// Note that GetRemote (as found in resources.GetRemote) is
|
||||
// not covered by this interface, as this is only available as a global template function.
|
||||
type ResourceFinder interface {
|
||||
type ResourceGetter interface {
|
||||
// Get locates the Resource with the given name in the current context (e.g. in .Page.Resources).
|
||||
//
|
||||
// It returns nil if no Resource could found, panics if name is invalid.
|
||||
Get(name any) Resource
|
||||
}
|
||||
|
||||
type IsProbablySameResourceGetter interface {
|
||||
IsProbablySameResourceGetter(other ResourceGetter) bool
|
||||
}
|
||||
|
||||
// StaleInfoResourceGetter is a ResourceGetter that also provides information about
|
||||
// whether the underlying resources are stale.
|
||||
type StaleInfoResourceGetter interface {
|
||||
StaleInfo
|
||||
ResourceGetter
|
||||
}
|
||||
|
||||
type resourceGetterFunc func(name any) Resource
|
||||
|
||||
func (f resourceGetterFunc) Get(name any) Resource {
|
||||
return f(name)
|
||||
}
|
||||
|
||||
// ResourceFinder provides methods to find Resources.
|
||||
// Note that GetRemote (as found in resources.GetRemote) is
|
||||
// not covered by this interface, as this is only available as a global template function.
|
||||
type ResourceFinder interface {
|
||||
ResourceGetter
|
||||
|
||||
// GetMatch finds the first Resource matching the given pattern, or nil if none found.
|
||||
//
|
||||
|
@ -235,3 +316,92 @@ type ResourceFinder interface {
|
|||
// It returns nil if no Resources could found, panics if typ is invalid.
|
||||
ByType(typ any) Resources
|
||||
}
|
||||
|
||||
// NewCachedResourceGetter creates a new ResourceGetter from the given objects.
|
||||
// If multiple objects are provided, they are merged into one where
|
||||
// the first match wins.
|
||||
func NewCachedResourceGetter(os ...any) *cachedResourceGetter {
|
||||
var getters multiResourceGetter
|
||||
for _, o := range os {
|
||||
if g, ok := unwrapResourceGetter(o); ok {
|
||||
getters = append(getters, g)
|
||||
}
|
||||
}
|
||||
|
||||
return &cachedResourceGetter{
|
||||
cache: maps.NewCache[string, Resource](),
|
||||
delegate: getters,
|
||||
}
|
||||
}
|
||||
|
||||
type multiResourceGetter []ResourceGetter
|
||||
|
||||
func (m multiResourceGetter) Get(name any) Resource {
|
||||
for _, g := range m {
|
||||
if res := g.Get(name); res != nil {
|
||||
return res
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ ResourceGetter = (*cachedResourceGetter)(nil)
|
||||
_ IsProbablySameResourceGetter = (*cachedResourceGetter)(nil)
|
||||
)
|
||||
|
||||
type cachedResourceGetter struct {
|
||||
cache *maps.Cache[string, Resource]
|
||||
delegate ResourceGetter
|
||||
}
|
||||
|
||||
func (c *cachedResourceGetter) Get(name any) Resource {
|
||||
namestr, err := cast.ToStringE(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
v, _ := c.cache.GetOrCreate(namestr, func() (Resource, error) {
|
||||
v := c.delegate.Get(name)
|
||||
return v, nil
|
||||
})
|
||||
return v
|
||||
}
|
||||
|
||||
func (c *cachedResourceGetter) IsProbablySameResourceGetter(other ResourceGetter) bool {
|
||||
isProbablyEq := true
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
105
resources/resource/resources_integration_test.go
Normal file
105
resources/resource/resources_integration_test.go
Normal 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|",
|
||||
)
|
||||
}
|
122
resources/resource/resources_test.go
Normal file
122
resources/resource/resources_test.go
Normal 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
|
||||
}
|
|
@ -143,16 +143,18 @@ func (c *Client) Get(pathname string) (resource.Resource, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
pi := fi.(hugofs.FileMetaInfo).Meta().PathInfo
|
||||
meta := fi.(hugofs.FileMetaInfo).Meta()
|
||||
pi := meta.PathInfo
|
||||
|
||||
return c.rs.NewResource(resources.ResourceSourceDescriptor{
|
||||
LazyPublish: true,
|
||||
OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) {
|
||||
return c.rs.BaseFs.Assets.Fs.Open(filename)
|
||||
},
|
||||
Path: pi,
|
||||
GroupIdentity: pi,
|
||||
TargetPath: pathname,
|
||||
Path: pi,
|
||||
GroupIdentity: pi,
|
||||
TargetPath: pathname,
|
||||
SourceFilenameOrPath: meta.Filename,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -196,10 +198,11 @@ func (c *Client) match(name, pattern string, matchFunc func(r resource.Resource)
|
|||
OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) {
|
||||
return meta.Open()
|
||||
},
|
||||
NameNormalized: meta.PathInfo.Path(),
|
||||
NameOriginal: meta.PathInfo.Unnormalized().Path(),
|
||||
GroupIdentity: meta.PathInfo,
|
||||
TargetPath: meta.PathInfo.Unnormalized().Path(),
|
||||
NameNormalized: meta.PathInfo.Path(),
|
||||
NameOriginal: meta.PathInfo.Unnormalized().Path(),
|
||||
GroupIdentity: meta.PathInfo,
|
||||
TargetPath: meta.PathInfo.Unnormalized().Path(),
|
||||
SourceFilenameOrPath: meta.Filename,
|
||||
})
|
||||
if err != nil {
|
||||
return true, err
|
||||
|
|
|
@ -15,6 +15,7 @@ package resources
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
@ -26,6 +27,7 @@ import (
|
|||
"github.com/spf13/cast"
|
||||
|
||||
"github.com/gohugoio/hugo/common/maps"
|
||||
"github.com/gohugoio/hugo/common/paths"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -172,6 +174,8 @@ func assignMetadata(metadata []map[string]any, ma *metaResource) error {
|
|||
name, found := meta["name"]
|
||||
if found {
|
||||
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 {
|
||||
nameCounterFound = strings.Contains(name, counterPlaceHolder)
|
||||
}
|
||||
|
|
|
@ -16,8 +16,26 @@ package resources
|
|||
import (
|
||||
"os"
|
||||
"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) {
|
||||
f, err := os.Open("testdata/sunset.jpg")
|
||||
if err != nil {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 The Hugo Authors. All rights reserved.
|
||||
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
@ -14,209 +14,69 @@
|
|||
package js
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"github.com/gohugoio/hugo/hugofs"
|
||||
"github.com/gohugoio/hugo/media"
|
||||
|
||||
"github.com/gohugoio/hugo/common/herrors"
|
||||
"github.com/gohugoio/hugo/common/text"
|
||||
|
||||
"github.com/gohugoio/hugo/hugolib/filesystems"
|
||||
"github.com/gohugoio/hugo/resources/internal"
|
||||
|
||||
"github.com/evanw/esbuild/pkg/api"
|
||||
"github.com/gohugoio/hugo/hugolib/filesystems"
|
||||
"github.com/gohugoio/hugo/internal/js/esbuild"
|
||||
|
||||
"github.com/gohugoio/hugo/resources"
|
||||
"github.com/gohugoio/hugo/resources/resource"
|
||||
)
|
||||
|
||||
// Client context for ESBuild.
|
||||
type Client struct {
|
||||
rs *resources.Spec
|
||||
sfs *filesystems.SourceFilesystem
|
||||
c *esbuild.BuildClient
|
||||
}
|
||||
|
||||
// New creates a new client context.
|
||||
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client {
|
||||
return &Client{
|
||||
rs: rs,
|
||||
sfs: fs,
|
||||
c: esbuild.NewBuildClient(fs, rs),
|
||||
}
|
||||
}
|
||||
|
||||
type buildTransformation struct {
|
||||
optsm map[string]any
|
||||
c *Client
|
||||
}
|
||||
|
||||
func (t *buildTransformation) Key() internal.ResourceTransformationKey {
|
||||
return internal.NewResourceTransformationKey("jsbuild", t.optsm)
|
||||
}
|
||||
|
||||
func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
|
||||
ctx.OutMediaType = media.Builtin.JavascriptType
|
||||
|
||||
opts, err := decodeOptions(t.optsm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.TargetPath != "" {
|
||||
ctx.OutPath = opts.TargetPath
|
||||
} else {
|
||||
ctx.ReplaceOutPathExtension(".js")
|
||||
}
|
||||
|
||||
src, err := io.ReadAll(ctx.From)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts.sourceDir = filepath.FromSlash(path.Dir(ctx.SourcePath))
|
||||
opts.resolveDir = t.c.rs.Cfg.BaseConfig().WorkingDir // where node_modules gets resolved
|
||||
opts.contents = string(src)
|
||||
opts.mediaType = ctx.InMediaType
|
||||
opts.tsConfig = t.c.rs.ResolveJSConfigFile("tsconfig.json")
|
||||
|
||||
buildOptions, err := toBuildOptions(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buildOptions.Plugins, err = createBuildPlugins(ctx.DependencyManager, t.c, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if buildOptions.Sourcemap == api.SourceMapExternal && buildOptions.Outdir == "" {
|
||||
buildOptions.Outdir, err = os.MkdirTemp(os.TempDir(), "compileOutput")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(buildOptions.Outdir)
|
||||
}
|
||||
|
||||
if opts.Inject != nil {
|
||||
// Resolve the absolute filenames.
|
||||
for i, ext := range opts.Inject {
|
||||
impPath := filepath.FromSlash(ext)
|
||||
if filepath.IsAbs(impPath) {
|
||||
return fmt.Errorf("inject: absolute paths not supported, must be relative to /assets")
|
||||
}
|
||||
|
||||
m := resolveComponentInAssets(t.c.rs.Assets.Fs, impPath)
|
||||
|
||||
if m == nil {
|
||||
return fmt.Errorf("inject: file %q not found", ext)
|
||||
}
|
||||
|
||||
opts.Inject[i] = m.Filename
|
||||
|
||||
}
|
||||
|
||||
buildOptions.Inject = opts.Inject
|
||||
|
||||
}
|
||||
|
||||
result := api.Build(buildOptions)
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
|
||||
createErr := func(msg api.Message) error {
|
||||
loc := msg.Location
|
||||
if loc == nil {
|
||||
return errors.New(msg.Text)
|
||||
}
|
||||
path := loc.File
|
||||
if path == stdinImporter {
|
||||
path = ctx.SourcePath
|
||||
}
|
||||
|
||||
errorMessage := msg.Text
|
||||
errorMessage = strings.ReplaceAll(errorMessage, nsImportHugo+":", "")
|
||||
|
||||
var (
|
||||
f afero.File
|
||||
err error
|
||||
)
|
||||
|
||||
if strings.HasPrefix(path, nsImportHugo) {
|
||||
path = strings.TrimPrefix(path, nsImportHugo+":")
|
||||
f, err = hugofs.Os.Open(path)
|
||||
} else {
|
||||
var fi os.FileInfo
|
||||
fi, err = t.c.sfs.Fs.Stat(path)
|
||||
if err == nil {
|
||||
m := fi.(hugofs.FileMetaInfo).Meta()
|
||||
path = m.Filename
|
||||
f, err = m.Open()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
fe := herrors.
|
||||
NewFileErrorFromName(errors.New(errorMessage), path).
|
||||
UpdatePosition(text.Position{Offset: -1, LineNumber: loc.Line, ColumnNumber: loc.Column}).
|
||||
UpdateContent(f, nil)
|
||||
|
||||
f.Close()
|
||||
return fe
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s", errorMessage)
|
||||
}
|
||||
|
||||
var errors []error
|
||||
|
||||
for _, msg := range result.Errors {
|
||||
errors = append(errors, createErr(msg))
|
||||
}
|
||||
|
||||
// Return 1, log the rest.
|
||||
for i, err := range errors {
|
||||
if i > 0 {
|
||||
t.c.rs.Logger.Errorf("js.Build failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return errors[0]
|
||||
}
|
||||
|
||||
if buildOptions.Sourcemap == api.SourceMapExternal {
|
||||
content := string(result.OutputFiles[1].Contents)
|
||||
symPath := path.Base(ctx.OutPath) + ".map"
|
||||
re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
|
||||
content = re.ReplaceAllString(content, "//# sourceMappingURL="+symPath+"\n")
|
||||
|
||||
if err = ctx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := ctx.To.Write([]byte(content))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
_, err := ctx.To.Write(result.OutputFiles[0].Contents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Process process esbuild transform
|
||||
// Process processes a resource with the user provided options.
|
||||
func (c *Client) Process(res resources.ResourceTransformer, opts map[string]any) (resource.Resource, error) {
|
||||
return res.Transform(
|
||||
&buildTransformation{c: c, optsm: opts},
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) transform(opts esbuild.Options, transformCtx *resources.ResourceTransformationCtx) (api.BuildResult, error) {
|
||||
if transformCtx.DependencyManager != nil {
|
||||
opts.DependencyManager = transformCtx.DependencyManager
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2021 The Hugo Authors. All rights reserved.
|
||||
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
@ -14,13 +14,16 @@
|
|||
package js_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
qt "github.com/frankban/quicktest"
|
||||
"github.com/gohugoio/hugo/common/paths"
|
||||
"github.com/gohugoio/hugo/htesting"
|
||||
"github.com/gohugoio/hugo/hugolib"
|
||||
"github.com/gohugoio/hugo/internal/js/esbuild"
|
||||
)
|
||||
|
||||
func TestBuildVariants(t *testing.T) {
|
||||
|
@ -173,7 +176,7 @@ hello:
|
|||
hello:
|
||||
other: "Bonjour"
|
||||
-- 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: {{ template "print" $js }}
|
||||
{{ $jsx := resources.Get "js/myjsx.jsx" | js.Build $options }}
|
||||
|
@ -201,14 +204,31 @@ TS2: {{ template "print" $ts2 }}
|
|||
TxtarString: files,
|
||||
}).Build()
|
||||
|
||||
b.AssertFileContent("public/js/myts.js", `//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJz`)
|
||||
b.AssertFileContent("public/js/myts2.js.map", `"version": 3,`)
|
||||
b.AssertFileContent("public/js/main.js", `//# sourceMappingURL=main.js.map`)
|
||||
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", `
|
||||
console.log("included");
|
||||
if (hasSpace.test(string))
|
||||
var React = __toESM(__require("react"));
|
||||
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) {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
68
resources/resource_transformers/js/transform.go
Normal file
68
resources/resource_transformers/js/transform.go
Normal 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
|
||||
}
|
|
@ -139,7 +139,7 @@ func (t importResolver) CanonicalizeURL(url string) (string, error) {
|
|||
return url, nil
|
||||
}
|
||||
|
||||
filePath, isURL := paths.UrlToFilename(url)
|
||||
filePath, isURL := paths.UrlStringToFilename(url)
|
||||
var prevDir string
|
||||
var pathDir string
|
||||
if isURL {
|
||||
|
@ -195,7 +195,7 @@ func (t importResolver) Load(url string) (godartsass.Import, error) {
|
|||
if url == sass.HugoVarsNamespace {
|
||||
return t.varsStylesheet, nil
|
||||
}
|
||||
filename, _ := paths.UrlToFilename(url)
|
||||
filename, _ := paths.UrlStringToFilename(url)
|
||||
b, err := afero.ReadFile(hugofs.Os, filename)
|
||||
|
||||
sourceSyntax := godartsass.SourceSyntaxSCSS
|
||||
|
|
|
@ -52,6 +52,8 @@ var (
|
|||
_ identity.IdentityGroupProvider = (*resourceAdapterInner)(nil)
|
||||
_ resource.Source = (*resourceAdapter)(nil)
|
||||
_ resource.Identifier = (*resourceAdapter)(nil)
|
||||
_ targetPathProvider = (*resourceAdapter)(nil)
|
||||
_ sourcePathProvider = (*resourceAdapter)(nil)
|
||||
_ resource.ResourceNameTitleProvider = (*resourceAdapter)(nil)
|
||||
_ resource.WithResourceMetaProvider = (*resourceAdapter)(nil)
|
||||
_ identity.DependencyManagerProvider = (*resourceAdapter)(nil)
|
||||
|
@ -277,6 +279,19 @@ func (r *resourceAdapter) Key() string {
|
|||
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 {
|
||||
r.init(false, false)
|
||||
return r.target.MediaType()
|
||||
|
|
|
@ -41,7 +41,7 @@ func New(d *deps.Deps) *Namespace {
|
|||
|
||||
l := d.Log.InfoCommand("timer")
|
||||
|
||||
d.BuildEndListeners.Add(func() {
|
||||
d.BuildEndListeners.Add(func(...any) bool {
|
||||
type data struct {
|
||||
Name string
|
||||
Count int
|
||||
|
@ -84,6 +84,8 @@ func New(d *deps.Deps) *Namespace {
|
|||
}
|
||||
|
||||
ns.timers = make(map[string][]*timer)
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return ns
|
||||
|
|
|
@ -30,8 +30,9 @@ func New(d *deps.Deps) *Namespace {
|
|||
logger: d.Log,
|
||||
}
|
||||
|
||||
d.BuildStartListeners.Add(func() {
|
||||
d.BuildStartListeners.Add(func(...any) bool {
|
||||
ns.logger.Reset()
|
||||
return false
|
||||
})
|
||||
|
||||
return ns
|
||||
|
|
|
@ -24,7 +24,10 @@ const name = "js"
|
|||
|
||||
func init() {
|
||||
f := func(d *deps.Deps) *internal.TemplateFuncsNamespace {
|
||||
ctx := New(d)
|
||||
ctx, err := New(d)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
ns := &internal.TemplateFuncsNamespace{
|
||||
Name: name,
|
||||
|
|
57
tpl/js/js.go
57
tpl/js/js.go
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 The Hugo Authors. All rights reserved.
|
||||
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
@ -17,29 +17,47 @@ package js
|
|||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gohugoio/hugo/common/maps"
|
||||
"github.com/gohugoio/hugo/deps"
|
||||
"github.com/gohugoio/hugo/internal/js/esbuild"
|
||||
"github.com/gohugoio/hugo/resources"
|
||||
"github.com/gohugoio/hugo/resources/resource"
|
||||
"github.com/gohugoio/hugo/resources/resource_factories/create"
|
||||
"github.com/gohugoio/hugo/resources/resource_transformers/babel"
|
||||
"github.com/gohugoio/hugo/resources/resource_transformers/js"
|
||||
jstransform "github.com/gohugoio/hugo/resources/resource_transformers/js"
|
||||
"github.com/gohugoio/hugo/tpl/internal/resourcehelpers"
|
||||
)
|
||||
|
||||
// New returns a new instance of the js-namespaced template functions.
|
||||
func New(deps *deps.Deps) *Namespace {
|
||||
if deps.ResourceSpec == nil {
|
||||
return &Namespace{}
|
||||
func New(d *deps.Deps) (*Namespace, error) {
|
||||
if d.ResourceSpec == nil {
|
||||
return &Namespace{}, nil
|
||||
}
|
||||
|
||||
batcherClient, err := esbuild.NewBatcherClient(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Namespace{
|
||||
client: js.New(deps.BaseFs.Assets, deps.ResourceSpec),
|
||||
babelClient: babel.New(deps.ResourceSpec),
|
||||
}
|
||||
d: d,
|
||||
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.
|
||||
type Namespace struct {
|
||||
client *js.Client
|
||||
babelClient *babel.Client
|
||||
d *deps.Deps
|
||||
|
||||
jsTransformClient *jstransform.Client
|
||||
createClient *create.Client
|
||||
babelClient *babel.Client
|
||||
jsBatcherClient *esbuild.BatcherClient
|
||||
jsBatcherStore *maps.Cache[string, esbuild.Batcher]
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
|
@ -81,8 +81,9 @@ func New(deps *deps.Deps) *Namespace {
|
|||
|
||||
cache := &partialCache{cache: lru}
|
||||
deps.BuildStartListeners.Add(
|
||||
func() {
|
||||
func(...any) bool {
|
||||
cache.clear()
|
||||
return false
|
||||
})
|
||||
|
||||
return &Namespace{
|
||||
|
|
|
@ -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 }}
|
|
@ -695,13 +695,13 @@ func (t *templateHandler) applyBaseTemplate(overlay, base templateInfo) (tpl.Tem
|
|||
if !base.IsZero() {
|
||||
templ, err = templ.Parse(base.template)
|
||||
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)
|
||||
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
|
||||
|
@ -720,13 +720,13 @@ func (t *templateHandler) applyBaseTemplate(overlay, base templateInfo) (tpl.Tem
|
|||
if !base.IsZero() {
|
||||
templ, err = templ.Parse(base.template)
|
||||
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)
|
||||
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
|
||||
|
|
|
@ -251,6 +251,9 @@ func newTemplateExecuter(d *deps.Deps) (texttemplate.Executer, map[string]reflec
|
|||
}
|
||||
|
||||
func createFuncMap(d *deps.Deps) map[string]any {
|
||||
if d.TmplFuncMap != nil {
|
||||
return d.TmplFuncMap
|
||||
}
|
||||
funcMap := template.FuncMap{}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue