Add js.Batch

Fixes #12626
Closes #7499
Closes #12874
This commit is contained in:
Bjørn Erik Pedersen 2024-06-27 16:22:35 +02:00
parent df8bd4af4f
commit 070e7f4034
No known key found for this signature in database
44 changed files with 3394 additions and 940 deletions

View file

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

View file

@ -133,6 +133,21 @@ func IsNotExist(err error) bool {
return false return false
} }
// IsExist returns true if the error is a file exists error.
// Unlike os.IsExist, this also considers wrapped errors.
func IsExist(err error) bool {
if os.IsExist(err) {
return true
}
// os.IsExist does not consider wrapped errors.
if os.IsExist(errors.Unwrap(err)) {
return true
}
return false
}
var nilPointerErrRe = regexp.MustCompile(`at <(.*)>: error calling (.*?): runtime error: invalid memory address or nil pointer dereference`) var nilPointerErrRe = regexp.MustCompile(`at <(.*)>: error calling (.*?): runtime error: invalid memory address or nil pointer dereference`)
const deferredPrefix = "__hdeferred/" const deferredPrefix = "__hdeferred/"

View file

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

View file

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

View file

@ -19,6 +19,13 @@ type Closer interface {
Close() error Close() error
} }
// CloserFunc is a convenience type to create a Closer from a function.
type CloserFunc func() error
func (f CloserFunc) Close() error {
return f()
}
type CloseAdder interface { type CloseAdder interface {
Add(Closer) Add(Closer)
} }

View file

@ -137,11 +137,11 @@ func (c ConfigLanguage) Watching() bool {
return c.m.Base.Internal.Watch return c.m.Base.Internal.Watch
} }
func (c ConfigLanguage) NewIdentityManager(name string) identity.Manager { func (c ConfigLanguage) NewIdentityManager(name string, opts ...identity.ManagerOption) identity.Manager {
if !c.Watching() { if !c.Watching() {
return identity.NopManager return identity.NopManager
} }
return identity.NewManager(name) return identity.NewManager(name, opts...)
} }
func (c ConfigLanguage) ContentTypes() config.ContentTypesProvider { func (c ConfigLanguage) ContentTypes() config.ContentTypesProvider {

View file

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

0
debug.log Normal file
View file

15
deps/deps.go vendored
View file

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@ -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

View file

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

View file

@ -520,8 +520,9 @@ func (s *Site) executeDeferredTemplates(de *deps.DeferredExecutions) error {
}, },
}) })
de.FilenamesWithPostPrefix.ForEeach(func(filename string, _ bool) { de.FilenamesWithPostPrefix.ForEeach(func(filename string, _ bool) bool {
g.Enqueue(filename) g.Enqueue(filename)
return true
}) })
return g.Wait() return g.Wait()

View file

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

View file

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

View file

@ -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() {

View file

@ -245,10 +245,11 @@ func (b *BuildState) resolveDeletedPaths() {
return return
} }
var paths []string var paths []string
b.sourceInfosPrevious.ForEeach(func(k string, _ *sourceInfo) { b.sourceInfosPrevious.ForEeach(func(k string, _ *sourceInfo) bool {
if _, found := b.sourceInfosCurrent.Get(k); !found { if _, found := b.sourceInfosCurrent.Get(k); !found {
paths = append(paths, k) paths = append(paths, k)
} }
return true
}) })
b.DeletedPaths = paths b.DeletedPaths = paths

View file

@ -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",

View file

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

View file

@ -82,9 +82,8 @@ func FirstIdentity(v any) Identity {
var result Identity = Anonymous var result Identity = Anonymous
WalkIdentitiesShallow(v, func(level int, id Identity) bool { WalkIdentitiesShallow(v, func(level int, id Identity) bool {
result = id result = id
return true return result != Anonymous
}) })
return result return result
} }
@ -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) {

View 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

File diff suppressed because it is too large Load diff

View 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.

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

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

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

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

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

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

View file

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

View file

@ -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() {

View file

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

View file

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

View file

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

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

View file

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

View file

@ -1,4 +1,4 @@
// Copyright 2020 The Hugo Authors. All rights reserved. // Copyright 2024 The Hugo Authors. All rights reserved.
// //
// Licensed under the Apache License, Version 2.0 (the "License"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. // you may not use this file except in compliance with the License.
@ -14,209 +14,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
}

View file

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

View file

@ -1,4 +1,4 @@
// Copyright 2021 The Hugo Authors. All rights reserved. // Copyright 2024 The Hugo Authors. All rights reserved.
// //
// Licensed under the Apache License, Version 2.0 (the "License"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. // you may not use this file except in compliance with the License.

View file

@ -1,461 +0,0 @@
// Copyright 2020 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package js
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/identity"
"github.com/spf13/afero"
"github.com/evanw/esbuild/pkg/api"
"github.com/gohugoio/hugo/hugofs"
"github.com/gohugoio/hugo/media"
"github.com/mitchellh/mapstructure"
)
const (
nsImportHugo = "ns-hugo"
nsParams = "ns-params"
stdinImporter = "<stdin>"
)
// Options esbuild configuration
type Options struct {
// If not set, the source path will be used as the base target path.
// Note that the target path's extension may change if the target MIME type
// is different, e.g. when the source is TypeScript.
TargetPath string
// Whether to minify to output.
Minify bool
// Whether to write mapfiles
SourceMap string
// The language target.
// One of: es2015, es2016, es2017, es2018, es2019, es2020 or esnext.
// Default is esnext.
Target string
// The output format.
// One of: iife, cjs, esm
// Default is to esm.
Format string
// External dependencies, e.g. "react".
Externals []string
// This option allows you to automatically replace a global variable with an import from another file.
// The filenames must be relative to /assets.
// See https://esbuild.github.io/api/#inject
Inject []string
// User defined symbols.
Defines map[string]any
// Maps a component import to another.
Shims map[string]string
// User defined params. Will be marshaled to JSON and available as "@params", e.g.
// import * as params from '@params';
Params any
// What to use instead of React.createElement.
JSXFactory string
// What to use instead of React.Fragment.
JSXFragment string
// What to do about JSX syntax.
// See https://esbuild.github.io/api/#jsx
JSX string
// Which library to use to automatically import JSX helper functions from. Only works if JSX is set to automatic.
// See https://esbuild.github.io/api/#jsx-import-source
JSXImportSource string
// There is/was a bug in WebKit with severe performance issue with the tracking
// of TDZ checks in JavaScriptCore.
//
// Enabling this flag removes the TDZ and `const` assignment checks and
// may improve performance of larger JS codebases until the WebKit fix
// is in widespread use.
//
// See https://bugs.webkit.org/show_bug.cgi?id=199866
// Deprecated: This no longer have any effect and will be removed.
// TODO(bep) remove. See https://github.com/evanw/esbuild/commit/869e8117b499ca1dbfc5b3021938a53ffe934dba
AvoidTDZ bool
mediaType media.Type
outDir string
contents string
sourceDir string
resolveDir string
tsConfig string
}
func decodeOptions(m map[string]any) (Options, error) {
var opts Options
if err := mapstructure.WeakDecode(m, &opts); err != nil {
return opts, err
}
if opts.TargetPath != "" {
opts.TargetPath = paths.ToSlashTrimLeading(opts.TargetPath)
}
opts.Target = strings.ToLower(opts.Target)
opts.Format = strings.ToLower(opts.Format)
return opts, nil
}
var extensionToLoaderMap = map[string]api.Loader{
".js": api.LoaderJS,
".mjs": api.LoaderJS,
".cjs": api.LoaderJS,
".jsx": api.LoaderJSX,
".ts": api.LoaderTS,
".tsx": api.LoaderTSX,
".css": api.LoaderCSS,
".json": api.LoaderJSON,
".txt": api.LoaderText,
}
func loaderFromFilename(filename string) api.Loader {
l, found := extensionToLoaderMap[filepath.Ext(filename)]
if found {
return l
}
return api.LoaderJS
}
func resolveComponentInAssets(fs afero.Fs, impPath string) *hugofs.FileMeta {
findFirst := func(base string) *hugofs.FileMeta {
// This is the most common sub-set of ESBuild's default extensions.
// We assume that imports of JSON, CSS etc. will be using their full
// name with extension.
for _, ext := range []string{".js", ".ts", ".tsx", ".jsx"} {
if strings.HasSuffix(impPath, ext) {
// Import of foo.js.js need the full name.
continue
}
if fi, err := fs.Stat(base + ext); err == nil {
return fi.(hugofs.FileMetaInfo).Meta()
}
}
// Not found.
return nil
}
var m *hugofs.FileMeta
// We need to check if this is a regular file imported without an extension.
// There may be ambiguous situations where both foo.js and foo/index.js exists.
// This import order is in line with both how Node and ESBuild's native
// import resolver works.
// It may be a regular file imported without an extension, e.g.
// foo or foo/index.
m = findFirst(impPath)
if m != nil {
return m
}
base := filepath.Base(impPath)
if base == "index" {
// try index.esm.js etc.
m = findFirst(impPath + ".esm")
if m != nil {
return m
}
}
// Check the path as is.
fi, err := fs.Stat(impPath)
if err == nil {
if fi.IsDir() {
m = findFirst(filepath.Join(impPath, "index"))
if m == nil {
m = findFirst(filepath.Join(impPath, "index.esm"))
}
} else {
m = fi.(hugofs.FileMetaInfo).Meta()
}
} else if strings.HasSuffix(base, ".js") {
m = findFirst(strings.TrimSuffix(impPath, ".js"))
}
return m
}
func createBuildPlugins(depsManager identity.Manager, c *Client, opts Options) ([]api.Plugin, error) {
fs := c.rs.Assets
resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
impPath := args.Path
if opts.Shims != nil {
override, found := opts.Shims[impPath]
if found {
impPath = override
}
}
isStdin := args.Importer == stdinImporter
var relDir string
if !isStdin {
rel, found := fs.MakePathRelative(args.Importer, true)
if !found {
// Not in any of the /assets folders.
// This is an import from a node_modules, let
// ESBuild resolve this.
return api.OnResolveResult{}, nil
}
relDir = filepath.Dir(rel)
} else {
relDir = opts.sourceDir
}
// Imports not starting with a "." is assumed to live relative to /assets.
// Hugo makes no assumptions about the directory structure below /assets.
if relDir != "" && strings.HasPrefix(impPath, ".") {
impPath = filepath.Join(relDir, impPath)
}
m := resolveComponentInAssets(fs.Fs, impPath)
if m != nil {
depsManager.AddIdentity(m.PathInfo)
// Store the source root so we can create a jsconfig.json
// to help IntelliSense when the build is done.
// This should be a small number of elements, and when
// in server mode, we may get stale entries on renames etc.,
// but that shouldn't matter too much.
c.rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)
return api.OnResolveResult{Path: m.Filename, Namespace: nsImportHugo}, nil
}
// Fall back to ESBuild's resolve.
return api.OnResolveResult{}, nil
}
importResolver := api.Plugin{
Name: "hugo-import-resolver",
Setup: func(build api.PluginBuild) {
build.OnResolve(api.OnResolveOptions{Filter: `.*`},
func(args api.OnResolveArgs) (api.OnResolveResult, error) {
return resolveImport(args)
})
build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsImportHugo},
func(args api.OnLoadArgs) (api.OnLoadResult, error) {
b, err := os.ReadFile(args.Path)
if err != nil {
return api.OnLoadResult{}, fmt.Errorf("failed to read %q: %w", args.Path, err)
}
c := string(b)
return api.OnLoadResult{
// See https://github.com/evanw/esbuild/issues/502
// This allows all modules to resolve dependencies
// in the main project's node_modules.
ResolveDir: opts.resolveDir,
Contents: &c,
Loader: loaderFromFilename(args.Path),
}, nil
})
},
}
params := opts.Params
if params == nil {
// This way @params will always resolve to something.
params = make(map[string]any)
}
b, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("failed to marshal params: %w", err)
}
bs := string(b)
paramsPlugin := api.Plugin{
Name: "hugo-params-plugin",
Setup: func(build api.PluginBuild) {
build.OnResolve(api.OnResolveOptions{Filter: `^@params$`},
func(args api.OnResolveArgs) (api.OnResolveResult, error) {
return api.OnResolveResult{
Path: args.Path,
Namespace: nsParams,
}, nil
})
build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsParams},
func(args api.OnLoadArgs) (api.OnLoadResult, error) {
return api.OnLoadResult{
Contents: &bs,
Loader: api.LoaderJSON,
}, nil
})
},
}
return []api.Plugin{importResolver, paramsPlugin}, nil
}
func toBuildOptions(opts Options) (buildOptions api.BuildOptions, err error) {
var target api.Target
switch opts.Target {
case "", "esnext":
target = api.ESNext
case "es5":
target = api.ES5
case "es6", "es2015":
target = api.ES2015
case "es2016":
target = api.ES2016
case "es2017":
target = api.ES2017
case "es2018":
target = api.ES2018
case "es2019":
target = api.ES2019
case "es2020":
target = api.ES2020
case "es2021":
target = api.ES2021
case "es2022":
target = api.ES2022
case "es2023":
target = api.ES2023
default:
err = fmt.Errorf("invalid target: %q", opts.Target)
return
}
mediaType := opts.mediaType
if mediaType.IsZero() {
mediaType = media.Builtin.JavascriptType
}
var loader api.Loader
switch mediaType.SubType {
// TODO(bep) ESBuild support a set of other loaders, but I currently fail
// to see the relevance. That may change as we start using this.
case media.Builtin.JavascriptType.SubType:
loader = api.LoaderJS
case media.Builtin.TypeScriptType.SubType:
loader = api.LoaderTS
case media.Builtin.TSXType.SubType:
loader = api.LoaderTSX
case media.Builtin.JSXType.SubType:
loader = api.LoaderJSX
default:
err = fmt.Errorf("unsupported Media Type: %q", opts.mediaType)
return
}
var format api.Format
// One of: iife, cjs, esm
switch opts.Format {
case "", "iife":
format = api.FormatIIFE
case "esm":
format = api.FormatESModule
case "cjs":
format = api.FormatCommonJS
default:
err = fmt.Errorf("unsupported script output format: %q", opts.Format)
return
}
var jsx api.JSX
switch opts.JSX {
case "", "transform":
jsx = api.JSXTransform
case "preserve":
jsx = api.JSXPreserve
case "automatic":
jsx = api.JSXAutomatic
default:
err = fmt.Errorf("unsupported jsx type: %q", opts.JSX)
return
}
var defines map[string]string
if opts.Defines != nil {
defines = maps.ToStringMapString(opts.Defines)
}
// By default we only need to specify outDir and no outFile
outDir := opts.outDir
outFile := ""
var sourceMap api.SourceMap
switch opts.SourceMap {
case "inline":
sourceMap = api.SourceMapInline
case "external":
sourceMap = api.SourceMapExternal
case "":
sourceMap = api.SourceMapNone
default:
err = fmt.Errorf("unsupported sourcemap type: %q", opts.SourceMap)
return
}
buildOptions = api.BuildOptions{
Outfile: outFile,
Bundle: true,
Target: target,
Format: format,
Sourcemap: sourceMap,
MinifyWhitespace: opts.Minify,
MinifyIdentifiers: opts.Minify,
MinifySyntax: opts.Minify,
Outdir: outDir,
Define: defines,
External: opts.Externals,
JSXFactory: opts.JSXFactory,
JSXFragment: opts.JSXFragment,
JSX: jsx,
JSXImportSource: opts.JSXImportSource,
Tsconfig: opts.tsConfig,
// Note: We're not passing Sourcefile to ESBuild.
// This makes ESBuild pass `stdin` as the Importer to the import
// resolver, which is what we need/expect.
Stdin: &api.StdinOptions{
Contents: opts.contents,
ResolveDir: opts.resolveDir,
Loader: loader,
},
}
return
}

View file

@ -1,241 +0,0 @@
// Copyright 2020 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package js
import (
"path"
"path/filepath"
"testing"
"github.com/gohugoio/hugo/config"
"github.com/gohugoio/hugo/config/testconfig"
"github.com/gohugoio/hugo/hugofs"
"github.com/gohugoio/hugo/hugolib/filesystems"
"github.com/gohugoio/hugo/hugolib/paths"
"github.com/gohugoio/hugo/media"
"github.com/spf13/afero"
"github.com/evanw/esbuild/pkg/api"
qt "github.com/frankban/quicktest"
)
// This test is added to test/warn against breaking the "stability" of the
// cache key. It's sometimes needed to break this, but should be avoided if possible.
func TestOptionKey(t *testing.T) {
c := qt.New(t)
opts := map[string]any{
"TargetPath": "foo",
"Target": "es2018",
}
key := (&buildTransformation{optsm: opts}).Key()
c.Assert(key.Value(), qt.Equals, "jsbuild_1533819657654811600")
}
func TestToBuildOptions(t *testing.T) {
c := qt.New(t)
opts, err := toBuildOptions(Options{mediaType: media.Builtin.JavascriptType})
c.Assert(err, qt.IsNil)
c.Assert(opts, qt.DeepEquals, api.BuildOptions{
Bundle: true,
Target: api.ESNext,
Format: api.FormatIIFE,
Stdin: &api.StdinOptions{
Loader: api.LoaderJS,
},
})
opts, err = toBuildOptions(Options{
Target: "es2018",
Format: "cjs",
Minify: true,
mediaType: media.Builtin.JavascriptType,
AvoidTDZ: true,
})
c.Assert(err, qt.IsNil)
c.Assert(opts, qt.DeepEquals, api.BuildOptions{
Bundle: true,
Target: api.ES2018,
Format: api.FormatCommonJS,
MinifyIdentifiers: true,
MinifySyntax: true,
MinifyWhitespace: true,
Stdin: &api.StdinOptions{
Loader: api.LoaderJS,
},
})
opts, err = toBuildOptions(Options{
Target: "es2018", Format: "cjs", Minify: true, mediaType: media.Builtin.JavascriptType,
SourceMap: "inline",
})
c.Assert(err, qt.IsNil)
c.Assert(opts, qt.DeepEquals, api.BuildOptions{
Bundle: true,
Target: api.ES2018,
Format: api.FormatCommonJS,
MinifyIdentifiers: true,
MinifySyntax: true,
MinifyWhitespace: true,
Sourcemap: api.SourceMapInline,
Stdin: &api.StdinOptions{
Loader: api.LoaderJS,
},
})
opts, err = toBuildOptions(Options{
Target: "es2018", Format: "cjs", Minify: true, mediaType: media.Builtin.JavascriptType,
SourceMap: "inline",
})
c.Assert(err, qt.IsNil)
c.Assert(opts, qt.DeepEquals, api.BuildOptions{
Bundle: true,
Target: api.ES2018,
Format: api.FormatCommonJS,
MinifyIdentifiers: true,
MinifySyntax: true,
MinifyWhitespace: true,
Sourcemap: api.SourceMapInline,
Stdin: &api.StdinOptions{
Loader: api.LoaderJS,
},
})
opts, err = toBuildOptions(Options{
Target: "es2018", Format: "cjs", Minify: true, mediaType: media.Builtin.JavascriptType,
SourceMap: "external",
})
c.Assert(err, qt.IsNil)
c.Assert(opts, qt.DeepEquals, api.BuildOptions{
Bundle: true,
Target: api.ES2018,
Format: api.FormatCommonJS,
MinifyIdentifiers: true,
MinifySyntax: true,
MinifyWhitespace: true,
Sourcemap: api.SourceMapExternal,
Stdin: &api.StdinOptions{
Loader: api.LoaderJS,
},
})
opts, err = toBuildOptions(Options{
mediaType: media.Builtin.JavascriptType,
JSX: "automatic", JSXImportSource: "preact",
})
c.Assert(err, qt.IsNil)
c.Assert(opts, qt.DeepEquals, api.BuildOptions{
Bundle: true,
Target: api.ESNext,
Format: api.FormatIIFE,
Stdin: &api.StdinOptions{
Loader: api.LoaderJS,
},
JSX: api.JSXAutomatic,
JSXImportSource: "preact",
})
}
func TestToBuildOptionsTarget(t *testing.T) {
c := qt.New(t)
for _, test := range []struct {
target string
expect api.Target
}{
{"es2015", api.ES2015},
{"es2016", api.ES2016},
{"es2017", api.ES2017},
{"es2018", api.ES2018},
{"es2019", api.ES2019},
{"es2020", api.ES2020},
{"es2021", api.ES2021},
{"es2022", api.ES2022},
{"es2023", api.ES2023},
{"", api.ESNext},
{"esnext", api.ESNext},
} {
c.Run(test.target, func(c *qt.C) {
opts, err := toBuildOptions(Options{
Target: test.target,
mediaType: media.Builtin.JavascriptType,
})
c.Assert(err, qt.IsNil)
c.Assert(opts.Target, qt.Equals, test.expect)
})
}
}
func TestResolveComponentInAssets(t *testing.T) {
c := qt.New(t)
for _, test := range []struct {
name string
files []string
impPath string
expect string
}{
{"Basic, extension", []string{"foo.js", "bar.js"}, "foo.js", "foo.js"},
{"Basic, no extension", []string{"foo.js", "bar.js"}, "foo", "foo.js"},
{"Basic, no extension, typescript", []string{"foo.ts", "bar.js"}, "foo", "foo.ts"},
{"Not found", []string{"foo.js", "bar.js"}, "moo.js", ""},
{"Not found, double js extension", []string{"foo.js.js", "bar.js"}, "foo.js", ""},
{"Index file, folder only", []string{"foo/index.js", "bar.js"}, "foo", "foo/index.js"},
{"Index file, folder and index", []string{"foo/index.js", "bar.js"}, "foo/index", "foo/index.js"},
{"Index file, folder and index and suffix", []string{"foo/index.js", "bar.js"}, "foo/index.js", "foo/index.js"},
{"Index ESM file, folder only", []string{"foo/index.esm.js", "bar.js"}, "foo", "foo/index.esm.js"},
{"Index ESM file, folder and index", []string{"foo/index.esm.js", "bar.js"}, "foo/index", "foo/index.esm.js"},
{"Index ESM file, folder and index and suffix", []string{"foo/index.esm.js", "bar.js"}, "foo/index.esm.js", "foo/index.esm.js"},
// We added these index.esm.js cases in v0.101.0. The case below is unlikely to happen in the wild, but add a test
// to document Hugo's behavior. We pick the file with the name index.js; anything else would be breaking.
{"Index and Index ESM file, folder only", []string{"foo/index.esm.js", "foo/index.js", "bar.js"}, "foo", "foo/index.js"},
// Issue #8949
{"Check file before directory", []string{"foo.js", "foo/index.js"}, "foo", "foo.js"},
} {
c.Run(test.name, func(c *qt.C) {
baseDir := "assets"
mfs := afero.NewMemMapFs()
for _, filename := range test.files {
c.Assert(afero.WriteFile(mfs, filepath.Join(baseDir, filename), []byte("let foo='bar';"), 0o777), qt.IsNil)
}
conf := testconfig.GetTestConfig(mfs, config.New())
fs := hugofs.NewFrom(mfs, conf.BaseConfig())
p, err := paths.New(fs, conf)
c.Assert(err, qt.IsNil)
bfs, err := filesystems.NewBase(p, nil)
c.Assert(err, qt.IsNil)
got := resolveComponentInAssets(bfs.Assets.Fs, test.impPath)
gotPath := ""
expect := test.expect
if got != nil {
gotPath = filepath.ToSlash(got.Filename)
expect = path.Join(baseDir, test.expect)
}
c.Assert(gotPath, qt.Equals, expect)
})
}
}

View file

@ -0,0 +1,68 @@
// Copyright 2024 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package js
import (
"io"
"path"
"path/filepath"
"github.com/gohugoio/hugo/internal/js/esbuild"
"github.com/gohugoio/hugo/media"
"github.com/gohugoio/hugo/resources"
"github.com/gohugoio/hugo/resources/internal"
)
type buildTransformation struct {
optsm map[string]any
c *Client
}
func (t *buildTransformation) Key() internal.ResourceTransformationKey {
return internal.NewResourceTransformationKey("jsbuild", t.optsm)
}
func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
ctx.OutMediaType = media.Builtin.JavascriptType
var opts esbuild.Options
if t.optsm != nil {
optsExt, err := esbuild.DecodeExternalOptions(t.optsm)
if err != nil {
return err
}
opts.ExternalOptions = optsExt
}
if opts.TargetPath != "" {
ctx.OutPath = opts.TargetPath
} else {
ctx.ReplaceOutPathExtension(".js")
}
src, err := io.ReadAll(ctx.From)
if err != nil {
return err
}
opts.SourceDir = filepath.FromSlash(path.Dir(ctx.SourcePath))
opts.Contents = string(src)
opts.MediaType = ctx.InMediaType
opts.Stdin = true
_, err = t.c.transform(opts, ctx)
return err
}

View file

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

View file

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

View file

@ -1,4 +1,4 @@
// Copyright 2020 The Hugo Authors. All rights reserved. // Copyright 2024 The Hugo Authors. All rights reserved.
// //
// Licensed under the Apache License, Version 2.0 (the "License"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. // you may not use this file except in compliance with the License.
@ -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.

View file

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