Add js.Batch

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
// 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

View File

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

View File

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

View File

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

View File

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

View File

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