mirror of
https://github.com/gohugoio/hugo.git
synced 2024-05-11 05:54:58 +00:00
Updates #7777 Added support to allow SourceMap files to be external to the build. In addition added more information when the js compilation has an error. Correctly append sourceMappingURL to output file. Fix merge conflict.
614 lines
15 KiB
Go
614 lines
15 KiB
Go
// 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"
|
|
"io/ioutil"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/achiku/varfmt"
|
|
"github.com/spf13/cast"
|
|
|
|
"github.com/gohugoio/hugo/helpers"
|
|
"github.com/gohugoio/hugo/hugolib/filesystems"
|
|
"github.com/gohugoio/hugo/media"
|
|
"github.com/gohugoio/hugo/resources/internal"
|
|
|
|
"github.com/mitchellh/mapstructure"
|
|
|
|
"github.com/evanw/esbuild/pkg/api"
|
|
"github.com/gohugoio/hugo/resources"
|
|
"github.com/gohugoio/hugo/resources/resource"
|
|
)
|
|
|
|
// 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 `hash:"set"`
|
|
|
|
// User defined symbols.
|
|
Defines map[string]interface{}
|
|
|
|
// User defined data (must be JSON marshall'able)
|
|
Data interface{}
|
|
|
|
// What to use instead of React.createElement.
|
|
JSXFactory string
|
|
|
|
// What to use instead of React.Fragment.
|
|
JSXFragment string
|
|
|
|
mediaType media.Type
|
|
outDir string
|
|
contents string
|
|
sourcefile string
|
|
resolveDir string
|
|
workDir string
|
|
tsConfig string
|
|
}
|
|
|
|
func decodeOptions(m map[string]interface{}) (Options, error) {
|
|
var opts Options
|
|
|
|
if err := mapstructure.WeakDecode(m, &opts); err != nil {
|
|
return opts, err
|
|
}
|
|
|
|
if opts.TargetPath != "" {
|
|
opts.TargetPath = helpers.ToSlashTrimLeading(opts.TargetPath)
|
|
}
|
|
|
|
opts.Target = strings.ToLower(opts.Target)
|
|
opts.Format = strings.ToLower(opts.Format)
|
|
|
|
return opts, nil
|
|
}
|
|
|
|
// Client context for esbuild
|
|
type Client struct {
|
|
rs *resources.Spec
|
|
sfs *filesystems.SourceFilesystem
|
|
}
|
|
|
|
// New create new client context
|
|
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client {
|
|
return &Client{rs: rs, sfs: fs}
|
|
}
|
|
|
|
type buildTransformation struct {
|
|
optsm map[string]interface{}
|
|
rs *resources.Spec
|
|
sfs *filesystems.SourceFilesystem
|
|
}
|
|
|
|
func (t *buildTransformation) Key() internal.ResourceTransformationKey {
|
|
return internal.NewResourceTransformationKey("jsbuild", t.optsm)
|
|
}
|
|
|
|
func appendExts(list []string, rel string) []string {
|
|
for _, ext := range []string{".tsx", ".ts", ".jsx", ".mjs", ".cjs", ".js", ".json"} {
|
|
list = append(list, fmt.Sprintf("%s/index%s", rel, ext))
|
|
}
|
|
return list
|
|
}
|
|
|
|
func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
|
|
ctx.OutMediaType = media.JavascriptType
|
|
|
|
opts, err := decodeOptions(t.optsm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.TargetPath != "" {
|
|
ctx.OutPath = opts.TargetPath
|
|
} else {
|
|
ctx.ReplaceOutPathExtension(".js")
|
|
}
|
|
|
|
src, err := ioutil.ReadAll(ctx.From)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sdir, sfile := filepath.Split(t.sfs.RealFilename(ctx.SourcePath))
|
|
opts.workDir, err = filepath.Abs(t.rs.WorkingDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
opts.sourcefile = sfile
|
|
opts.resolveDir = sdir
|
|
opts.contents = string(src)
|
|
opts.mediaType = ctx.InMediaType
|
|
|
|
// Create new temporary tsconfig file
|
|
newTSConfig, err := ioutil.TempFile("", "tsconfig.*.json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
filesToDelete := make([]*os.File, 0)
|
|
|
|
defer func() {
|
|
for _, file := range filesToDelete {
|
|
os.Remove(file.Name())
|
|
}
|
|
}()
|
|
|
|
filesToDelete = append(filesToDelete, newTSConfig)
|
|
configDir, _ := filepath.Split(newTSConfig.Name())
|
|
|
|
// Search for the innerMost tsconfig or jsconfig
|
|
innerTsConfig := ""
|
|
tsDir := opts.resolveDir
|
|
baseURLAbs := configDir
|
|
baseURL := "."
|
|
for tsDir != "." {
|
|
tryTsConfig := path.Join(tsDir, "tsconfig.json")
|
|
_, err := os.Stat(tryTsConfig)
|
|
if err != nil {
|
|
tryTsConfig := path.Join(tsDir, "jsconfig.json")
|
|
_, err = os.Stat(tryTsConfig)
|
|
if err == nil {
|
|
innerTsConfig = tryTsConfig
|
|
baseURLAbs = tsDir
|
|
break
|
|
}
|
|
} else {
|
|
innerTsConfig = tryTsConfig
|
|
baseURLAbs = tsDir
|
|
break
|
|
}
|
|
if tsDir == opts.workDir {
|
|
break
|
|
}
|
|
tsDir = path.Dir(tsDir)
|
|
}
|
|
|
|
// Resolve paths for @assets and @js (@js is just an alias for assets/js)
|
|
dirs := make([]string, 0)
|
|
rootPaths := make([]string, 0)
|
|
for _, dir := range t.sfs.RealDirs(".") {
|
|
rootDir := dir
|
|
if !strings.HasSuffix(dir, "package.json") {
|
|
dirs = append(dirs, dir)
|
|
} else {
|
|
rootDir, _ = path.Split(dir)
|
|
}
|
|
nodeModules := path.Join(rootDir, "node_modules")
|
|
if _, err := os.Stat(nodeModules); err == nil {
|
|
rootPaths = append(rootPaths, nodeModules)
|
|
}
|
|
}
|
|
|
|
// Construct new temporary tsconfig file content
|
|
config := make(map[string]interface{})
|
|
if innerTsConfig != "" {
|
|
oldConfig, err := ioutil.ReadFile(innerTsConfig)
|
|
if err == nil {
|
|
// If there is an error, it just means there is no config file here.
|
|
// Since we're also using the tsConfig file path to detect where
|
|
// to put the temp file, this is ok.
|
|
err = json.Unmarshal(oldConfig, &config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
if config["compilerOptions"] == nil {
|
|
config["compilerOptions"] = map[string]interface{}{}
|
|
}
|
|
|
|
// Assign new global paths to the config file while reading existing ones.
|
|
compilerOptions := config["compilerOptions"].(map[string]interface{})
|
|
|
|
// Handle original baseUrl if it's there
|
|
if compilerOptions["baseUrl"] != nil {
|
|
baseURL = compilerOptions["baseUrl"].(string)
|
|
oldBaseURLAbs := path.Join(tsDir, baseURL)
|
|
rel, _ := filepath.Rel(configDir, oldBaseURLAbs)
|
|
configDir = oldBaseURLAbs
|
|
baseURLAbs = configDir
|
|
if "/" != helpers.FilePathSeparator {
|
|
// On windows we need to use slashes instead of backslash
|
|
rel = strings.ReplaceAll(rel, helpers.FilePathSeparator, "/")
|
|
}
|
|
if rel != "" {
|
|
if strings.HasPrefix(rel, ".") {
|
|
baseURL = rel
|
|
} else {
|
|
baseURL = fmt.Sprintf("./%s", rel)
|
|
}
|
|
}
|
|
compilerOptions["baseUrl"] = baseURL
|
|
} else {
|
|
compilerOptions["baseUrl"] = baseURL
|
|
}
|
|
|
|
jsRel := func(refPath string) string {
|
|
rel, _ := filepath.Rel(configDir, refPath)
|
|
if "/" != helpers.FilePathSeparator {
|
|
// On windows we need to use slashes instead of backslash
|
|
rel = strings.ReplaceAll(rel, helpers.FilePathSeparator, "/")
|
|
}
|
|
if rel != "" {
|
|
if !strings.HasPrefix(rel, ".") {
|
|
rel = fmt.Sprintf("./%s", rel)
|
|
}
|
|
} else {
|
|
rel = "."
|
|
}
|
|
return rel
|
|
}
|
|
|
|
// Handle possible extends
|
|
if config["extends"] != nil {
|
|
extends := config["extends"].(string)
|
|
extendsAbs := path.Join(tsDir, extends)
|
|
rel := jsRel(extendsAbs)
|
|
config["extends"] = rel
|
|
}
|
|
|
|
var optionsPaths map[string]interface{}
|
|
// Get original paths if they exist
|
|
if compilerOptions["paths"] != nil {
|
|
optionsPaths = compilerOptions["paths"].(map[string]interface{})
|
|
} else {
|
|
optionsPaths = make(map[string]interface{})
|
|
}
|
|
compilerOptions["paths"] = optionsPaths
|
|
|
|
assets := make([]string, 0)
|
|
assetsExact := make([]string, 0)
|
|
js := make([]string, 0)
|
|
jsExact := make([]string, 0)
|
|
for _, dir := range dirs {
|
|
rel := jsRel(dir)
|
|
assets = append(assets, fmt.Sprintf("%s/*", rel))
|
|
assetsExact = appendExts(assetsExact, rel)
|
|
|
|
rel = jsRel(filepath.Join(dir, "js"))
|
|
js = append(js, fmt.Sprintf("%s/*", rel))
|
|
jsExact = appendExts(jsExact, rel)
|
|
}
|
|
|
|
optionsPaths["@assets/*"] = assets
|
|
optionsPaths["@js/*"] = js
|
|
|
|
// Make @js and @assets absolue matches search for index files
|
|
// to get around the problem in ESBuild resolving folders as index files.
|
|
optionsPaths["@assets"] = assetsExact
|
|
optionsPaths["@js"] = jsExact
|
|
|
|
var newDataFile *os.File
|
|
if opts.Data != nil {
|
|
// Create a data file
|
|
lines := make([]string, 0)
|
|
lines = append(lines, "// auto generated data import")
|
|
exports := make([]string, 0)
|
|
keys := make(map[string]bool)
|
|
|
|
var bytes []byte
|
|
|
|
conv := reflect.ValueOf(opts.Data)
|
|
convType := conv.Kind()
|
|
if convType == reflect.Interface {
|
|
if conv.IsNil() {
|
|
conv = reflect.Value{}
|
|
}
|
|
}
|
|
|
|
if conv.Kind() != reflect.Map {
|
|
// Write out as single JSON file
|
|
newDataFile, err = ioutil.TempFile("", "data.*.json")
|
|
// Output the data
|
|
bytes, err = json.MarshalIndent(conv.InterfaceData(), "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
// Try to allow tree shaking at the root
|
|
newDataFile, err = ioutil.TempFile(configDir, "data.*.js")
|
|
for _, key := range conv.MapKeys() {
|
|
strKey := key.Interface().(string)
|
|
if keys[strKey] {
|
|
continue
|
|
}
|
|
keys[strKey] = true
|
|
|
|
value := conv.MapIndex(key)
|
|
|
|
keyVar := varfmt.PublicVarName(strKey)
|
|
|
|
// Output the data
|
|
bytes, err := json.MarshalIndent(value.Interface(), "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
jsonValue := string(bytes)
|
|
|
|
lines = append(lines, fmt.Sprintf("export const %s = %s;", keyVar, jsonValue))
|
|
exports = append(exports, fmt.Sprintf(" %s,", keyVar))
|
|
if strKey != keyVar {
|
|
exports = append(exports, fmt.Sprintf(" [\"%s\"]: %s,", strKey, keyVar))
|
|
}
|
|
}
|
|
|
|
lines = append(lines, "const all = {")
|
|
for _, line := range exports {
|
|
lines = append(lines, line)
|
|
}
|
|
lines = append(lines, "};")
|
|
lines = append(lines, "export default all;")
|
|
|
|
bytes = []byte(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
// Write tsconfig file
|
|
_, err = newDataFile.Write(bytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = newDataFile.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Link this file into `import data from "@data"`
|
|
dataFiles := make([]string, 1)
|
|
rel, _ := filepath.Rel(baseURLAbs, newDataFile.Name())
|
|
dataFiles[0] = rel
|
|
optionsPaths["@data"] = dataFiles
|
|
|
|
filesToDelete = append(filesToDelete, newDataFile)
|
|
}
|
|
|
|
if len(rootPaths) > 0 {
|
|
// This will allow import "react" to resolve a react module that's
|
|
// either in the root node_modules or in one of the hugo mods.
|
|
optionsPaths["*"] = rootPaths
|
|
}
|
|
|
|
// Output the new config file
|
|
bytes, err := json.MarshalIndent(config, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write tsconfig file
|
|
_, err = newTSConfig.Write(bytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = newTSConfig.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Tell ESBuild about this new config file to use
|
|
opts.tsConfig = newTSConfig.Name()
|
|
|
|
buildOptions, err := toBuildOptions(opts)
|
|
if err != nil {
|
|
os.Remove(opts.tsConfig)
|
|
return err
|
|
}
|
|
|
|
result := api.Build(buildOptions)
|
|
|
|
if len(result.Warnings) > 0 {
|
|
for _, value := range result.Warnings {
|
|
if value.Location != nil {
|
|
t.rs.Logger.WARN.Println(fmt.Sprintf("%s:%d: WARN: %s",
|
|
filepath.Join(sdir, value.Location.File),
|
|
value.Location.Line, value.Text))
|
|
t.rs.Logger.WARN.Println(" ", value.Location.LineText)
|
|
} else {
|
|
t.rs.Logger.WARN.Println(fmt.Sprintf("%s: WARN: %s",
|
|
sdir,
|
|
value.Text))
|
|
}
|
|
}
|
|
}
|
|
if len(result.Errors) > 0 {
|
|
output := result.Errors[0].Text
|
|
for _, value := range result.Errors {
|
|
var line string
|
|
if value.Location != nil {
|
|
line = fmt.Sprintf("%s:%d ERROR: %s",
|
|
filepath.Join(sdir, value.Location.File),
|
|
value.Location.Line, value.Text)
|
|
} else {
|
|
line = fmt.Sprintf("%s ERROR: %s",
|
|
sdir,
|
|
value.Text)
|
|
}
|
|
t.rs.Logger.ERROR.Println(line)
|
|
output = fmt.Sprintf("%s\n%s", output, line)
|
|
if value.Location != nil {
|
|
t.rs.Logger.ERROR.Println(" ", value.Location.LineText)
|
|
}
|
|
}
|
|
return fmt.Errorf("%s", output)
|
|
}
|
|
|
|
if buildOptions.Outfile != "" {
|
|
_, tfile := path.Split(opts.TargetPath)
|
|
output := fmt.Sprintf("%s//# sourceMappingURL=%s\n",
|
|
string(result.OutputFiles[1].Contents), tfile+".map")
|
|
_, err := ctx.To.Write([]byte(output))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx.PublishSourceMap(string(result.OutputFiles[0].Contents))
|
|
} else {
|
|
ctx.To.Write(result.OutputFiles[0].Contents)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Process process esbuild transform
|
|
func (c *Client) Process(res resources.ResourceTransformer, opts map[string]interface{}) (resource.Resource, error) {
|
|
return res.Transform(
|
|
&buildTransformation{rs: c.rs, sfs: c.sfs, optsm: opts},
|
|
)
|
|
}
|
|
|
|
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
|
|
default:
|
|
err = fmt.Errorf("invalid target: %q", opts.Target)
|
|
return
|
|
}
|
|
|
|
mediaType := opts.mediaType
|
|
if mediaType.IsZero() {
|
|
mediaType = media.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.JavascriptType.SubType:
|
|
loader = api.LoaderJS
|
|
case media.TypeScriptType.SubType:
|
|
loader = api.LoaderTS
|
|
case media.TSXType.SubType:
|
|
loader = api.LoaderTSX
|
|
case media.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 defines map[string]string
|
|
if opts.Defines != nil {
|
|
defines = cast.ToStringMapString(opts.Defines)
|
|
}
|
|
|
|
// By default we only need to specify outDir and no outFile
|
|
var outDir = opts.outDir
|
|
var outFile = ""
|
|
var sourceMap api.SourceMap
|
|
switch opts.SourceMap {
|
|
case "inline":
|
|
sourceMap = api.SourceMapInline
|
|
case "external":
|
|
// When doing external sourcemaps we should specify
|
|
// out file and no out dir
|
|
sourceMap = api.SourceMapExternal
|
|
outFile = filepath.Join(opts.workDir, opts.TargetPath)
|
|
outDir = ""
|
|
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,
|
|
Defines: defines,
|
|
|
|
Externals: opts.Externals,
|
|
|
|
JSXFactory: opts.JSXFactory,
|
|
JSXFragment: opts.JSXFragment,
|
|
|
|
Tsconfig: opts.tsConfig,
|
|
|
|
Stdin: &api.StdinOptions{
|
|
Contents: opts.contents,
|
|
Sourcefile: opts.sourcefile,
|
|
ResolveDir: opts.resolveDir,
|
|
Loader: loader,
|
|
},
|
|
}
|
|
return
|
|
|
|
}
|