// 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 TestBatchMultiHost(t *testing.T) {
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term", "section"]
[languages]
[languages.en]
weight = 1
baseURL = "https://example.com/en"
[languages.fr]
weight = 2
baseURL = "https://example.com/fr"
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/index.html --
Home.
{{ $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 }}
{{ $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 }}
`
b := hugolib.Test(t, files, hugolib.TestOptWithOSFs())
b.AssertPublishDir(
"en/mybatch/chunk-TOZKWCDE.js", "en/mybatch/mygroup.js ",
"fr/mybatch/mygroup.js", "fr/mybatch/chunk-TOZKWCDE.js")
}
func TestBatchRenameBundledScript(t *testing.T) {
files := jsBatchFilesTemplate
b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs())
b.AssertFileContent("public/mybatch/p1.js", "P1 Script")
b.RenameFile("content/p1/p1script.js", "content/p1/p1script2.js")
_, err := b.BuildE()
b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, "resource not set")
// Rename it back.
b.RenameFile("content/p1/p1script2.js", "content/p1/p1script.js")
b.Build()
}
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 (
)
}
-- 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 (
)
}
-- 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 (
)
}
-- 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..")
}
}