mirror of
https://github.com/gohugoio/hugo.git
synced 2024-11-07 20:30:36 -05:00
parent
df8bd4af4f
commit
070e7f4034
44 changed files with 3394 additions and 940 deletions
|
@ -455,6 +455,7 @@ func (r *rootCommand) PreRun(cd, runner *simplecobra.Commandeer) error {
|
||||||
r.hugoSites = lazycache.New(lazycache.Options[configKey, *hugolib.HugoSites]{
|
r.hugoSites = lazycache.New(lazycache.Options[configKey, *hugolib.HugoSites]{
|
||||||
MaxEntries: 1,
|
MaxEntries: 1,
|
||||||
OnEvict: func(key configKey, value *hugolib.HugoSites) {
|
OnEvict: func(key configKey, value *hugolib.HugoSites) {
|
||||||
|
fmt.Println("Evicting HugoSites", key) // TODO1 remove me.
|
||||||
value.Close()
|
value.Close()
|
||||||
runtime.GC()
|
runtime.GC()
|
||||||
},
|
},
|
||||||
|
|
|
@ -133,6 +133,21 @@ func IsNotExist(err error) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsExist returns true if the error is a file exists error.
|
||||||
|
// Unlike os.IsExist, this also considers wrapped errors.
|
||||||
|
func IsExist(err error) bool {
|
||||||
|
if os.IsExist(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// os.IsExist does not consider wrapped errors.
|
||||||
|
if os.IsExist(errors.Unwrap(err)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var nilPointerErrRe = regexp.MustCompile(`at <(.*)>: error calling (.*?): runtime error: invalid memory address or nil pointer dereference`)
|
var nilPointerErrRe = regexp.MustCompile(`at <(.*)>: error calling (.*?): runtime error: invalid memory address or nil pointer dereference`)
|
||||||
|
|
||||||
const deferredPrefix = "__hdeferred/"
|
const deferredPrefix = "__hdeferred/"
|
||||||
|
|
|
@ -69,11 +69,14 @@ func (c *Cache[K, T]) Set(key K, value T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForEeach calls the given function for each key/value pair in the cache.
|
// ForEeach calls the given function for each key/value pair in the cache.
|
||||||
func (c *Cache[K, T]) ForEeach(f func(K, T)) {
|
// If the function returns false, the iteration stops.
|
||||||
|
func (c *Cache[K, T]) ForEeach(f func(K, T) bool) {
|
||||||
c.RLock()
|
c.RLock()
|
||||||
defer c.RUnlock()
|
defer c.RUnlock()
|
||||||
for k, v := range c.m {
|
for k, v := range c.m {
|
||||||
f(k, v)
|
if !f(k, v) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -107,6 +107,23 @@ func (c *Scratch) Get(key string) any {
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOrCreate returns the value for the given key if it exists, or creates it
|
||||||
|
// using the given func and stores that value in the map.
|
||||||
|
// For internal use.
|
||||||
|
func (c *Scratch) GetOrCreate(key string, create func() (any, error)) (any, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if val, found := c.values[key]; found {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
val, err := create()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.values[key] = val
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Values returns the raw backing map. Note that you should just use
|
// Values returns the raw backing map. Note that you should just use
|
||||||
// this method on the locally scoped Scratch instances you obtain via newScratch, not
|
// this method on the locally scoped Scratch instances you obtain via newScratch, not
|
||||||
// .Page.Scratch etc., as that will lead to concurrency issues.
|
// .Page.Scratch etc., as that will lead to concurrency issues.
|
||||||
|
|
|
@ -19,6 +19,13 @@ type Closer interface {
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CloserFunc is a convenience type to create a Closer from a function.
|
||||||
|
type CloserFunc func() error
|
||||||
|
|
||||||
|
func (f CloserFunc) Close() error {
|
||||||
|
return f()
|
||||||
|
}
|
||||||
|
|
||||||
type CloseAdder interface {
|
type CloseAdder interface {
|
||||||
Add(Closer)
|
Add(Closer)
|
||||||
}
|
}
|
||||||
|
|
|
@ -137,11 +137,11 @@ func (c ConfigLanguage) Watching() bool {
|
||||||
return c.m.Base.Internal.Watch
|
return c.m.Base.Internal.Watch
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c ConfigLanguage) NewIdentityManager(name string) identity.Manager {
|
func (c ConfigLanguage) NewIdentityManager(name string, opts ...identity.ManagerOption) identity.Manager {
|
||||||
if !c.Watching() {
|
if !c.Watching() {
|
||||||
return identity.NopManager
|
return identity.NopManager
|
||||||
}
|
}
|
||||||
return identity.NewManager(name)
|
return identity.NewManager(name, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c ConfigLanguage) ContentTypes() config.ContentTypesProvider {
|
func (c ConfigLanguage) ContentTypes() config.ContentTypesProvider {
|
||||||
|
|
|
@ -58,7 +58,7 @@ type AllProvider interface {
|
||||||
BuildDrafts() bool
|
BuildDrafts() bool
|
||||||
Running() bool
|
Running() bool
|
||||||
Watching() bool
|
Watching() bool
|
||||||
NewIdentityManager(name string) identity.Manager
|
NewIdentityManager(name string, opts ...identity.ManagerOption) identity.Manager
|
||||||
FastRenderMode() bool
|
FastRenderMode() bool
|
||||||
PrintUnusedTemplates() bool
|
PrintUnusedTemplates() bool
|
||||||
EnableMissingTranslationPlaceholders() bool
|
EnableMissingTranslationPlaceholders() bool
|
||||||
|
|
0
debug.log
Normal file
0
debug.log
Normal file
15
deps/deps.go
vendored
15
deps/deps.go
vendored
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -268,6 +269,20 @@ func (d *Deps) Compile(prototype *Deps) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MkdirTemp returns a temporary directory path that will be cleaned up on exit.
|
||||||
|
func (d Deps) MkdirTemp(pattern string) (string, error) {
|
||||||
|
filename, err := os.MkdirTemp("", pattern)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
d.BuildClosers.Add(types.CloserFunc(
|
||||||
|
func() error {
|
||||||
|
return os.RemoveAll(filename)
|
||||||
|
}))
|
||||||
|
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
|
|
||||||
type globalErrHandler struct {
|
type globalErrHandler struct {
|
||||||
logger loggers.Logger
|
logger loggers.Logger
|
||||||
|
|
||||||
|
|
|
@ -111,6 +111,10 @@ func (h *HugoSites) ShouldSkipFileChangeEvent(ev fsnotify.Event) bool {
|
||||||
return h.skipRebuildForFilenames[ev.Name]
|
return h.skipRebuildForFilenames[ev.Name]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HugoSites) Close() error {
|
||||||
|
return h.Deps.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HugoSites) isRebuild() bool {
|
func (h *HugoSites) isRebuild() bool {
|
||||||
return h.buildCounter.Load() > 0
|
return h.buildCounter.Load() > 0
|
||||||
}
|
}
|
||||||
|
|
|
@ -520,8 +520,9 @@ func (s *Site) executeDeferredTemplates(de *deps.DeferredExecutions) error {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
de.FilenamesWithPostPrefix.ForEeach(func(filename string, _ bool) {
|
de.FilenamesWithPostPrefix.ForEeach(func(filename string, _ bool) bool {
|
||||||
g.Enqueue(filename)
|
g.Enqueue(filename)
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
return g.Wait()
|
return g.Wait()
|
||||||
|
|
|
@ -83,6 +83,13 @@ func TestOptWithNFDOnDarwin() TestOpt {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOptWithOSFs enables the real file system.
|
||||||
|
func TestOptWithOSFs() TestOpt {
|
||||||
|
return func(c *IntegrationTestConfig) {
|
||||||
|
c.NeedsOsFS = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestOptWithWorkingDir allows setting any config optiona as a function al option.
|
// TestOptWithWorkingDir allows setting any config optiona as a function al option.
|
||||||
func TestOptWithConfig(fn func(c *IntegrationTestConfig)) TestOpt {
|
func TestOptWithConfig(fn func(c *IntegrationTestConfig)) TestOpt {
|
||||||
return func(c *IntegrationTestConfig) {
|
return func(c *IntegrationTestConfig) {
|
||||||
|
@ -277,8 +284,9 @@ func (s *IntegrationTestBuilder) negate(match string) (string, bool) {
|
||||||
func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...string) {
|
func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...string) {
|
||||||
s.Helper()
|
s.Helper()
|
||||||
content := strings.TrimSpace(s.FileContent(filename))
|
content := strings.TrimSpace(s.FileContent(filename))
|
||||||
|
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
cm := qt.Commentf("File: %s Match %s", filename, m)
|
cm := qt.Commentf("File: %s Match %s\nContent:\n%s", filename, m, content)
|
||||||
lines := strings.Split(m, "\n")
|
lines := strings.Split(m, "\n")
|
||||||
for _, match := range lines {
|
for _, match := range lines {
|
||||||
match = strings.TrimSpace(match)
|
match = strings.TrimSpace(match)
|
||||||
|
@ -288,7 +296,8 @@ func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...s
|
||||||
var negate bool
|
var negate bool
|
||||||
match, negate = s.negate(match)
|
match, negate = s.negate(match)
|
||||||
if negate {
|
if negate {
|
||||||
s.Assert(content, qt.Not(qt.Contains), match, cm)
|
if !s.Assert(content, qt.Not(qt.Contains), match, cm) {
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.Assert(content, qt.Contains, match, cm)
|
s.Assert(content, qt.Contains, match, cm)
|
||||||
|
@ -306,7 +315,8 @@ func (s *IntegrationTestBuilder) AssertFileContentExact(filename string, matches
|
||||||
s.Helper()
|
s.Helper()
|
||||||
content := s.FileContent(filename)
|
content := s.FileContent(filename)
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
s.Assert(content, qt.Contains, m, qt.Commentf(m))
|
cm := qt.Commentf("File: %s Match %s\nContent:\n%s", filename, m, content)
|
||||||
|
s.Assert(content, qt.Contains, m, cm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -443,6 +453,11 @@ func (s *IntegrationTestBuilder) Build() *IntegrationTestBuilder {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *IntegrationTestBuilder) Close() {
|
||||||
|
s.Helper()
|
||||||
|
s.Assert(s.H.Close(), qt.IsNil)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *IntegrationTestBuilder) LogString() string {
|
func (s *IntegrationTestBuilder) LogString() string {
|
||||||
return s.lastBuildLog
|
return s.lastBuildLog
|
||||||
}
|
}
|
||||||
|
|
|
@ -174,6 +174,7 @@ func (h *HugoSites) doNewPage(m *pageMeta) (*pageState, *paths.Path, error) {
|
||||||
return nil, m.wrapError(err, h.SourceFs)
|
return nil, m.wrapError(err, h.SourceFs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
ps := &pageState{
|
ps := &pageState{
|
||||||
pid: pid,
|
pid: pid,
|
||||||
pageOutput: nopPageOutput,
|
pageOutput: nopPageOutput,
|
||||||
|
|
|
@ -149,7 +149,15 @@ func (c *pagesCollector) Collect() (collectErr error) {
|
||||||
id.p,
|
id.p,
|
||||||
false,
|
false,
|
||||||
func(fim hugofs.FileMetaInfo) bool {
|
func(fim hugofs.FileMetaInfo) bool {
|
||||||
return true
|
if id.isStructuralChange() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
fimp := fim.Meta().PathInfo
|
||||||
|
if fimp == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return fimp.Path() == id.p.Path()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else if id.p.IsBranchBundle() {
|
} else if id.p.IsBranchBundle() {
|
||||||
|
|
|
@ -245,10 +245,11 @@ func (b *BuildState) resolveDeletedPaths() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var paths []string
|
var paths []string
|
||||||
b.sourceInfosPrevious.ForEeach(func(k string, _ *sourceInfo) {
|
b.sourceInfosPrevious.ForEeach(func(k string, _ *sourceInfo) bool {
|
||||||
if _, found := b.sourceInfosCurrent.Get(k); !found {
|
if _, found := b.sourceInfosCurrent.Get(k); !found {
|
||||||
paths = append(paths, k)
|
paths = append(paths, k)
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
b.DeletedPaths = paths
|
b.DeletedPaths = paths
|
||||||
|
|
|
@ -71,6 +71,18 @@ Foo.
|
||||||
|
|
||||||
`
|
`
|
||||||
|
|
||||||
|
func TestRebuildEditLeafBundleHeaderOnly(t *testing.T) {
|
||||||
|
b := TestRunning(t, rebuildFilesSimple)
|
||||||
|
b.AssertFileContent("public/mysection/mysectionbundle/index.html",
|
||||||
|
"My Section Bundle Content Content.")
|
||||||
|
|
||||||
|
b.EditFileReplaceAll("content/mysection/mysectionbundle/index.md", "My Section Bundle Content.", "My Section Bundle Content Edited.").Build()
|
||||||
|
b.AssertFileContent("public/mysection/mysectionbundle/index.html",
|
||||||
|
"My Section Bundle Content Edited.")
|
||||||
|
b.AssertRenderCountPage(1)
|
||||||
|
b.AssertRenderCountContent(1)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRebuildEditTextFileInLeafBundle(t *testing.T) {
|
func TestRebuildEditTextFileInLeafBundle(t *testing.T) {
|
||||||
b := TestRunning(t, rebuildFilesSimple)
|
b := TestRunning(t, rebuildFilesSimple)
|
||||||
b.AssertFileContent("public/mysection/mysectionbundle/index.html",
|
b.AssertFileContent("public/mysection/mysectionbundle/index.html",
|
||||||
|
|
|
@ -1513,7 +1513,11 @@ func (s *Site) renderForTemplate(ctx context.Context, name, outputFormat string,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = s.Tmpl().ExecuteWithContext(ctx, templ, w, d); err != nil {
|
if err = s.Tmpl().ExecuteWithContext(ctx, templ, w, d); err != nil {
|
||||||
return fmt.Errorf("render of %q failed: %w", name, err)
|
filename := name
|
||||||
|
if p, ok := d.(*pageState); ok {
|
||||||
|
filename = p.pathOrTitle()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("render of %q failed: %w", filename, err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,9 +82,8 @@ func FirstIdentity(v any) Identity {
|
||||||
var result Identity = Anonymous
|
var result Identity = Anonymous
|
||||||
WalkIdentitiesShallow(v, func(level int, id Identity) bool {
|
WalkIdentitiesShallow(v, func(level int, id Identity) bool {
|
||||||
result = id
|
result = id
|
||||||
return true
|
return result != Anonymous
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -308,11 +307,13 @@ type identityManager struct {
|
||||||
|
|
||||||
func (im *identityManager) AddIdentity(ids ...Identity) {
|
func (im *identityManager) AddIdentity(ids ...Identity) {
|
||||||
im.mu.Lock()
|
im.mu.Lock()
|
||||||
|
defer im.mu.Unlock()
|
||||||
|
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if id == nil || id == Anonymous {
|
if id == nil || id == Anonymous {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, found := im.ids[id]; !found {
|
if _, found := im.ids[id]; !found {
|
||||||
if im.onAddIdentity != nil {
|
if im.onAddIdentity != nil {
|
||||||
im.onAddIdentity(id)
|
im.onAddIdentity(id)
|
||||||
|
@ -320,7 +321,6 @@ func (im *identityManager) AddIdentity(ids ...Identity) {
|
||||||
im.ids[id] = true
|
im.ids[id] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
im.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (im *identityManager) AddIdentityForEach(ids ...ForEeachIdentityProvider) {
|
func (im *identityManager) AddIdentityForEach(ids ...ForEeachIdentityProvider) {
|
||||||
|
|
22
internal/js/esbuild/batch-esm-runner.gotmpl
Normal file
22
internal/js/esbuild/batch-esm-runner.gotmpl
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{{ range $i, $e := .Scripts -}}
|
||||||
|
{{ if eq .Export "*" }}
|
||||||
|
{{ printf "import %s as Script%d from %q;" .Export $i .Import }}
|
||||||
|
{{ else }}
|
||||||
|
{{ printf "import { %s as Script%d } from %q;" .Export $i .Import }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end -}}
|
||||||
|
{{ range $i, $e := .Runners }}
|
||||||
|
{{ printf "import { %s as Run%d } from %q;" .Export $i .Import }}
|
||||||
|
{{ end }}
|
||||||
|
{{/* */}}
|
||||||
|
{{ if .Runners }}
|
||||||
|
let scripts = [];
|
||||||
|
{{ range $i, $e := .Scripts -}}
|
||||||
|
scripts.push({{ .RunnerJSON $i }});
|
||||||
|
{{ end -}}
|
||||||
|
{{/* */}}
|
||||||
|
{{ range $i, $e := .Runners }}
|
||||||
|
{{ $id := printf "Run%d" $i }}
|
||||||
|
{{ $id }}(scripts);
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
1204
internal/js/esbuild/batch.go
Normal file
1204
internal/js/esbuild/batch.go
Normal file
File diff suppressed because it is too large
Load diff
428
internal/js/esbuild/batch_integration_test.go
Normal file
428
internal/js/esbuild/batch_integration_test.go
Normal file
|
@ -0,0 +1,428 @@
|
||||||
|
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
// Package js provides functions for building JavaScript resources
|
||||||
|
package esbuild_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
qt "github.com/frankban/quicktest"
|
||||||
|
|
||||||
|
"github.com/bep/logg"
|
||||||
|
"github.com/gohugoio/hugo/hugolib"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Used to test misc. error situations etc.
|
||||||
|
const jsBatchFilesTemplate = `
|
||||||
|
-- hugo.toml --
|
||||||
|
disableKinds = ["taxonomy", "term", "section", "page"]
|
||||||
|
-- assets/js/styles.css --
|
||||||
|
body {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
-- assets/js/main.js --
|
||||||
|
import './styles.css';
|
||||||
|
console.log("Hello, Main!");
|
||||||
|
-- assets/js/runner.js --
|
||||||
|
console.log("Hello, Runner!");
|
||||||
|
-- layouts/index.html --
|
||||||
|
{{ $batch := (js.Batch "mybatch" site.Home.Store) }}
|
||||||
|
{{ with $batch.Group "mygroup" }}
|
||||||
|
{{ with .Runner "run" }}
|
||||||
|
{{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }}
|
||||||
|
{{ end }}
|
||||||
|
{{ with .Script "main" }}
|
||||||
|
{{ .SetOptions (dict "resource" (resources.Get "js/main.js")) }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ with (templates.Defer (dict "key" "global")) }}
|
||||||
|
Defer:
|
||||||
|
{{ $batch := (js.Batch "mybatch" site.Home.Store) }}
|
||||||
|
{{ range $k, $v := $batch.Build.Groups }}
|
||||||
|
{{ $k }}:
|
||||||
|
{{ range . }}
|
||||||
|
RelPermalink: {{ .RelPermalink }}
|
||||||
|
Content: {{ .Content | safeHTML }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
`
|
||||||
|
|
||||||
|
// Just to verify that the above file setup works.
|
||||||
|
func TestBatchTemplateOKBuild(t *testing.T) {
|
||||||
|
b := hugolib.Test(t, jsBatchFilesTemplate, hugolib.TestOptWithOSFs())
|
||||||
|
b.AssertPublishDir("mybatch_mygroup.js", "mybatch_mygroup.css")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchErrorScriptResourceNotSet(t *testing.T) {
|
||||||
|
files := strings.Replace(jsBatchFilesTemplate, `(resources.Get "js/main.js")`, `(resources.Get "js/doesnotexist.js")`, 1)
|
||||||
|
b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
|
||||||
|
b.Assert(err, qt.IsNotNil)
|
||||||
|
b.Assert(err.Error(), qt.Contains, `failed to build Batch "mybatch": failed to compile Script for "mygroup" > "main": resource not set`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchErrorRunnerResourceNotSet(t *testing.T) {
|
||||||
|
files := strings.Replace(jsBatchFilesTemplate, `(resources.Get "js/runner.js")`, `(resources.Get "js/doesnotexist.js")`, 1)
|
||||||
|
b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
|
||||||
|
b.Assert(err, qt.IsNotNil)
|
||||||
|
b.Assert(err.Error(), qt.Contains, `failed to build Batch "mybatch": failed to compile Runner for "mygroup" > "run": resource not set`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchErrorScriptResourceSyntaxError(t *testing.T) {
|
||||||
|
files := strings.Replace(jsBatchFilesTemplate, `console.log("Hello, Main!");`, `console.flog("Hello, Main!"`, 1)
|
||||||
|
b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
|
||||||
|
b.Assert(err, qt.IsNotNil)
|
||||||
|
b.Assert(err.Error(), qt.Contains, `failed to build Batch "mybatch": failed to build bundle: "/js/mybatch_mygroup_main.js:2:27": Expected ")" but found end of file`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatch(t *testing.T) {
|
||||||
|
files := `
|
||||||
|
-- hugo.toml --
|
||||||
|
disableKinds = ["taxonomy", "term"]
|
||||||
|
disableLiveReload = true
|
||||||
|
baseURL = "https://example.com"
|
||||||
|
-- package.json --
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
-- assets/js/shims/react.js --
|
||||||
|
-- assets/js/shims/react-dom.js --
|
||||||
|
module.exports = window.ReactDOM;
|
||||||
|
module.exports = window.React;
|
||||||
|
-- content/mybundle/index.md --
|
||||||
|
---
|
||||||
|
title: "My Bundle"
|
||||||
|
---
|
||||||
|
-- content/mybundle/mybundlestyles.css --
|
||||||
|
@import './foo.css';
|
||||||
|
@import './bar.css';
|
||||||
|
@import './otherbundlestyles.css';
|
||||||
|
|
||||||
|
.mybundlestyles {
|
||||||
|
background-color: blue;
|
||||||
|
}
|
||||||
|
-- content/mybundle/bundlereact.jsx --
|
||||||
|
import * as React from "react";
|
||||||
|
import './foo.css';
|
||||||
|
import './mybundlestyles.css';
|
||||||
|
window.React1 = React;
|
||||||
|
|
||||||
|
let text = 'Click me, too!'
|
||||||
|
|
||||||
|
export default function MyBundleButton() {
|
||||||
|
return (
|
||||||
|
<button>${text}</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
-- assets/js/reactrunner.js --
|
||||||
|
import * as ReactDOM from 'react-dom/client';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export default function Run(modules) {
|
||||||
|
for (const module of modules) {
|
||||||
|
for (const instance of module.instances) {
|
||||||
|
/* This is a convention in this project. */
|
||||||
|
let elId = §§${module.id}-${instance.id}§§;
|
||||||
|
let el = document.getElementById(elId);
|
||||||
|
if (!el) {
|
||||||
|
console.warn(§§Element with id ${elId} not found§§);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const root = ReactDOM.createRoot(el);
|
||||||
|
const reactEl = React.createElement(module.mod, instance.params);
|
||||||
|
root.render(reactEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
-- assets/other/otherbundlestyles.css --
|
||||||
|
.otherbundlestyles {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
-- assets/other/foo.css --
|
||||||
|
@import './bar.css';
|
||||||
|
|
||||||
|
.foo {
|
||||||
|
background-color: blue;
|
||||||
|
}
|
||||||
|
-- assets/other/bar.css --
|
||||||
|
.bar {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
-- assets/js/button.css --
|
||||||
|
button {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
-- assets/js/bar.css --
|
||||||
|
.bar-assets {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
-- assets/js/helper.js --
|
||||||
|
import './bar.css'
|
||||||
|
|
||||||
|
export function helper() {
|
||||||
|
console.log('helper');
|
||||||
|
}
|
||||||
|
|
||||||
|
-- assets/js/react1styles_nested.css --
|
||||||
|
.react1styles_nested {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
-- assets/js/react1styles.css --
|
||||||
|
@import './react1styles_nested.css';
|
||||||
|
.react1styles {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
-- assets/js/react1.jsx --
|
||||||
|
import * as React from "react";
|
||||||
|
import './button.css'
|
||||||
|
import './foo.css'
|
||||||
|
import './react1styles.css'
|
||||||
|
|
||||||
|
window.React1 = React;
|
||||||
|
|
||||||
|
let text = 'Click me'
|
||||||
|
|
||||||
|
export default function MyButton() {
|
||||||
|
return (
|
||||||
|
<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';
|
||||||
|
|
||||||
|
console.log('main3.params.id', params.id)
|
||||||
|
|
||||||
|
export default function Main3() {};
|
||||||
|
|
||||||
|
-- layouts/_default/single.html --
|
||||||
|
Single.
|
||||||
|
{{ $r := .Resources.GetMatch "*.jsx" }}
|
||||||
|
{{ $batch := (js.Batch "mybundle" site.Home.Store) }}
|
||||||
|
{{ $otherCSS := (resources.Match "/other/*.css").Mount "/other" "." }}
|
||||||
|
{{ with $batch.Config }}
|
||||||
|
{{ $shims := dict "react" "js/shims/react.js" "react-dom/client" "js/shims/react-dom.js" }}
|
||||||
|
{{ .SetOptions (dict
|
||||||
|
"target" "es2018"
|
||||||
|
"params" (dict "id" "config")
|
||||||
|
"shims" $shims
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ end }}
|
||||||
|
{{ with $batch.Group "reactbatch" }}
|
||||||
|
{{ with .Script "r3" }}
|
||||||
|
{{ .SetOptions (dict
|
||||||
|
"resource" $r
|
||||||
|
"importContext" (slice $ $otherCSS)
|
||||||
|
"params" (dict "id" "r3")
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ end }}
|
||||||
|
{{ with .Instance "r3" "r2i1" }}
|
||||||
|
{{ .SetOptions (dict "title" "r2 instance 1")}}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
-- layouts/index.html --
|
||||||
|
Home.
|
||||||
|
{{ with (templates.Defer (dict "key" "global")) }}
|
||||||
|
{{ $batch := (js.Batch "mybundle" site.Home.Store) }}
|
||||||
|
{{ range $k, $v := $batch.Build.Groups }}
|
||||||
|
{{ $k }}:
|
||||||
|
{{ range . }}
|
||||||
|
{{ .RelPermalink }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ $myContentBundle := site.GetPage "mybundle" }}
|
||||||
|
{{ $batch := (js.Batch "mybundle" site.Home.Store) }}
|
||||||
|
{{ $otherCSS := (resources.Match "/other/*.css").Mount "/other" "." }}
|
||||||
|
{{ with $batch.Group "mains" }}
|
||||||
|
{{ with .Script "main1" }}
|
||||||
|
{{ .SetOptions (dict
|
||||||
|
"resource" (resources.Get "js/main1.js")
|
||||||
|
"params" (dict "id" "main1")
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ end }}
|
||||||
|
{{ with .Script "main2" }}
|
||||||
|
{{ .SetOptions (dict
|
||||||
|
"resource" (resources.Get "js/main2.js")
|
||||||
|
"params" (dict "id" "main2")
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ end }}
|
||||||
|
{{ with .Script "main3" }}
|
||||||
|
{{ .SetOptions (dict
|
||||||
|
"resource" (resources.Get "js/main3.js")
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ end }}
|
||||||
|
{{ with .Instance "main1" "m1i1" }}{{ .SetOptions (dict "params" (dict "title" "Main1 Instance 1"))}}{{ end }}
|
||||||
|
{{ with .Instance "main1" "m1i2" }}{{ .SetOptions (dict "params" (dict "title" "Main1 Instance 2"))}}{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ with $batch.Group "reactbatch" }}
|
||||||
|
{{ with .Runner "reactrunner" }}
|
||||||
|
{{ .SetOptions ( dict "resource" (resources.Get "js/reactrunner.js") )}}
|
||||||
|
{{ end }}
|
||||||
|
{{ with .Script "r1" }}
|
||||||
|
{{ .SetOptions (dict
|
||||||
|
"resource" (resources.Get "js/react1.jsx")
|
||||||
|
"importContext" (slice $myContentBundle $otherCSS)
|
||||||
|
"params" (dict "id" "r1")
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ end }}
|
||||||
|
{{ with .Instance "r1" "i1" }}{{ .SetOptions (dict "params" (dict "title" "Instance 1"))}}{{ end }}
|
||||||
|
{{ with .Instance "r1" "i2" }}{{ .SetOptions (dict "params" (dict "title" "Instance 2"))}}{{ end }}
|
||||||
|
{{ with .Script "r2" }}
|
||||||
|
{{ .SetOptions (dict
|
||||||
|
"resource" (resources.Get "js/react2.jsx")
|
||||||
|
"export" "MyOtherButton"
|
||||||
|
"importContext" $otherCSS
|
||||||
|
"params" (dict "id" "r2")
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ end }}
|
||||||
|
{{ with .Instance "r2" "i1" }}{{ .SetOptions (dict "params" (dict "title" "Instance 2-1"))}}{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
b := hugolib.NewIntegrationTestBuilder(
|
||||||
|
hugolib.IntegrationTestConfig{
|
||||||
|
T: t,
|
||||||
|
NeedsOsFS: true,
|
||||||
|
NeedsNpmInstall: true,
|
||||||
|
TxtarString: files,
|
||||||
|
Running: true,
|
||||||
|
LogLevel: logg.LevelWarn,
|
||||||
|
// PrintAndKeepTempDir: true,
|
||||||
|
}).Build()
|
||||||
|
|
||||||
|
fmt.Println(b.LogString())
|
||||||
|
|
||||||
|
b.AssertFileContent("public/mybundle_reactbatch.css",
|
||||||
|
".bar {",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify params resolution.
|
||||||
|
b.AssertFileContent("public/mybundle_mains.js",
|
||||||
|
`
|
||||||
|
var id = "main1";
|
||||||
|
console.log("main1.params.id", id);
|
||||||
|
var id2 = "main2";
|
||||||
|
console.log("main2.params.id", id2);
|
||||||
|
|
||||||
|
# Params from top level config.
|
||||||
|
var id3 = "config";
|
||||||
|
console.log("main3.params.id", id3);
|
||||||
|
`)
|
||||||
|
|
||||||
|
b.EditFileReplaceAll("content/mybundle/mybundlestyles.css", ".mybundlestyles", ".mybundlestyles-edit").Build()
|
||||||
|
b.AssertFileContent("public/mybundle_reactbatch.css", ".mybundlestyles-edit {")
|
||||||
|
|
||||||
|
b.EditFileReplaceAll("assets/other/bar.css", ".bar {", ".bar-edit {").Build()
|
||||||
|
b.AssertFileContent("public/mybundle_reactbatch.css", ".bar-edit {")
|
||||||
|
|
||||||
|
b.EditFileReplaceAll("assets/other/bar.css", ".bar-edit {", ".bar-edit2 {").Build()
|
||||||
|
b.AssertFileContent("public/mybundle_reactbatch.css", ".bar-edit2 {")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO1 move this.
|
||||||
|
func TestResourcesGet(t *testing.T) {
|
||||||
|
files := `
|
||||||
|
-- hugo.toml --
|
||||||
|
-- assets/text/txt1.txt --
|
||||||
|
Text 1.
|
||||||
|
-- assets/text/txt2.txt --
|
||||||
|
Text 2.
|
||||||
|
-- assets/text/sub/txt3.txt --
|
||||||
|
Text 3.
|
||||||
|
-- assets/text/sub/txt4.txt --
|
||||||
|
Text 4.
|
||||||
|
-- content/mybundle/index.md --
|
||||||
|
---
|
||||||
|
title: "My Bundle"
|
||||||
|
---
|
||||||
|
-- content/mybundle/txt1.txt --
|
||||||
|
Text 1.
|
||||||
|
-- content/mybundle/sub/txt2.txt --
|
||||||
|
Text 1.
|
||||||
|
-- layouts/index.html --
|
||||||
|
{{ $mybundle := site.GetPage "mybundle" }}
|
||||||
|
{{ $subResources := resources.Match "/text/sub/*.*" }}
|
||||||
|
{{ $subResourcesMount := $subResources.Mount "/text/sub" "/newroot" }}
|
||||||
|
resources:text/txt1.txt:{{ with resources.Get "text/txt1.txt" }}{{ .Name }}{{ end }}|
|
||||||
|
resources:text/txt2.txt:{{ with resources.Get "text/txt2.txt" }}{{ .Name }}{{ end }}|
|
||||||
|
resources:text/sub/txt3.txt:{{ with resources.Get "text/sub/txt3.txt" }}{{ .Name }}{{ end }}|
|
||||||
|
subResources.range:{{ range $subResources }}{{ .Name }}|{{ end }}|
|
||||||
|
subResources:"text/sub/txt3.txt:{{ with $subResources.Get "text/sub/txt3.txt" }}{{ .Name }}{{ end }}|
|
||||||
|
subResourcesMount:/newroot/txt3.txt:{{ with $subResourcesMount.Get "/newroot/txt3.txt" }}{{ .Name }}{{ end }}|
|
||||||
|
page:txt1.txt:{{ with $mybundle.Resources.Get "txt1.txt" }}{{ .Name }}{{ end }}|
|
||||||
|
page:./txt1.txt:{{ with $mybundle.Resources.Get "./txt1.txt" }}{{ .Name }}{{ end }}|
|
||||||
|
page:sub/txt2.txt:{{ with $mybundle.Resources.Get "sub/txt2.txt" }}{{ .Name }}{{ end }}|
|
||||||
|
`
|
||||||
|
b := hugolib.Test(t, files)
|
||||||
|
|
||||||
|
b.AssertFileContent("public/index.html", `
|
||||||
|
resources:text/txt1.txt:/text/txt1.txt|
|
||||||
|
resources:text/txt2.txt:/text/txt2.txt|
|
||||||
|
resources:text/sub/txt3.txt:/text/sub/txt3.txt|
|
||||||
|
subResources:"text/sub/txt3.txt:/text/sub/txt3.txt|
|
||||||
|
subResourcesMount:/newroot/txt3.txt:/text/sub/txt3.txt|
|
||||||
|
page:txt1.txt:txt1.txt|
|
||||||
|
page:./txt1.txt:txt1.txt|
|
||||||
|
page:sub/txt2.txt:sub/txt2.txt|
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO1 check .Name in bundles on renames.
|
181
internal/js/esbuild/build.go
Normal file
181
internal/js/esbuild/build.go
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
// Package esbuild provides functions for building JavaScript resources.
|
||||||
|
package esbuild
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/evanw/esbuild/pkg/api"
|
||||||
|
"github.com/gohugoio/hugo/common/herrors"
|
||||||
|
"github.com/gohugoio/hugo/common/hugio"
|
||||||
|
"github.com/gohugoio/hugo/common/text"
|
||||||
|
"github.com/gohugoio/hugo/hugofs"
|
||||||
|
"github.com/gohugoio/hugo/hugolib/filesystems"
|
||||||
|
"github.com/gohugoio/hugo/identity"
|
||||||
|
"github.com/gohugoio/hugo/resources"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewBuildClient creates a new BuildClient.
|
||||||
|
func NewBuildClient(fs *filesystems.SourceFilesystem, rs *resources.Spec) *BuildClient {
|
||||||
|
return &BuildClient{
|
||||||
|
rs: rs,
|
||||||
|
sfs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildClient struct {
|
||||||
|
rs *resources.Spec
|
||||||
|
sfs *filesystems.SourceFilesystem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BuildClient) Build(opts Options) (api.BuildResult, error) {
|
||||||
|
dependencyManager := opts.DependencyManager
|
||||||
|
if dependencyManager == nil {
|
||||||
|
dependencyManager = identity.NopManager
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.ResolveDir = c.rs.Cfg.BaseConfig().WorkingDir // where node_modules gets resolved
|
||||||
|
opts.TsConfig = c.rs.ResolveJSConfigFile("tsconfig.json")
|
||||||
|
|
||||||
|
if err := opts.validate(); err != nil {
|
||||||
|
return api.BuildResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
buildOptions, err := toBuildOptions(opts)
|
||||||
|
if err != nil {
|
||||||
|
return api.BuildResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
buildOptions.Plugins, err = createBuildPlugins(c.rs, dependencyManager, opts)
|
||||||
|
if err != nil {
|
||||||
|
return api.BuildResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if buildOptions.Sourcemap == api.SourceMapExternal && buildOptions.Outdir == "" {
|
||||||
|
buildOptions.Outdir, err = os.MkdirTemp(os.TempDir(), "compileOutput")
|
||||||
|
if err != nil {
|
||||||
|
return api.BuildResult{}, err
|
||||||
|
}
|
||||||
|
defer os.Remove(buildOptions.Outdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Inject != nil {
|
||||||
|
// Resolve the absolute filenames.
|
||||||
|
for i, ext := range opts.Inject {
|
||||||
|
impPath := filepath.FromSlash(ext)
|
||||||
|
if filepath.IsAbs(impPath) {
|
||||||
|
return api.BuildResult{}, fmt.Errorf("inject: absolute paths not supported, must be relative to /assets")
|
||||||
|
}
|
||||||
|
|
||||||
|
m := resolveComponentInAssets(c.rs.Assets.Fs, impPath)
|
||||||
|
|
||||||
|
if m == nil {
|
||||||
|
return api.BuildResult{}, fmt.Errorf("inject: file %q not found", ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.Inject[i] = m.Filename
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
buildOptions.Inject = opts.Inject
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
result := api.Build(buildOptions)
|
||||||
|
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
createErr := func(msg api.Message) error {
|
||||||
|
if msg.Location == nil {
|
||||||
|
return errors.New(msg.Text)
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
contentr hugio.ReadSeekCloser
|
||||||
|
errorMessage string
|
||||||
|
loc = msg.Location
|
||||||
|
errorPath = loc.File
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
var resolvedError *ErrorMessageResolved
|
||||||
|
|
||||||
|
if opts.ErrorMessageResolveFunc != nil {
|
||||||
|
resolvedError = opts.ErrorMessageResolveFunc(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolvedError == nil {
|
||||||
|
if errorPath == stdinImporter {
|
||||||
|
errorPath = "TODO1" // transformCtx.SourcePath
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = msg.Text
|
||||||
|
// TODO1 handle all namespaces, make more general
|
||||||
|
errorMessage = strings.ReplaceAll(errorMessage, NsHugoImport+":", "")
|
||||||
|
|
||||||
|
if strings.HasPrefix(errorPath, NsHugoImport) {
|
||||||
|
errorPath = strings.TrimPrefix(errorPath, NsHugoImport+":")
|
||||||
|
contentr, err = hugofs.Os.Open(errorPath)
|
||||||
|
} else {
|
||||||
|
var fi os.FileInfo
|
||||||
|
fi, err = c.sfs.Fs.Stat(errorPath)
|
||||||
|
if err == nil {
|
||||||
|
m := fi.(hugofs.FileMetaInfo).Meta()
|
||||||
|
errorPath = m.Filename
|
||||||
|
contentr, err = m.Open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentr = resolvedError.Content
|
||||||
|
errorPath = resolvedError.Path
|
||||||
|
errorMessage = resolvedError.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentr != nil {
|
||||||
|
defer contentr.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
fe := herrors.
|
||||||
|
NewFileErrorFromName(errors.New(errorMessage), errorPath).
|
||||||
|
UpdatePosition(text.Position{Offset: -1, LineNumber: loc.Line, ColumnNumber: loc.Column}).
|
||||||
|
UpdateContent(contentr, nil)
|
||||||
|
|
||||||
|
return fe
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%s", errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errors []error
|
||||||
|
|
||||||
|
for _, msg := range result.Errors {
|
||||||
|
errors = append(errors, createErr(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return 1, log the rest.
|
||||||
|
for i, err := range errors {
|
||||||
|
if i > 0 {
|
||||||
|
c.rs.Logger.Errorf("js.Build failed: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, errors[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
45
internal/js/esbuild/helpers.go
Normal file
45
internal/js/esbuild/helpers.go
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
// Package esbuild provides functions for building JavaScript resources.
|
||||||
|
package esbuild
|
||||||
|
|
||||||
|
import "github.com/gohugoio/hugo/lazy"
|
||||||
|
|
||||||
|
// getOnce is a helper to get a value once.
|
||||||
|
// Any invocation after the first one will return T's zero value.
|
||||||
|
// This is thread safe.
|
||||||
|
// Note that once can be reset.
|
||||||
|
type getOnce[T any] struct {
|
||||||
|
v T
|
||||||
|
once lazy.OnceMore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *getOnce[T]) Get() T {
|
||||||
|
var v T
|
||||||
|
g.once.Do(func() {
|
||||||
|
v = g.v
|
||||||
|
})
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *getOnce[T]) commit() T {
|
||||||
|
g.once.Do(func() {
|
||||||
|
})
|
||||||
|
return g.v
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO1 remove.
|
||||||
|
func deb(what string, v ...any) {
|
||||||
|
// fmt.Println(what, v)
|
||||||
|
}
|
301
internal/js/esbuild/options.go
Normal file
301
internal/js/esbuild/options.go
Normal file
|
@ -0,0 +1,301 @@
|
||||||
|
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package esbuild
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gohugoio/hugo/common/hugio"
|
||||||
|
"github.com/gohugoio/hugo/common/maps"
|
||||||
|
"github.com/gohugoio/hugo/common/paths"
|
||||||
|
"github.com/gohugoio/hugo/identity"
|
||||||
|
|
||||||
|
"github.com/evanw/esbuild/pkg/api"
|
||||||
|
|
||||||
|
"github.com/gohugoio/hugo/media"
|
||||||
|
"github.com/mitchellh/mapstructure"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
ExternalOptions
|
||||||
|
InternalOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opts *Options) validate() error {
|
||||||
|
if opts.ImportOnResolveFunc != nil && opts.ImportOnLoadFunc == nil {
|
||||||
|
return fmt.Errorf("ImportOnLoadFunc must be set if ImportOnResolveFunc is set")
|
||||||
|
}
|
||||||
|
if opts.ImportOnResolveFunc == nil && opts.ImportOnLoadFunc != nil {
|
||||||
|
return fmt.Errorf("ImportOnResolveFunc must be set if ImportOnLoadFunc is set")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InternalOptions holds internal options for the js.Build template function.
|
||||||
|
type InternalOptions struct {
|
||||||
|
MediaType media.Type
|
||||||
|
OutDir string
|
||||||
|
Contents string
|
||||||
|
SourceDir string
|
||||||
|
ResolveDir string
|
||||||
|
|
||||||
|
DependencyManager identity.Manager
|
||||||
|
|
||||||
|
Write bool // Set to false to write to memory.
|
||||||
|
AllowOverwrite bool
|
||||||
|
Splitting bool
|
||||||
|
TsConfig string
|
||||||
|
EntryPoints []string
|
||||||
|
ImportOnResolveFunc func(identity.Manager, string, api.OnResolveArgs) string
|
||||||
|
ImportOnLoadFunc func(api.OnLoadArgs) string
|
||||||
|
ImportParamsOnLoadFunc func(args api.OnLoadArgs) json.RawMessage
|
||||||
|
ErrorMessageResolveFunc func(api.Message) *ErrorMessageResolved
|
||||||
|
Stdin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorMessageResolved struct {
|
||||||
|
Path string
|
||||||
|
Message string
|
||||||
|
Content hugio.ReadSeekCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExternalOptions holds user facing options for the js.Build template function.
|
||||||
|
type ExternalOptions struct {
|
||||||
|
// If not set, the source path will be used as the base target path.
|
||||||
|
// Note that the target path's extension may change if the target MIME type
|
||||||
|
// is different, e.g. when the source is TypeScript.
|
||||||
|
TargetPath string
|
||||||
|
|
||||||
|
// Whether to minify to output.
|
||||||
|
Minify bool
|
||||||
|
|
||||||
|
// Whether to write mapfiles
|
||||||
|
SourceMap string
|
||||||
|
|
||||||
|
// The language target.
|
||||||
|
// One of: es2015, es2016, es2017, es2018, es2019, es2020 or esnext.
|
||||||
|
// Default is esnext.
|
||||||
|
Target string
|
||||||
|
|
||||||
|
// The output format.
|
||||||
|
// One of: iife, cjs, esm
|
||||||
|
// Default is to esm.
|
||||||
|
Format string
|
||||||
|
|
||||||
|
// External dependencies, e.g. "react".
|
||||||
|
Externals []string
|
||||||
|
|
||||||
|
// This option allows you to automatically replace a global variable with an import from another file.
|
||||||
|
// The filenames must be relative to /assets.
|
||||||
|
// See https://esbuild.github.io/api/#inject
|
||||||
|
Inject []string
|
||||||
|
|
||||||
|
// User defined symbols.
|
||||||
|
Defines map[string]any
|
||||||
|
|
||||||
|
// Maps a component import to another.
|
||||||
|
Shims map[string]string
|
||||||
|
|
||||||
|
// User defined params. Will be marshaled to JSON and available as "@params", e.g.
|
||||||
|
// import * as params from '@params';
|
||||||
|
Params any
|
||||||
|
|
||||||
|
// What to use instead of React.createElement.
|
||||||
|
JSXFactory string
|
||||||
|
|
||||||
|
// What to use instead of React.Fragment.
|
||||||
|
JSXFragment string
|
||||||
|
|
||||||
|
// What to do about JSX syntax.
|
||||||
|
// See https://esbuild.github.io/api/#jsx
|
||||||
|
JSX string
|
||||||
|
|
||||||
|
// Which library to use to automatically import JSX helper functions from. Only works if JSX is set to automatic.
|
||||||
|
// See https://esbuild.github.io/api/#jsx-import-source
|
||||||
|
JSXImportSource string
|
||||||
|
|
||||||
|
// There is/was a bug in WebKit with severe performance issue with the tracking
|
||||||
|
// of TDZ checks in JavaScriptCore.
|
||||||
|
//
|
||||||
|
// Enabling this flag removes the TDZ and `const` assignment checks and
|
||||||
|
// may improve performance of larger JS codebases until the WebKit fix
|
||||||
|
// is in widespread use.
|
||||||
|
//
|
||||||
|
// See https://bugs.webkit.org/show_bug.cgi?id=199866
|
||||||
|
// Deprecated: This no longer have any effect and will be removed.
|
||||||
|
// TODO(bep) remove. See https://github.com/evanw/esbuild/commit/869e8117b499ca1dbfc5b3021938a53ffe934dba
|
||||||
|
AvoidTDZ bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeExternalOptions(m map[string]any) (ExternalOptions, error) {
|
||||||
|
var opts ExternalOptions
|
||||||
|
|
||||||
|
if err := mapstructure.WeakDecode(m, &opts); err != nil {
|
||||||
|
return opts, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.TargetPath != "" {
|
||||||
|
opts.TargetPath = paths.ToSlashTrimLeading(opts.TargetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.Target = strings.ToLower(opts.Target)
|
||||||
|
opts.Format = strings.ToLower(opts.Format)
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBuildOptions(opts Options) (buildOptions api.BuildOptions, err error) {
|
||||||
|
var target api.Target
|
||||||
|
switch opts.Target {
|
||||||
|
case "", "esnext":
|
||||||
|
target = api.ESNext
|
||||||
|
case "es5":
|
||||||
|
target = api.ES5
|
||||||
|
case "es6", "es2015":
|
||||||
|
target = api.ES2015
|
||||||
|
case "es2016":
|
||||||
|
target = api.ES2016
|
||||||
|
case "es2017":
|
||||||
|
target = api.ES2017
|
||||||
|
case "es2018":
|
||||||
|
target = api.ES2018
|
||||||
|
case "es2019":
|
||||||
|
target = api.ES2019
|
||||||
|
case "es2020":
|
||||||
|
target = api.ES2020
|
||||||
|
case "es2021":
|
||||||
|
target = api.ES2021
|
||||||
|
case "es2022":
|
||||||
|
target = api.ES2022
|
||||||
|
case "es2023":
|
||||||
|
target = api.ES2023
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("invalid target: %q", opts.Target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType := opts.MediaType
|
||||||
|
if mediaType.IsZero() {
|
||||||
|
mediaType = media.Builtin.JavascriptType
|
||||||
|
}
|
||||||
|
|
||||||
|
var loader api.Loader
|
||||||
|
switch mediaType.SubType {
|
||||||
|
// TODO(bep) ESBuild support a set of other loaders, but I currently fail
|
||||||
|
// to see the relevance. That may change as we start using this.
|
||||||
|
case media.Builtin.JavascriptType.SubType:
|
||||||
|
loader = api.LoaderJS
|
||||||
|
case media.Builtin.TypeScriptType.SubType:
|
||||||
|
loader = api.LoaderTS
|
||||||
|
case media.Builtin.TSXType.SubType:
|
||||||
|
loader = api.LoaderTSX
|
||||||
|
case media.Builtin.JSXType.SubType:
|
||||||
|
loader = api.LoaderJSX
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported Media Type: %q", opts.MediaType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var format api.Format
|
||||||
|
// One of: iife, cjs, esm
|
||||||
|
switch opts.Format {
|
||||||
|
case "", "iife":
|
||||||
|
format = api.FormatIIFE
|
||||||
|
case "esm":
|
||||||
|
format = api.FormatESModule
|
||||||
|
case "cjs":
|
||||||
|
format = api.FormatCommonJS
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported script output format: %q", opts.Format)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsx api.JSX
|
||||||
|
switch opts.JSX {
|
||||||
|
case "", "transform":
|
||||||
|
jsx = api.JSXTransform
|
||||||
|
case "preserve":
|
||||||
|
jsx = api.JSXPreserve
|
||||||
|
case "automatic":
|
||||||
|
jsx = api.JSXAutomatic
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported jsx type: %q", opts.JSX)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var defines map[string]string
|
||||||
|
if opts.Defines != nil {
|
||||||
|
defines = maps.ToStringMapString(opts.Defines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// By default we only need to specify outDir and no outFile
|
||||||
|
outDir := opts.OutDir
|
||||||
|
outFile := ""
|
||||||
|
var sourceMap api.SourceMap
|
||||||
|
switch opts.SourceMap {
|
||||||
|
case "inline":
|
||||||
|
sourceMap = api.SourceMapInline
|
||||||
|
case "external":
|
||||||
|
sourceMap = api.SourceMapExternal
|
||||||
|
case "":
|
||||||
|
sourceMap = api.SourceMapNone
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported sourcemap type: %q", opts.SourceMap)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buildOptions = api.BuildOptions{
|
||||||
|
Outfile: outFile,
|
||||||
|
Bundle: true,
|
||||||
|
Metafile: true, // TODO1 batch only.
|
||||||
|
|
||||||
|
Target: target,
|
||||||
|
Format: format,
|
||||||
|
Sourcemap: sourceMap,
|
||||||
|
|
||||||
|
MinifyWhitespace: opts.Minify,
|
||||||
|
MinifyIdentifiers: opts.Minify,
|
||||||
|
MinifySyntax: opts.Minify,
|
||||||
|
|
||||||
|
Outdir: outDir,
|
||||||
|
Write: opts.Write,
|
||||||
|
AllowOverwrite: opts.AllowOverwrite,
|
||||||
|
Splitting: opts.Splitting,
|
||||||
|
|
||||||
|
Define: defines,
|
||||||
|
External: opts.Externals,
|
||||||
|
|
||||||
|
JSXFactory: opts.JSXFactory,
|
||||||
|
JSXFragment: opts.JSXFragment,
|
||||||
|
|
||||||
|
JSX: jsx,
|
||||||
|
JSXImportSource: opts.JSXImportSource,
|
||||||
|
|
||||||
|
Tsconfig: opts.TsConfig,
|
||||||
|
|
||||||
|
EntryPoints: opts.EntryPoints,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Stdin {
|
||||||
|
// This makes ESBuild pass `stdin` as the Importer to the import.
|
||||||
|
buildOptions.Stdin = &api.StdinOptions{
|
||||||
|
Contents: opts.Contents,
|
||||||
|
ResolveDir: opts.ResolveDir,
|
||||||
|
Loader: loader,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
220
internal/js/esbuild/options_test.go
Normal file
220
internal/js/esbuild/options_test.go
Normal file
|
@ -0,0 +1,220 @@
|
||||||
|
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package esbuild
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gohugoio/hugo/media"
|
||||||
|
|
||||||
|
"github.com/evanw/esbuild/pkg/api"
|
||||||
|
|
||||||
|
qt "github.com/frankban/quicktest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToBuildOptions(t *testing.T) {
|
||||||
|
c := qt.New(t)
|
||||||
|
|
||||||
|
opts, err := toBuildOptions(
|
||||||
|
Options{
|
||||||
|
InternalOptions: InternalOptions{
|
||||||
|
MediaType: media.Builtin.JavascriptType,
|
||||||
|
Stdin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
c.Assert(err, qt.IsNil)
|
||||||
|
c.Assert(opts, qt.DeepEquals, api.BuildOptions{
|
||||||
|
Bundle: true,
|
||||||
|
Metafile: true,
|
||||||
|
Target: api.ESNext,
|
||||||
|
Format: api.FormatIIFE,
|
||||||
|
Stdin: &api.StdinOptions{
|
||||||
|
Loader: api.LoaderJS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
opts, err = toBuildOptions(
|
||||||
|
Options{
|
||||||
|
ExternalOptions: ExternalOptions{
|
||||||
|
Target: "es2018",
|
||||||
|
Format: "cjs",
|
||||||
|
Minify: true,
|
||||||
|
AvoidTDZ: true,
|
||||||
|
},
|
||||||
|
InternalOptions: InternalOptions{
|
||||||
|
MediaType: media.Builtin.JavascriptType,
|
||||||
|
Stdin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
c.Assert(err, qt.IsNil)
|
||||||
|
c.Assert(opts, qt.DeepEquals, api.BuildOptions{
|
||||||
|
Bundle: true,
|
||||||
|
Metafile: true,
|
||||||
|
Target: api.ES2018,
|
||||||
|
Format: api.FormatCommonJS,
|
||||||
|
MinifyIdentifiers: true,
|
||||||
|
MinifySyntax: true,
|
||||||
|
MinifyWhitespace: true,
|
||||||
|
Stdin: &api.StdinOptions{
|
||||||
|
Loader: api.LoaderJS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
opts, err = toBuildOptions(
|
||||||
|
Options{
|
||||||
|
ExternalOptions: ExternalOptions{
|
||||||
|
Target: "es2018", Format: "cjs", Minify: true,
|
||||||
|
SourceMap: "inline",
|
||||||
|
},
|
||||||
|
InternalOptions: InternalOptions{
|
||||||
|
MediaType: media.Builtin.JavascriptType,
|
||||||
|
Stdin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
c.Assert(err, qt.IsNil)
|
||||||
|
c.Assert(opts, qt.DeepEquals, api.BuildOptions{
|
||||||
|
Bundle: true,
|
||||||
|
Metafile: true,
|
||||||
|
Target: api.ES2018,
|
||||||
|
Format: api.FormatCommonJS,
|
||||||
|
MinifyIdentifiers: true,
|
||||||
|
MinifySyntax: true,
|
||||||
|
MinifyWhitespace: true,
|
||||||
|
Sourcemap: api.SourceMapInline,
|
||||||
|
Stdin: &api.StdinOptions{
|
||||||
|
Loader: api.LoaderJS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
opts, err = toBuildOptions(
|
||||||
|
Options{
|
||||||
|
ExternalOptions: ExternalOptions{
|
||||||
|
Target: "es2018", Format: "cjs", Minify: true,
|
||||||
|
SourceMap: "inline",
|
||||||
|
},
|
||||||
|
InternalOptions: InternalOptions{
|
||||||
|
MediaType: media.Builtin.JavascriptType,
|
||||||
|
Stdin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
c.Assert(err, qt.IsNil)
|
||||||
|
c.Assert(opts, qt.DeepEquals, api.BuildOptions{
|
||||||
|
Bundle: true,
|
||||||
|
Metafile: true,
|
||||||
|
Target: api.ES2018,
|
||||||
|
Format: api.FormatCommonJS,
|
||||||
|
MinifyIdentifiers: true,
|
||||||
|
MinifySyntax: true,
|
||||||
|
MinifyWhitespace: true,
|
||||||
|
Sourcemap: api.SourceMapInline,
|
||||||
|
Stdin: &api.StdinOptions{
|
||||||
|
Loader: api.LoaderJS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
opts, err = toBuildOptions(
|
||||||
|
Options{
|
||||||
|
ExternalOptions: ExternalOptions{
|
||||||
|
Target: "es2018", Format: "cjs", Minify: true,
|
||||||
|
SourceMap: "external",
|
||||||
|
},
|
||||||
|
InternalOptions: InternalOptions{
|
||||||
|
MediaType: media.Builtin.JavascriptType,
|
||||||
|
Stdin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
c.Assert(err, qt.IsNil)
|
||||||
|
c.Assert(opts, qt.DeepEquals, api.BuildOptions{
|
||||||
|
Bundle: true,
|
||||||
|
Metafile: true,
|
||||||
|
Target: api.ES2018,
|
||||||
|
Format: api.FormatCommonJS,
|
||||||
|
MinifyIdentifiers: true,
|
||||||
|
MinifySyntax: true,
|
||||||
|
MinifyWhitespace: true,
|
||||||
|
Sourcemap: api.SourceMapExternal,
|
||||||
|
Stdin: &api.StdinOptions{
|
||||||
|
Loader: api.LoaderJS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
opts, err = toBuildOptions(
|
||||||
|
Options{
|
||||||
|
ExternalOptions: ExternalOptions{
|
||||||
|
JSX: "automatic", JSXImportSource: "preact",
|
||||||
|
},
|
||||||
|
InternalOptions: InternalOptions{
|
||||||
|
MediaType: media.Builtin.JavascriptType,
|
||||||
|
Stdin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
c.Assert(err, qt.IsNil)
|
||||||
|
c.Assert(opts, qt.DeepEquals, api.BuildOptions{
|
||||||
|
Bundle: true,
|
||||||
|
Metafile: true,
|
||||||
|
Target: api.ESNext,
|
||||||
|
Format: api.FormatIIFE,
|
||||||
|
Stdin: &api.StdinOptions{
|
||||||
|
Loader: api.LoaderJS,
|
||||||
|
},
|
||||||
|
JSX: api.JSXAutomatic,
|
||||||
|
JSXImportSource: "preact",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToBuildOptionsTarget(t *testing.T) {
|
||||||
|
c := qt.New(t)
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
target string
|
||||||
|
expect api.Target
|
||||||
|
}{
|
||||||
|
{"es2015", api.ES2015},
|
||||||
|
{"es2016", api.ES2016},
|
||||||
|
{"es2017", api.ES2017},
|
||||||
|
{"es2018", api.ES2018},
|
||||||
|
{"es2019", api.ES2019},
|
||||||
|
{"es2020", api.ES2020},
|
||||||
|
{"es2021", api.ES2021},
|
||||||
|
{"es2022", api.ES2022},
|
||||||
|
{"es2023", api.ES2023},
|
||||||
|
{"", api.ESNext},
|
||||||
|
{"esnext", api.ESNext},
|
||||||
|
} {
|
||||||
|
c.Run(test.target, func(c *qt.C) {
|
||||||
|
opts, err := toBuildOptions(
|
||||||
|
Options{
|
||||||
|
ExternalOptions: ExternalOptions{
|
||||||
|
Target: test.target,
|
||||||
|
},
|
||||||
|
InternalOptions: InternalOptions{
|
||||||
|
MediaType: media.Builtin.JavascriptType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
c.Assert(err, qt.IsNil)
|
||||||
|
c.Assert(opts.Target, qt.Equals, test.expect)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
275
internal/js/esbuild/resolve.go
Normal file
275
internal/js/esbuild/resolve.go
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package esbuild
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/evanw/esbuild/pkg/api"
|
||||||
|
"github.com/gohugoio/hugo/hugofs"
|
||||||
|
"github.com/gohugoio/hugo/identity"
|
||||||
|
"github.com/gohugoio/hugo/resources"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
NsHugoImport = "ns-hugo-import"
|
||||||
|
NsHugoImportResolveFunc = "ns-hugo-import-resolvefunc"
|
||||||
|
nsHugoParams = "ns-hugo-params"
|
||||||
|
|
||||||
|
stdinImporter = "<stdin>"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PrefixHugoVirtual = "@hugo-virtual"
|
||||||
|
)
|
||||||
|
|
||||||
|
var extensionToLoaderMap = map[string]api.Loader{
|
||||||
|
".js": api.LoaderJS,
|
||||||
|
".mjs": api.LoaderJS,
|
||||||
|
".cjs": api.LoaderJS,
|
||||||
|
".jsx": api.LoaderJSX,
|
||||||
|
".ts": api.LoaderTS,
|
||||||
|
".tsx": api.LoaderTSX,
|
||||||
|
".css": api.LoaderCSS,
|
||||||
|
".json": api.LoaderJSON,
|
||||||
|
".txt": api.LoaderText,
|
||||||
|
}
|
||||||
|
|
||||||
|
func loaderFromFilename(filename string) api.Loader {
|
||||||
|
l, found := extensionToLoaderMap[filepath.Ext(filename)]
|
||||||
|
if found {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return api.LoaderJS
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveComponentInAssets(fs afero.Fs, impPath string) *hugofs.FileMeta {
|
||||||
|
findFirst := func(base string) *hugofs.FileMeta {
|
||||||
|
// This is the most common sub-set of ESBuild's default extensions.
|
||||||
|
// We assume that imports of JSON, CSS etc. will be using their full
|
||||||
|
// name with extension.
|
||||||
|
for _, ext := range []string{".js", ".ts", ".tsx", ".jsx"} {
|
||||||
|
if strings.HasSuffix(impPath, ext) {
|
||||||
|
// Import of foo.js.js need the full name.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fi, err := fs.Stat(base + ext); err == nil {
|
||||||
|
return fi.(hugofs.FileMetaInfo).Meta()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var m *hugofs.FileMeta
|
||||||
|
|
||||||
|
// We need to check if this is a regular file imported without an extension.
|
||||||
|
// There may be ambiguous situations where both foo.js and foo/index.js exists.
|
||||||
|
// This import order is in line with both how Node and ESBuild's native
|
||||||
|
// import resolver works.
|
||||||
|
|
||||||
|
// It may be a regular file imported without an extension, e.g.
|
||||||
|
// foo or foo/index.
|
||||||
|
m = findFirst(impPath)
|
||||||
|
if m != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
base := filepath.Base(impPath)
|
||||||
|
if base == "index" {
|
||||||
|
// try index.esm.js etc.
|
||||||
|
m = findFirst(impPath + ".esm")
|
||||||
|
if m != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the path as is.
|
||||||
|
fi, err := fs.Stat(impPath)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
if fi.IsDir() {
|
||||||
|
m = findFirst(filepath.Join(impPath, "index"))
|
||||||
|
if m == nil {
|
||||||
|
m = findFirst(filepath.Join(impPath, "index.esm"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m = fi.(hugofs.FileMetaInfo).Meta()
|
||||||
|
}
|
||||||
|
} else if strings.HasSuffix(base, ".js") {
|
||||||
|
m = findFirst(strings.TrimSuffix(impPath, ".js"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBuildPlugins(rs *resources.Spec, depsManager identity.Manager, opts Options) ([]api.Plugin, error) {
|
||||||
|
fs := rs.Assets
|
||||||
|
|
||||||
|
resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
|
||||||
|
impPath := args.Path
|
||||||
|
shimmed := false
|
||||||
|
if opts.Shims != nil {
|
||||||
|
override, found := opts.Shims[impPath]
|
||||||
|
if found {
|
||||||
|
impPath = override
|
||||||
|
shimmed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ImportOnResolveFunc != nil {
|
||||||
|
if s := opts.ImportOnResolveFunc(depsManager, impPath, args); s != "" {
|
||||||
|
return api.OnResolveResult{Path: s, Namespace: NsHugoImportResolveFunc}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
importer := args.Importer
|
||||||
|
|
||||||
|
isStdin := importer == stdinImporter
|
||||||
|
var relDir string
|
||||||
|
if !isStdin {
|
||||||
|
if strings.HasPrefix(importer, PrefixHugoVirtual) {
|
||||||
|
relDir = filepath.Dir(strings.TrimPrefix(importer, PrefixHugoVirtual))
|
||||||
|
} else {
|
||||||
|
rel, found := fs.MakePathRelative(importer, true)
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
if shimmed {
|
||||||
|
relDir = opts.SourceDir
|
||||||
|
} else {
|
||||||
|
// Not in any of the /assets folders.
|
||||||
|
// This is an import from a node_modules, let
|
||||||
|
// ESBuild resolve this.
|
||||||
|
return api.OnResolveResult{}, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
relDir = filepath.Dir(rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
relDir = opts.SourceDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imports not starting with a "." is assumed to live relative to /assets.
|
||||||
|
// Hugo makes no assumptions about the directory structure below /assets.
|
||||||
|
if relDir != "" && strings.HasPrefix(impPath, ".") {
|
||||||
|
impPath = filepath.Join(relDir, impPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := resolveComponentInAssets(fs.Fs, impPath)
|
||||||
|
|
||||||
|
if m != nil {
|
||||||
|
depsManager.AddIdentity(m.PathInfo)
|
||||||
|
|
||||||
|
// Store the source root so we can create a jsconfig.json
|
||||||
|
// to help IntelliSense when the build is done.
|
||||||
|
// This should be a small number of elements, and when
|
||||||
|
// in server mode, we may get stale entries on renames etc.,
|
||||||
|
// but that shouldn't matter too much.
|
||||||
|
rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)
|
||||||
|
return api.OnResolveResult{Path: m.Filename, Namespace: NsHugoImport}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to ESBuild's resolve.
|
||||||
|
return api.OnResolveResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
importResolver := api.Plugin{
|
||||||
|
Name: "hugo-import-resolver",
|
||||||
|
Setup: func(build api.PluginBuild) {
|
||||||
|
build.OnResolve(api.OnResolveOptions{Filter: `.*`},
|
||||||
|
func(args api.OnResolveArgs) (api.OnResolveResult, error) {
|
||||||
|
return resolveImport(args)
|
||||||
|
})
|
||||||
|
build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: NsHugoImport},
|
||||||
|
func(args api.OnLoadArgs) (api.OnLoadResult, error) {
|
||||||
|
b, err := os.ReadFile(args.Path)
|
||||||
|
if err != nil {
|
||||||
|
return api.OnLoadResult{}, fmt.Errorf("failed to read %q: %w", args.Path, err)
|
||||||
|
}
|
||||||
|
c := string(b)
|
||||||
|
|
||||||
|
return api.OnLoadResult{
|
||||||
|
// See https://github.com/evanw/esbuild/issues/502
|
||||||
|
// This allows all modules to resolve dependencies
|
||||||
|
// in the main project's node_modules.
|
||||||
|
ResolveDir: opts.ResolveDir,
|
||||||
|
Contents: &c,
|
||||||
|
Loader: loaderFromFilename(args.Path),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: NsHugoImportResolveFunc},
|
||||||
|
func(args api.OnLoadArgs) (api.OnLoadResult, error) {
|
||||||
|
c := opts.ImportOnLoadFunc(args)
|
||||||
|
if c == "" {
|
||||||
|
return api.OnLoadResult{}, fmt.Errorf("ImportOnLoadFunc failed to resolve %q", args.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.OnLoadResult{
|
||||||
|
ResolveDir: opts.ResolveDir,
|
||||||
|
Contents: &c,
|
||||||
|
Loader: loaderFromFilename(args.Path),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
params := opts.Params
|
||||||
|
if params == nil {
|
||||||
|
// This way @params will always resolve to something.
|
||||||
|
params = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal params: %w", err)
|
||||||
|
}
|
||||||
|
paramsPlugin := api.Plugin{
|
||||||
|
Name: "hugo-params-plugin",
|
||||||
|
Setup: func(build api.PluginBuild) {
|
||||||
|
build.OnResolve(api.OnResolveOptions{Filter: `^@params$`},
|
||||||
|
func(args api.OnResolveArgs) (api.OnResolveResult, error) {
|
||||||
|
return api.OnResolveResult{
|
||||||
|
Path: args.Importer,
|
||||||
|
Namespace: nsHugoParams,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsHugoParams},
|
||||||
|
func(args api.OnLoadArgs) (api.OnLoadResult, error) {
|
||||||
|
bb := b
|
||||||
|
|
||||||
|
if opts.ImportParamsOnLoadFunc != nil {
|
||||||
|
if bbb := opts.ImportParamsOnLoadFunc(args); bbb != nil {
|
||||||
|
bb = bbb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s := string(bb)
|
||||||
|
|
||||||
|
return api.OnLoadResult{
|
||||||
|
Contents: &s,
|
||||||
|
Loader: api.LoaderJSON,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return []api.Plugin{importResolver, paramsPlugin}, nil
|
||||||
|
}
|
85
internal/js/esbuild/resolve_test.go
Normal file
85
internal/js/esbuild/resolve_test.go
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package esbuild
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
qt "github.com/frankban/quicktest"
|
||||||
|
"github.com/gohugoio/hugo/config"
|
||||||
|
"github.com/gohugoio/hugo/config/testconfig"
|
||||||
|
"github.com/gohugoio/hugo/hugofs"
|
||||||
|
"github.com/gohugoio/hugo/hugolib/filesystems"
|
||||||
|
"github.com/gohugoio/hugo/hugolib/paths"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveComponentInAssets(t *testing.T) {
|
||||||
|
c := qt.New(t)
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
files []string
|
||||||
|
impPath string
|
||||||
|
expect string
|
||||||
|
}{
|
||||||
|
{"Basic, extension", []string{"foo.js", "bar.js"}, "foo.js", "foo.js"},
|
||||||
|
{"Basic, no extension", []string{"foo.js", "bar.js"}, "foo", "foo.js"},
|
||||||
|
{"Basic, no extension, typescript", []string{"foo.ts", "bar.js"}, "foo", "foo.ts"},
|
||||||
|
{"Not found", []string{"foo.js", "bar.js"}, "moo.js", ""},
|
||||||
|
{"Not found, double js extension", []string{"foo.js.js", "bar.js"}, "foo.js", ""},
|
||||||
|
{"Index file, folder only", []string{"foo/index.js", "bar.js"}, "foo", "foo/index.js"},
|
||||||
|
{"Index file, folder and index", []string{"foo/index.js", "bar.js"}, "foo/index", "foo/index.js"},
|
||||||
|
{"Index file, folder and index and suffix", []string{"foo/index.js", "bar.js"}, "foo/index.js", "foo/index.js"},
|
||||||
|
{"Index ESM file, folder only", []string{"foo/index.esm.js", "bar.js"}, "foo", "foo/index.esm.js"},
|
||||||
|
{"Index ESM file, folder and index", []string{"foo/index.esm.js", "bar.js"}, "foo/index", "foo/index.esm.js"},
|
||||||
|
{"Index ESM file, folder and index and suffix", []string{"foo/index.esm.js", "bar.js"}, "foo/index.esm.js", "foo/index.esm.js"},
|
||||||
|
// We added these index.esm.js cases in v0.101.0. The case below is unlikely to happen in the wild, but add a test
|
||||||
|
// to document Hugo's behavior. We pick the file with the name index.js; anything else would be breaking.
|
||||||
|
{"Index and Index ESM file, folder only", []string{"foo/index.esm.js", "foo/index.js", "bar.js"}, "foo", "foo/index.js"},
|
||||||
|
|
||||||
|
// Issue #8949
|
||||||
|
{"Check file before directory", []string{"foo.js", "foo/index.js"}, "foo", "foo.js"},
|
||||||
|
} {
|
||||||
|
c.Run(test.name, func(c *qt.C) {
|
||||||
|
baseDir := "assets"
|
||||||
|
mfs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
for _, filename := range test.files {
|
||||||
|
c.Assert(afero.WriteFile(mfs, filepath.Join(baseDir, filename), []byte("let foo='bar';"), 0o777), qt.IsNil)
|
||||||
|
}
|
||||||
|
|
||||||
|
conf := testconfig.GetTestConfig(mfs, config.New())
|
||||||
|
fs := hugofs.NewFrom(mfs, conf.BaseConfig())
|
||||||
|
|
||||||
|
p, err := paths.New(fs, conf)
|
||||||
|
c.Assert(err, qt.IsNil)
|
||||||
|
bfs, err := filesystems.NewBase(p, nil)
|
||||||
|
c.Assert(err, qt.IsNil)
|
||||||
|
|
||||||
|
got := resolveComponentInAssets(bfs.Assets.Fs, test.impPath)
|
||||||
|
|
||||||
|
gotPath := ""
|
||||||
|
expect := test.expect
|
||||||
|
if got != nil {
|
||||||
|
gotPath = filepath.ToSlash(got.Filename)
|
||||||
|
expect = path.Join(baseDir, test.expect)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Assert(gotPath, qt.Equals, expect)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -273,9 +273,13 @@ func (t Types) GetByType(tp string) (Type, bool) {
|
||||||
return Type{}, false
|
return Type{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t Types) normalizeSuffix(s string) string {
|
||||||
|
return strings.ToLower(strings.TrimPrefix(s, "."))
|
||||||
|
}
|
||||||
|
|
||||||
// BySuffix will return all media types matching a suffix.
|
// BySuffix will return all media types matching a suffix.
|
||||||
func (t Types) BySuffix(suffix string) []Type {
|
func (t Types) BySuffix(suffix string) []Type {
|
||||||
suffix = strings.ToLower(suffix)
|
suffix = t.normalizeSuffix(suffix)
|
||||||
var types []Type
|
var types []Type
|
||||||
for _, tt := range t {
|
for _, tt := range t {
|
||||||
if tt.hasSuffix(suffix) {
|
if tt.hasSuffix(suffix) {
|
||||||
|
@ -287,7 +291,7 @@ func (t Types) BySuffix(suffix string) []Type {
|
||||||
|
|
||||||
// GetFirstBySuffix will return the first type matching the given suffix.
|
// GetFirstBySuffix will return the first type matching the given suffix.
|
||||||
func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) {
|
func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) {
|
||||||
suffix = strings.ToLower(suffix)
|
suffix = t.normalizeSuffix(suffix)
|
||||||
for _, tt := range t {
|
for _, tt := range t {
|
||||||
if tt.hasSuffix(suffix) {
|
if tt.hasSuffix(suffix) {
|
||||||
return tt, SuffixInfo{
|
return tt, SuffixInfo{
|
||||||
|
@ -304,7 +308,7 @@ func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) {
|
||||||
// is ambiguous.
|
// is ambiguous.
|
||||||
// The lookup is case insensitive.
|
// The lookup is case insensitive.
|
||||||
func (t Types) GetBySuffix(suffix string) (tp Type, si SuffixInfo, found bool) {
|
func (t Types) GetBySuffix(suffix string) (tp Type, si SuffixInfo, found bool) {
|
||||||
suffix = strings.ToLower(suffix)
|
suffix = t.normalizeSuffix(suffix)
|
||||||
for _, tt := range t {
|
for _, tt := range t {
|
||||||
if tt.hasSuffix(suffix) {
|
if tt.hasSuffix(suffix) {
|
||||||
if found {
|
if found {
|
||||||
|
@ -324,7 +328,7 @@ func (t Types) GetBySuffix(suffix string) (tp Type, si SuffixInfo, found bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Types) IsTextSuffix(suffix string) bool {
|
func (t Types) IsTextSuffix(suffix string) bool {
|
||||||
suffix = strings.ToLower(suffix)
|
suffix = t.normalizeSuffix(suffix)
|
||||||
for _, tt := range t {
|
for _, tt := range t {
|
||||||
if tt.hasSuffix(suffix) {
|
if tt.hasSuffix(suffix) {
|
||||||
return tt.IsText()
|
return tt.IsText()
|
||||||
|
|
|
@ -120,6 +120,14 @@ var (
|
||||||
Rel: "alternate",
|
Rel: "alternate",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoTmplFormat = Format{
|
||||||
|
Name: "gotmpl",
|
||||||
|
MediaType: media.Builtin.TextType,
|
||||||
|
BaseName: "index",
|
||||||
|
IsPlainText: true,
|
||||||
|
NotAlternative: true,
|
||||||
|
}
|
||||||
|
|
||||||
HTMLFormat = Format{
|
HTMLFormat = Format{
|
||||||
Name: "html",
|
Name: "html",
|
||||||
MediaType: media.Builtin.HTMLType,
|
MediaType: media.Builtin.HTMLType,
|
||||||
|
@ -215,6 +223,7 @@ var DefaultFormats = Formats{
|
||||||
RobotsTxtFormat,
|
RobotsTxtFormat,
|
||||||
RSSFormat,
|
RSSFormat,
|
||||||
SitemapFormat,
|
SitemapFormat,
|
||||||
|
GoTmplFormat,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
@ -71,8 +71,7 @@ type ChildCareProvider interface {
|
||||||
// section.
|
// section.
|
||||||
RegularPagesRecursive() Pages
|
RegularPagesRecursive() Pages
|
||||||
|
|
||||||
// Resources returns a list of all resources.
|
resource.ResourcesProvider
|
||||||
Resources() resource.Resources
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MarkupProvider interface {
|
type MarkupProvider interface {
|
||||||
|
|
|
@ -47,6 +47,7 @@ var (
|
||||||
_ resource.Cloner = (*genericResource)(nil)
|
_ resource.Cloner = (*genericResource)(nil)
|
||||||
_ resource.ResourcesLanguageMerger = (*resource.Resources)(nil)
|
_ resource.ResourcesLanguageMerger = (*resource.Resources)(nil)
|
||||||
_ resource.Identifier = (*genericResource)(nil)
|
_ resource.Identifier = (*genericResource)(nil)
|
||||||
|
_ resource.PathProvider = (*genericResource)(nil)
|
||||||
_ identity.IdentityGroupProvider = (*genericResource)(nil)
|
_ identity.IdentityGroupProvider = (*genericResource)(nil)
|
||||||
_ identity.DependencyManagerProvider = (*genericResource)(nil)
|
_ identity.DependencyManagerProvider = (*genericResource)(nil)
|
||||||
_ identity.Identity = (*genericResource)(nil)
|
_ identity.Identity = (*genericResource)(nil)
|
||||||
|
@ -463,6 +464,11 @@ func (l *genericResource) Key() string {
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO1 test and document this. Consider adding it to the Resource interface.
|
||||||
|
func (l *genericResource) Path() string {
|
||||||
|
return l.paths.TargetPath()
|
||||||
|
}
|
||||||
|
|
||||||
func (l *genericResource) MediaType() media.Type {
|
func (l *genericResource) MediaType() media.Type {
|
||||||
return l.sd.MediaType
|
return l.sd.MediaType
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,8 +16,10 @@ package resource
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gohugoio/hugo/common/maps"
|
||||||
"github.com/gohugoio/hugo/common/paths"
|
"github.com/gohugoio/hugo/common/paths"
|
||||||
"github.com/gohugoio/hugo/hugofs/glob"
|
"github.com/gohugoio/hugo/hugofs/glob"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
|
@ -29,6 +31,53 @@ var _ ResourceFinder = (*Resources)(nil)
|
||||||
// I.e. both pages and images etc.
|
// I.e. both pages and images etc.
|
||||||
type Resources []Resource
|
type Resources []Resource
|
||||||
|
|
||||||
|
// TODO1
|
||||||
|
// TODO1 move to a func + template func. Maybe.
|
||||||
|
func (r Resources) Mount(base, target string) ResourceGetter {
|
||||||
|
return resourceGetterFunc(func(namev any) Resource {
|
||||||
|
name1, err := cast.ToStringE(namev)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isTargetAbs := strings.HasPrefix(target, "/")
|
||||||
|
|
||||||
|
if target != "" {
|
||||||
|
name1 = strings.TrimPrefix(name1, target)
|
||||||
|
if !isTargetAbs {
|
||||||
|
name1 = paths.TrimLeading(name1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if base != "" && isTargetAbs {
|
||||||
|
name1 = path.Join(base, name1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, res := range r {
|
||||||
|
name2 := res.Name()
|
||||||
|
|
||||||
|
if base != "" && !isTargetAbs {
|
||||||
|
name2 = paths.TrimLeading(strings.TrimPrefix(name2, base))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO1 remove.
|
||||||
|
// fmt.Println("name1", name1, "name2", name2, "base", base)
|
||||||
|
|
||||||
|
if strings.EqualFold(name1, name2) {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourcesProvider interface {
|
||||||
|
// Resources returns a list of all resources.
|
||||||
|
Resources() Resources
|
||||||
|
}
|
||||||
|
|
||||||
// var _ resource.ResourceFinder = (*Namespace)(nil)
|
// var _ resource.ResourceFinder = (*Namespace)(nil)
|
||||||
// ResourcesConverter converts a given slice of Resource objects to Resources.
|
// ResourcesConverter converts a given slice of Resource objects to Resources.
|
||||||
type ResourcesConverter interface {
|
type ResourcesConverter interface {
|
||||||
|
@ -63,13 +112,25 @@ func (r Resources) Get(name any) Resource {
|
||||||
panic(err)
|
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.
|
// First check the Name.
|
||||||
// Note that this can be modified by the user in the front matter,
|
// Note that this can be modified by the user in the front matter,
|
||||||
// also, it does not contain any language code.
|
// also, it does not contain any language code.
|
||||||
for _, resource := range r {
|
for _, resource := range r {
|
||||||
if strings.EqualFold(namestr, paths.AddLeadingSlash(resource.Name())) {
|
if check(resource.Name()) {
|
||||||
return resource
|
return resource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -77,7 +138,7 @@ func (r Resources) Get(name any) Resource {
|
||||||
// Finally, check the normalized name.
|
// Finally, check the normalized name.
|
||||||
for _, resource := range r {
|
for _, resource := range r {
|
||||||
if nop, ok := resource.(NameNormalizedProvider); ok {
|
if nop, ok := resource.(NameNormalizedProvider); ok {
|
||||||
if strings.EqualFold(namestr, paths.AddLeadingSlash(nop.NameNormalized())) {
|
if check(nop.NameNormalized()) {
|
||||||
return resource
|
return resource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -197,14 +258,35 @@ type Source interface {
|
||||||
Publish() error
|
Publish() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResourceFinder provides methods to find Resources.
|
type ResourceGetter interface {
|
||||||
// Note that GetRemote (as found in resources.GetRemote) is
|
|
||||||
// not covered by this interface, as this is only available as a global template function.
|
|
||||||
type ResourceFinder interface {
|
|
||||||
// Get locates the Resource with the given name in the current context (e.g. in .Page.Resources).
|
// Get locates the Resource with the given name in the current context (e.g. in .Page.Resources).
|
||||||
//
|
//
|
||||||
// It returns nil if no Resource could found, panics if name is invalid.
|
// It returns nil if no Resource could found, panics if name is invalid.
|
||||||
Get(name any) Resource
|
Get(name any) Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
type IsProbablySameResourceGetter interface {
|
||||||
|
IsProbablySameResourceGetter(other ResourceGetter) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaleInfoResourceGetter is a ResourceGetter that also provides information about
|
||||||
|
// whether the underlying resources are stale.
|
||||||
|
type StaleInfoResourceGetter interface {
|
||||||
|
StaleInfo
|
||||||
|
ResourceGetter
|
||||||
|
}
|
||||||
|
|
||||||
|
type resourceGetterFunc func(name any) Resource
|
||||||
|
|
||||||
|
func (f resourceGetterFunc) Get(name any) Resource {
|
||||||
|
return f(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResourceFinder provides methods to find Resources.
|
||||||
|
// Note that GetRemote (as found in resources.GetRemote) is
|
||||||
|
// not covered by this interface, as this is only available as a global template function.
|
||||||
|
type ResourceFinder interface {
|
||||||
|
ResourceGetter
|
||||||
|
|
||||||
// GetMatch finds the first Resource matching the given pattern, or nil if none found.
|
// GetMatch finds the first Resource matching the given pattern, or nil if none found.
|
||||||
//
|
//
|
||||||
|
@ -235,3 +317,92 @@ type ResourceFinder interface {
|
||||||
// It returns nil if no Resources could found, panics if typ is invalid.
|
// It returns nil if no Resources could found, panics if typ is invalid.
|
||||||
ByType(typ any) Resources
|
ByType(typ any) Resources
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewCachedResourceGetter creates a new ResourceGetter from the given objects.
|
||||||
|
// If multiple objects are provided, they are merged into one where
|
||||||
|
// the first match wins.
|
||||||
|
func NewCachedResourceGetter(os ...any) *cachedResourceGetter {
|
||||||
|
var getters multiResourceGetter
|
||||||
|
for _, o := range os {
|
||||||
|
if g, ok := unwrapResourceGetter(o); ok {
|
||||||
|
getters = append(getters, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cachedResourceGetter{
|
||||||
|
cache: maps.NewCache[string, Resource](),
|
||||||
|
delegate: getters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type multiResourceGetter []ResourceGetter
|
||||||
|
|
||||||
|
func (m multiResourceGetter) Get(name any) Resource {
|
||||||
|
for _, g := range m {
|
||||||
|
if res := g.Get(name); res != nil {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ ResourceGetter = (*cachedResourceGetter)(nil)
|
||||||
|
_ IsProbablySameResourceGetter = (*cachedResourceGetter)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
type cachedResourceGetter struct {
|
||||||
|
cache *maps.Cache[string, Resource]
|
||||||
|
delegate ResourceGetter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cachedResourceGetter) Get(name any) Resource {
|
||||||
|
namestr, err := cast.ToStringE(name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
v, _ := c.cache.GetOrCreate(namestr, func() (Resource, error) {
|
||||||
|
v := c.delegate.Get(name)
|
||||||
|
return v, nil
|
||||||
|
})
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cachedResourceGetter) IsProbablySameResourceGetter(other ResourceGetter) bool {
|
||||||
|
isProbablyEq := true
|
||||||
|
var count int
|
||||||
|
c.cache.ForEeach(func(k string, v Resource) bool {
|
||||||
|
count++
|
||||||
|
if v != other.Get(k) {
|
||||||
|
isProbablyEq = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return count > 0 && isProbablyEq
|
||||||
|
}
|
||||||
|
|
||||||
|
func unwrapResourceGetter(v any) (ResourceGetter, bool) {
|
||||||
|
if v == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
switch vv := v.(type) {
|
||||||
|
case ResourceGetter:
|
||||||
|
return vv, true
|
||||||
|
case ResourcesProvider:
|
||||||
|
return vv.Resources(), true
|
||||||
|
case func(name any) Resource:
|
||||||
|
return resourceGetterFunc(vv), true
|
||||||
|
case []any:
|
||||||
|
var getters multiResourceGetter
|
||||||
|
for _, vv := range vv {
|
||||||
|
if g, ok := unwrapResourceGetter(vv); ok {
|
||||||
|
getters = append(getters, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getters, len(getters) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
125
resources/resource/resources_test.go
Normal file
125
resources/resource/resources_test.go
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package resource
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
qt "github.com/frankban/quicktest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResourcesMount(t *testing.T) {
|
||||||
|
c := qt.New(t)
|
||||||
|
c.Assert(true, qt.IsTrue)
|
||||||
|
|
||||||
|
var m ResourceGetter
|
||||||
|
var r Resources
|
||||||
|
|
||||||
|
check := func(in, expect string) {
|
||||||
|
c.Helper()
|
||||||
|
r := m.Get(in)
|
||||||
|
c.Assert(r, qt.Not(qt.IsNil))
|
||||||
|
c.Assert(r.Name(), qt.Equals, expect)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkNil := func(in string) {
|
||||||
|
c.Helper()
|
||||||
|
r := m.Get(in)
|
||||||
|
c.Assert(r, qt.IsNil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Misc tests.
|
||||||
|
r = Resources{
|
||||||
|
testResource{name: "/foo/theme.css"},
|
||||||
|
}
|
||||||
|
|
||||||
|
m = r.Mount("/foo", ".")
|
||||||
|
check("./theme.css", "/foo/theme.css")
|
||||||
|
|
||||||
|
// Relative target.
|
||||||
|
r = Resources{
|
||||||
|
testResource{name: "/a/b/c/d.txt"},
|
||||||
|
testResource{name: "/a/b/c/e/f.txt"},
|
||||||
|
testResource{name: "/a/b/d.txt"},
|
||||||
|
testResource{name: "/a/b/e.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// namvev ./theme.css base /js/hugoheadlessui/components target . name /js/hugoheadlessui/components/theme.css name1 theme.css name2 .
|
||||||
|
|
||||||
|
m = r.Mount("/a/b/c", "z")
|
||||||
|
check("z/d.txt", "/a/b/c/d.txt")
|
||||||
|
// check("./z/d.txt", "/a/b/c/d.txt")
|
||||||
|
check("z/e/f.txt", "/a/b/c/e/f.txt")
|
||||||
|
|
||||||
|
m = r.Mount("/a/b", "")
|
||||||
|
check("d.txt", "/a/b/d.txt")
|
||||||
|
m = r.Mount("/a/b", ".")
|
||||||
|
check("d.txt", "/a/b/d.txt")
|
||||||
|
m = r.Mount("/a/b", "./")
|
||||||
|
check("d.txt", "/a/b/d.txt")
|
||||||
|
check("./d.txt", "/a/b/d.txt")
|
||||||
|
|
||||||
|
m = r.Mount("/a/b", ".")
|
||||||
|
check("./d.txt", "/a/b/d.txt")
|
||||||
|
|
||||||
|
// Absolute target.
|
||||||
|
m = r.Mount("/a/b/c", "/z")
|
||||||
|
check("/z/d.txt", "/a/b/c/d.txt")
|
||||||
|
check("/z/e/f.txt", "/a/b/c/e/f.txt")
|
||||||
|
checkNil("/z/f.txt")
|
||||||
|
|
||||||
|
m = r.Mount("/a/b", "/z")
|
||||||
|
check("/z/c/d.txt", "/a/b/c/d.txt")
|
||||||
|
check("/z/c/e/f.txt", "/a/b/c/e/f.txt")
|
||||||
|
check("/z/d.txt", "/a/b/d.txt")
|
||||||
|
checkNil("/z/f.txt")
|
||||||
|
|
||||||
|
m = r.Mount("", "")
|
||||||
|
check("/a/b/c/d.txt", "/a/b/c/d.txt")
|
||||||
|
check("/a/b/c/e/f.txt", "/a/b/c/e/f.txt")
|
||||||
|
check("/a/b/d.txt", "/a/b/d.txt")
|
||||||
|
checkNil("/a/b/f.txt")
|
||||||
|
|
||||||
|
m = r.Mount("/a/b", "/a/b")
|
||||||
|
check("/a/b/c/d.txt", "/a/b/c/d.txt")
|
||||||
|
check("/a/b/c/e/f.txt", "/a/b/c/e/f.txt")
|
||||||
|
check("/a/b/d.txt", "/a/b/d.txt")
|
||||||
|
checkNil("/a/b/f.txt")
|
||||||
|
|
||||||
|
// Resources with relative paths.
|
||||||
|
r = Resources{
|
||||||
|
testResource{name: "a/b/c/d.txt"},
|
||||||
|
testResource{name: "a/b/c/e/f.txt"},
|
||||||
|
testResource{name: "a/b/d.txt"},
|
||||||
|
testResource{name: "a/b/e.txt"},
|
||||||
|
testResource{name: "n.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
m = r.Mount("a/b", "z")
|
||||||
|
check("z/d.txt", "a/b/d.txt")
|
||||||
|
checkNil("/z/d.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
type testResource struct {
|
||||||
|
Resource
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r testResource) Name() string {
|
||||||
|
return r.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r testResource) NameNormalized() string {
|
||||||
|
return r.name
|
||||||
|
}
|
|
@ -108,6 +108,14 @@ type MediaTypeProvider interface {
|
||||||
MediaType() media.Type
|
MediaType() media.Type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO1 consider removing.s
|
||||||
|
type PathProvider interface {
|
||||||
|
// Path is the relative path to this resource.
|
||||||
|
// In most cases this will be the same as the RelPermalink(),
|
||||||
|
// but it will not trigger any lazy publishing.
|
||||||
|
Path() string
|
||||||
|
}
|
||||||
|
|
||||||
type ResourceLinksProvider interface {
|
type ResourceLinksProvider interface {
|
||||||
// Permalink represents the absolute link to this resource.
|
// Permalink represents the absolute link to this resource.
|
||||||
Permalink() string
|
Permalink() string
|
||||||
|
@ -244,6 +252,13 @@ type StaleInfo interface {
|
||||||
StaleVersion() uint32
|
StaleVersion() uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StaleInfoFunc is a function that returns the StaleVersion for one or more resources.
|
||||||
|
type StaleInfoFunc func() uint32
|
||||||
|
|
||||||
|
func (f StaleInfoFunc) StaleVersion() uint32 {
|
||||||
|
return f()
|
||||||
|
}
|
||||||
|
|
||||||
// StaleVersion returns the StaleVersion for the given os,
|
// StaleVersion returns the StaleVersion for the given os,
|
||||||
// or 0 if not set.
|
// or 0 if not set.
|
||||||
func StaleVersion(os any) uint32 {
|
func StaleVersion(os any) uint32 {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2020 The Hugo Authors. All rights reserved.
|
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||||
//
|
//
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
// you may not use this file except in compliance with the License.
|
// you may not use this file except in compliance with the License.
|
||||||
|
@ -14,209 +14,64 @@
|
||||||
package js
|
package js
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
|
|
||||||
"github.com/gohugoio/hugo/hugofs"
|
|
||||||
"github.com/gohugoio/hugo/media"
|
|
||||||
|
|
||||||
"github.com/gohugoio/hugo/common/herrors"
|
|
||||||
"github.com/gohugoio/hugo/common/text"
|
|
||||||
|
|
||||||
"github.com/gohugoio/hugo/hugolib/filesystems"
|
|
||||||
"github.com/gohugoio/hugo/resources/internal"
|
|
||||||
|
|
||||||
"github.com/evanw/esbuild/pkg/api"
|
"github.com/evanw/esbuild/pkg/api"
|
||||||
|
"github.com/gohugoio/hugo/hugolib/filesystems"
|
||||||
|
"github.com/gohugoio/hugo/internal/js/esbuild"
|
||||||
|
|
||||||
"github.com/gohugoio/hugo/resources"
|
"github.com/gohugoio/hugo/resources"
|
||||||
"github.com/gohugoio/hugo/resources/resource"
|
"github.com/gohugoio/hugo/resources/resource"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client context for ESBuild.
|
// Client context for ESBuild.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
rs *resources.Spec
|
c *esbuild.BuildClient
|
||||||
sfs *filesystems.SourceFilesystem
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new client context.
|
// New creates a new client context.
|
||||||
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client {
|
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
rs: rs,
|
c: esbuild.NewBuildClient(fs, rs),
|
||||||
sfs: fs,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type buildTransformation struct {
|
// Process processes a resource with the user provided options.
|
||||||
optsm map[string]any
|
|
||||||
c *Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *buildTransformation) Key() internal.ResourceTransformationKey {
|
|
||||||
return internal.NewResourceTransformationKey("jsbuild", t.optsm)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
|
|
||||||
ctx.OutMediaType = media.Builtin.JavascriptType
|
|
||||||
|
|
||||||
opts, err := decodeOptions(t.optsm)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.TargetPath != "" {
|
|
||||||
ctx.OutPath = opts.TargetPath
|
|
||||||
} else {
|
|
||||||
ctx.ReplaceOutPathExtension(".js")
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := io.ReadAll(ctx.From)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
opts.sourceDir = filepath.FromSlash(path.Dir(ctx.SourcePath))
|
|
||||||
opts.resolveDir = t.c.rs.Cfg.BaseConfig().WorkingDir // where node_modules gets resolved
|
|
||||||
opts.contents = string(src)
|
|
||||||
opts.mediaType = ctx.InMediaType
|
|
||||||
opts.tsConfig = t.c.rs.ResolveJSConfigFile("tsconfig.json")
|
|
||||||
|
|
||||||
buildOptions, err := toBuildOptions(opts)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
buildOptions.Plugins, err = createBuildPlugins(ctx.DependencyManager, t.c, opts)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if buildOptions.Sourcemap == api.SourceMapExternal && buildOptions.Outdir == "" {
|
|
||||||
buildOptions.Outdir, err = os.MkdirTemp(os.TempDir(), "compileOutput")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer os.Remove(buildOptions.Outdir)
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.Inject != nil {
|
|
||||||
// Resolve the absolute filenames.
|
|
||||||
for i, ext := range opts.Inject {
|
|
||||||
impPath := filepath.FromSlash(ext)
|
|
||||||
if filepath.IsAbs(impPath) {
|
|
||||||
return fmt.Errorf("inject: absolute paths not supported, must be relative to /assets")
|
|
||||||
}
|
|
||||||
|
|
||||||
m := resolveComponentInAssets(t.c.rs.Assets.Fs, impPath)
|
|
||||||
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("inject: file %q not found", ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
opts.Inject[i] = m.Filename
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
buildOptions.Inject = opts.Inject
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
result := api.Build(buildOptions)
|
|
||||||
|
|
||||||
if len(result.Errors) > 0 {
|
|
||||||
|
|
||||||
createErr := func(msg api.Message) error {
|
|
||||||
loc := msg.Location
|
|
||||||
if loc == nil {
|
|
||||||
return errors.New(msg.Text)
|
|
||||||
}
|
|
||||||
path := loc.File
|
|
||||||
if path == stdinImporter {
|
|
||||||
path = ctx.SourcePath
|
|
||||||
}
|
|
||||||
|
|
||||||
errorMessage := msg.Text
|
|
||||||
errorMessage = strings.ReplaceAll(errorMessage, nsImportHugo+":", "")
|
|
||||||
|
|
||||||
var (
|
|
||||||
f afero.File
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
if strings.HasPrefix(path, nsImportHugo) {
|
|
||||||
path = strings.TrimPrefix(path, nsImportHugo+":")
|
|
||||||
f, err = hugofs.Os.Open(path)
|
|
||||||
} else {
|
|
||||||
var fi os.FileInfo
|
|
||||||
fi, err = t.c.sfs.Fs.Stat(path)
|
|
||||||
if err == nil {
|
|
||||||
m := fi.(hugofs.FileMetaInfo).Meta()
|
|
||||||
path = m.Filename
|
|
||||||
f, err = m.Open()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
fe := herrors.
|
|
||||||
NewFileErrorFromName(errors.New(errorMessage), path).
|
|
||||||
UpdatePosition(text.Position{Offset: -1, LineNumber: loc.Line, ColumnNumber: loc.Column}).
|
|
||||||
UpdateContent(f, nil)
|
|
||||||
|
|
||||||
f.Close()
|
|
||||||
return fe
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("%s", errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
var errors []error
|
|
||||||
|
|
||||||
for _, msg := range result.Errors {
|
|
||||||
errors = append(errors, createErr(msg))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 1, log the rest.
|
|
||||||
for i, err := range errors {
|
|
||||||
if i > 0 {
|
|
||||||
t.c.rs.Logger.Errorf("js.Build failed: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
if buildOptions.Sourcemap == api.SourceMapExternal {
|
|
||||||
content := string(result.OutputFiles[1].Contents)
|
|
||||||
symPath := path.Base(ctx.OutPath) + ".map"
|
|
||||||
re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
|
|
||||||
content = re.ReplaceAllString(content, "//# sourceMappingURL="+symPath+"\n")
|
|
||||||
|
|
||||||
if err = ctx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := ctx.To.Write([]byte(content))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_, err := ctx.To.Write(result.OutputFiles[0].Contents)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process process esbuild transform
|
|
||||||
func (c *Client) Process(res resources.ResourceTransformer, opts map[string]any) (resource.Resource, error) {
|
func (c *Client) Process(res resources.ResourceTransformer, opts map[string]any) (resource.Resource, error) {
|
||||||
return res.Transform(
|
return res.Transform(
|
||||||
&buildTransformation{c: c, optsm: opts},
|
&buildTransformation{c: c, optsm: opts},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) transform(opts esbuild.Options, transformCtx *resources.ResourceTransformationCtx) (api.BuildResult, error) {
|
||||||
|
if transformCtx.DependencyManager != nil {
|
||||||
|
opts.DependencyManager = transformCtx.DependencyManager
|
||||||
|
}
|
||||||
|
result, err := c.c.Build(opts)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ExternalOptions.SourceMap == "external" {
|
||||||
|
content := string(result.OutputFiles[1].Contents)
|
||||||
|
symPath := path.Base(transformCtx.OutPath) + ".map"
|
||||||
|
re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
|
||||||
|
content = re.ReplaceAllString(content, "//# sourceMappingURL="+symPath+"\n")
|
||||||
|
|
||||||
|
if err = transformCtx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
_, err := transformCtx.To.Write([]byte(content))
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err := transformCtx.To.Write(result.OutputFiles[0].Contents)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
// Copyright 2020 The Hugo Authors. All rights reserved.
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
package js
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 The Hugo Authors. All rights reserved.
|
// Copyright 2024 The Hugo Authors. All rights reserved.
|
||||||
//
|
//
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
// you may not use this file except in compliance with the License.
|
// you may not use this file except in compliance with the License.
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -52,6 +52,7 @@ var (
|
||||||
_ identity.IdentityGroupProvider = (*resourceAdapterInner)(nil)
|
_ identity.IdentityGroupProvider = (*resourceAdapterInner)(nil)
|
||||||
_ resource.Source = (*resourceAdapter)(nil)
|
_ resource.Source = (*resourceAdapter)(nil)
|
||||||
_ resource.Identifier = (*resourceAdapter)(nil)
|
_ resource.Identifier = (*resourceAdapter)(nil)
|
||||||
|
_ resource.PathProvider = (*resourceAdapter)(nil)
|
||||||
_ resource.ResourceNameTitleProvider = (*resourceAdapter)(nil)
|
_ resource.ResourceNameTitleProvider = (*resourceAdapter)(nil)
|
||||||
_ resource.WithResourceMetaProvider = (*resourceAdapter)(nil)
|
_ resource.WithResourceMetaProvider = (*resourceAdapter)(nil)
|
||||||
_ identity.DependencyManagerProvider = (*resourceAdapter)(nil)
|
_ identity.DependencyManagerProvider = (*resourceAdapter)(nil)
|
||||||
|
@ -277,6 +278,11 @@ func (r *resourceAdapter) Key() string {
|
||||||
return r.target.(resource.Identifier).Key()
|
return r.target.(resource.Identifier).Key()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *resourceAdapter) Path() string {
|
||||||
|
r.init(false, false)
|
||||||
|
return r.target.(resource.PathProvider).Path()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *resourceAdapter) MediaType() media.Type {
|
func (r *resourceAdapter) MediaType() media.Type {
|
||||||
r.init(false, false)
|
r.init(false, false)
|
||||||
return r.target.MediaType()
|
return r.target.MediaType()
|
||||||
|
|
|
@ -24,7 +24,10 @@ const name = "js"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
f := func(d *deps.Deps) *internal.TemplateFuncsNamespace {
|
f := func(d *deps.Deps) *internal.TemplateFuncsNamespace {
|
||||||
ctx := New(d)
|
ctx, err := New(d)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
ns := &internal.TemplateFuncsNamespace{
|
ns := &internal.TemplateFuncsNamespace{
|
||||||
Name: name,
|
Name: name,
|
||||||
|
|
48
tpl/js/js.go
48
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");
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
// you may not use this file except in compliance with the License.
|
// you may not use this file except in compliance with the License.
|
||||||
|
@ -16,30 +16,47 @@ package js
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/gohugoio/hugo/common/maps"
|
||||||
"github.com/gohugoio/hugo/deps"
|
"github.com/gohugoio/hugo/deps"
|
||||||
|
"github.com/gohugoio/hugo/internal/js/esbuild"
|
||||||
"github.com/gohugoio/hugo/resources"
|
"github.com/gohugoio/hugo/resources"
|
||||||
"github.com/gohugoio/hugo/resources/resource"
|
"github.com/gohugoio/hugo/resources/resource"
|
||||||
|
"github.com/gohugoio/hugo/resources/resource_factories/create"
|
||||||
"github.com/gohugoio/hugo/resources/resource_transformers/babel"
|
"github.com/gohugoio/hugo/resources/resource_transformers/babel"
|
||||||
"github.com/gohugoio/hugo/resources/resource_transformers/js"
|
jstransform "github.com/gohugoio/hugo/resources/resource_transformers/js"
|
||||||
"github.com/gohugoio/hugo/tpl/internal/resourcehelpers"
|
"github.com/gohugoio/hugo/tpl/internal/resourcehelpers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New returns a new instance of the js-namespaced template functions.
|
// New returns a new instance of the js-namespaced template functions.
|
||||||
func New(deps *deps.Deps) *Namespace {
|
func New(deps *deps.Deps) (*Namespace, error) {
|
||||||
if deps.ResourceSpec == nil {
|
if deps.ResourceSpec == nil {
|
||||||
return &Namespace{}
|
return &Namespace{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
batcherClient, err := esbuild.NewBatcherClient(deps)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &Namespace{
|
return &Namespace{
|
||||||
client: js.New(deps.BaseFs.Assets, deps.ResourceSpec),
|
d: deps,
|
||||||
babelClient: babel.New(deps.ResourceSpec),
|
jsTransformClient: jstransform.New(deps.BaseFs.Assets, deps.ResourceSpec),
|
||||||
}
|
jsBatcherClient: batcherClient,
|
||||||
|
createClient: create.New(deps.ResourceSpec),
|
||||||
|
babelClient: babel.New(deps.ResourceSpec),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Namespace provides template functions for the "js" namespace.
|
// Namespace provides template functions for the "js" namespace.
|
||||||
type Namespace struct {
|
type Namespace struct {
|
||||||
client *js.Client
|
d *deps.Deps
|
||||||
babelClient *babel.Client
|
|
||||||
|
jsTransformClient *jstransform.Client
|
||||||
|
createClient *create.Client
|
||||||
|
babelClient *babel.Client
|
||||||
|
jsBatcherClient *esbuild.BatcherClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build processes the given Resource with ESBuild.
|
// Build processes the given Resource with ESBuild.
|
||||||
|
@ -65,7 +82,18 @@ func (ns *Namespace) Build(args ...any) (resource.Resource, error) {
|
||||||
m = map[string]any{"targetPath": targetPath}
|
m = map[string]any{"targetPath": targetPath}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ns.client.Process(r, m)
|
return ns.jsTransformClient.Process(r, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ns *Namespace) Batch(id string, store *maps.Scratch) (esbuild.Batcher, error) {
|
||||||
|
key := path.Join(esbuild.NsBatch, id)
|
||||||
|
b, err := store.GetOrCreate(key, func() (any, error) {
|
||||||
|
return ns.jsBatcherClient.New(id)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b.(esbuild.Batcher), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Babel processes the given Resource with Babel.
|
// Babel processes the given Resource with Babel.
|
||||||
|
|
|
@ -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 }}
|
Loading…
Reference in a new issue