diff options
Diffstat (limited to 'resources')
-rw-r--r-- | resources/jsconfig/jsconfig.go | 93 | ||||
-rw-r--r-- | resources/jsconfig/jsconfig_test.go | 35 | ||||
-rw-r--r-- | resources/resource_cache.go | 13 | ||||
-rw-r--r-- | resources/resource_spec.go | 34 | ||||
-rw-r--r-- | resources/resource_transformers/js/build.go | 565 | ||||
-rw-r--r-- | resources/resource_transformers/js/build_test.go | 82 | ||||
-rw-r--r-- | resources/resource_transformers/js/options.go | 353 | ||||
-rw-r--r-- | resources/resource_transformers/js/options_test.go | 105 |
8 files changed, 662 insertions, 618 deletions
diff --git a/resources/jsconfig/jsconfig.go b/resources/jsconfig/jsconfig.go new file mode 100644 index 000000000..9b399bfe7 --- /dev/null +++ b/resources/jsconfig/jsconfig.go @@ -0,0 +1,93 @@ +// 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 jsconfig + +import ( + "path/filepath" + "sort" + "sync" +) + +// Builder builds a jsconfig.json file that, currently, is used only to assist +// intellinsense in editors. +type Builder struct { + sourceRootsMu sync.RWMutex + sourceRoots map[string]bool +} + +// NewBuilder creates a new Builder. +func NewBuilder() *Builder { + return &Builder{sourceRoots: make(map[string]bool)} +} + +// Build builds a new Config with paths relative to dir. +// This method is thread safe. +func (b *Builder) Build(dir string) *Config { + b.sourceRootsMu.RLock() + defer b.sourceRootsMu.RUnlock() + + if len(b.sourceRoots) == 0 { + return nil + } + conf := newJSConfig() + + var roots []string + for root := range b.sourceRoots { + rel, err := filepath.Rel(dir, filepath.Join(root, "*")) + if err == nil { + roots = append(roots, rel) + } + } + sort.Strings(roots) + conf.CompilerOptions.Paths["*"] = roots + + return conf +} + +// AddSourceRoot adds a new source root. +// This method is thread safe. +func (b *Builder) AddSourceRoot(root string) { + b.sourceRootsMu.RLock() + found := b.sourceRoots[root] + b.sourceRootsMu.RUnlock() + + if found { + return + } + + b.sourceRootsMu.Lock() + b.sourceRoots[root] = true + b.sourceRootsMu.Unlock() + +} + +// CompilerOptions holds compilerOptions for jsonconfig.json. +type CompilerOptions struct { + BaseURL string `json:"baseUrl"` + Paths map[string][]string `json:"paths"` +} + +// Config holds the data for jsconfig.json. +type Config struct { + CompilerOptions CompilerOptions `json:"compilerOptions"` +} + +func newJSConfig() *Config { + return &Config{ + CompilerOptions: CompilerOptions{ + BaseURL: ".", + Paths: make(map[string][]string), + }, + } +} diff --git a/resources/jsconfig/jsconfig_test.go b/resources/jsconfig/jsconfig_test.go new file mode 100644 index 000000000..9a9657843 --- /dev/null +++ b/resources/jsconfig/jsconfig_test.go @@ -0,0 +1,35 @@ +// 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 jsconfig + +import ( + "path/filepath" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestJsConfigBuilder(t *testing.T) { + c := qt.New(t) + + b := NewBuilder() + b.AddSourceRoot("/c/assets") + b.AddSourceRoot("/d/assets") + + conf := b.Build("/a/b") + c.Assert(conf.CompilerOptions.BaseURL, qt.Equals, ".") + c.Assert(conf.CompilerOptions.Paths["*"], qt.DeepEquals, []string{filepath.FromSlash("../../c/assets/*"), filepath.FromSlash("../../d/assets/*")}) + + c.Assert(NewBuilder().Build("/a/b"), qt.IsNil) +} diff --git a/resources/resource_cache.go b/resources/resource_cache.go index feaa94f5c..6c4ba951b 100644 --- a/resources/resource_cache.go +++ b/resources/resource_cache.go @@ -18,6 +18,7 @@ import ( "io" "path" "path/filepath" + "regexp" "strings" "sync" @@ -296,21 +297,15 @@ func (c *ResourceCache) DeletePartitions(partitions ...string) { } -func (c *ResourceCache) DeleteContains(parts ...string) { +func (c *ResourceCache) DeleteMatches(re *regexp.Regexp) { c.Lock() defer c.Unlock() for k := range c.cache { - clear := false - for _, part := range parts { - if strings.Contains(k, part) { - clear = true - break - } - } - if clear { + if re.MatchString(k) { delete(c.cache, k) } + } } diff --git a/resources/resource_spec.go b/resources/resource_spec.go index 17225e3f5..0ca60fe31 100644 --- a/resources/resource_spec.go +++ b/resources/resource_spec.go @@ -23,6 +23,8 @@ import ( "strings" "sync" + "github.com/gohugoio/hugo/resources/jsconfig" + "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/config" @@ -76,17 +78,20 @@ func NewSpec( } rs := &Spec{ - PathSpec: s, - Logger: logger, - ErrorSender: errorHandler, - imaging: imaging, - incr: incr, - MediaTypes: mimeTypes, - OutputFormats: outputFormats, - Permalinks: permalinks, - BuildConfig: config.DecodeBuild(s.Cfg), - FileCaches: fileCaches, - PostProcessResources: make(map[string]postpub.PostPublishedResource), + PathSpec: s, + Logger: logger, + ErrorSender: errorHandler, + imaging: imaging, + incr: incr, + MediaTypes: mimeTypes, + OutputFormats: outputFormats, + Permalinks: permalinks, + BuildConfig: config.DecodeBuild(s.Cfg), + FileCaches: fileCaches, + PostBuildAssets: &PostBuildAssets{ + PostProcessResources: make(map[string]postpub.PostPublishedResource), + JSConfigBuilder: jsconfig.NewBuilder(), + }, imageCache: newImageCache( fileCaches.ImageCache(), @@ -121,8 +126,15 @@ type Spec struct { ResourceCache *ResourceCache FileCaches filecache.Caches + // Assets used after the build is done. + // This is shared between all sites. + *PostBuildAssets +} + +type PostBuildAssets struct { postProcessMu sync.RWMutex PostProcessResources map[string]postpub.PostPublishedResource + JSConfigBuilder *jsconfig.Builder } func (r *Spec) New(fd ResourceSourceDescriptor) (resource.Resource, error) { diff --git a/resources/resource_transformers/js/build.go b/resources/resource_transformers/js/build.go index d316bc85b..8a7c21592 100644 --- a/resources/resource_transformers/js/build.go +++ b/resources/resource_transformers/js/build.go @@ -14,122 +14,52 @@ package js import ( - "encoding/json" + "errors" "fmt" "io/ioutil" "os" "path" "path/filepath" - "reflect" "strings" - "github.com/achiku/varfmt" - "github.com/spf13/cast" + "github.com/gohugoio/hugo/hugofs" + + "github.com/spf13/afero" + + "github.com/gohugoio/hugo/common/herrors" - "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 +// Client context for ESBuild. type Client struct { rs *resources.Spec sfs *filesystems.SourceFilesystem } -// New create new client context +// New creates a new client context. func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client { - return &Client{rs: rs, sfs: fs} + return &Client{ + rs: rs, + sfs: fs, + } } type buildTransformation struct { optsm map[string]interface{} - rs *resources.Spec - sfs *filesystems.SourceFilesystem + c *Client } 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 @@ -149,465 +79,68 @@ func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx 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 + sdir, _ := path.Split(ctx.SourcePath) + opts.sourcefile = ctx.SourcePath + opts.resolveDir = t.c.sfs.RealFilename(sdir) + opts.workDir = t.c.rs.WorkingDir opts.contents = string(src) opts.mediaType = ctx.InMediaType - // Create new temporary tsconfig file - newTSConfig, err := ioutil.TempFile("", "tsconfig.*.json") + buildOptions, err := toBuildOptions(opts) if err != nil { return err } - filesToDelete := make([]*os.File, 0) - - defer func() { - for _, file := range filesToDelete { - os.Remove(file.Name()) - } - }() + buildOptions.Plugins, err = createBuildPlugins(t.c, opts) + if err != nil { + return err + } - filesToDelete = append(filesToDelete, newTSConfig) - configDir, _ := filepath.Split(newTSConfig.Name()) + result := api.Build(buildOptions) - // 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 len(result.Errors) > 0 { + first := result.Errors[0] + loc := first.Location + path := loc.File + + var err error + var f afero.File + var filename string + + if !strings.HasPrefix(path, "..") { + // Try first in the assets fs + var fi os.FileInfo + fi, err = t.c.rs.BaseFs.Assets.Fs.Stat(path) if err == nil { - innerTsConfig = tryTsConfig - baseURLAbs = tsDir - break + m := fi.(hugofs.FileMetaInfo).Meta() + filename = m.Filename() + f, err = m.Open() } - } 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) + if f == nil { + path = filepath.Join(t.c.rs.WorkingDir, path) + filename = path + f, err = t.c.rs.Fs.Os.Open(path) } - 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 { + fe := herrors.NewFileError("js", 0, loc.Line, loc.Column, errors.New(first.Text)) + err, _ := herrors.WithFileContext(fe, filename, f, herrors.SimpleLineMatcher) + f.Close() 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) + return fmt.Errorf("%s", result.Errors[0].Text) } - 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) - } + 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}, + &buildTransformation{c: c, 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 - -} diff --git a/resources/resource_transformers/js/build_test.go b/resources/resource_transformers/js/build_test.go index 8839c646e..30a4490ed 100644 --- a/resources/resource_transformers/js/build_test.go +++ b/resources/resource_transformers/js/build_test.go @@ -12,85 +12,3 @@ // limitations under the License. package js - -import ( - "testing" - - "github.com/gohugoio/hugo/media" - - "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]interface{}{ - "TargetPath": "foo", - "Target": "es2018", - } - - key := (&buildTransformation{optsm: opts}).Key() - - c.Assert(key.Value(), qt.Equals, "jsbuild_7891849149754191852") -} - -func TestToBuildOptions(t *testing.T) { - c := qt.New(t) - - opts, err := toBuildOptions(Options{mediaType: media.JavascriptType}) - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ESNext, - Format: api.FormatIIFE, - Stdin: &api.StdinOptions{}, - }) - - opts, err = toBuildOptions(Options{ - Target: "es2018", Format: "cjs", Minify: true, mediaType: media.JavascriptType}) - 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{}, - }) - - opts, err = toBuildOptions(Options{ - Target: "es2018", Format: "cjs", Minify: true, mediaType: media.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{}, - }) - - opts, err = toBuildOptions(Options{ - Target: "es2018", Format: "cjs", Minify: true, mediaType: media.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{}, - }) - -} diff --git a/resources/resource_transformers/js/options.go b/resources/resource_transformers/js/options.go new file mode 100644 index 000000000..5e74982d3 --- /dev/null +++ b/resources/resource_transformers/js/options.go @@ -0,0 +1,353 @@ +// 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" + "path/filepath" + "strings" + "sync" + + "github.com/pkg/errors" + + "github.com/evanw/esbuild/pkg/api" + + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/media" + "github.com/mitchellh/mapstructure" + "github.com/spf13/cast" +) + +// 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 params. Will be marshaled to JSON and available as "@params", e.g. + // import * as params from '@params'; + Params 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 +} + +type importCache struct { + sync.RWMutex + m map[string]api.OnResolveResult +} + +func createBuildPlugins(c *Client, opts Options) ([]api.Plugin, error) { + fs := c.rs.Assets + + cache := importCache{ + m: make(map[string]api.OnResolveResult), + } + + resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) { + relDir := fs.MakePathRelative(args.ResolveDir) + + if relDir == "" { + // Not in a Hugo Module, probably in node_modules. + return api.OnResolveResult{}, nil + } + + impPath := args.Path + + // stdin is the main entry file which already is at the relative root. + // Imports not starting with a "." is assumed to live relative to /assets. + // Hugo makes no assumptions about the directory structure below /assets. + if args.Importer != "<stdin>" && strings.HasPrefix(impPath, ".") { + impPath = filepath.Join(relDir, args.Path) + } + + 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 fi, err := fs.Fs.Stat(base + ext); err == nil { + return fi.(hugofs.FileMetaInfo).Meta() + } + } + + // Not found. + return nil + } + + var m hugofs.FileMeta + + // First the path as is. + fi, err := fs.Fs.Stat(impPath) + + if err == nil { + if fi.IsDir() { + m = findFirst(filepath.Join(impPath, "index")) + } else { + m = fi.(hugofs.FileMetaInfo).Meta() + } + } else { + // It may be a regular file imported without an extension. + m = findFirst(impPath) + } + + if m != nil { + // 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: ""}, nil + } + + 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) { + // Try cache first. + cache.RLock() + v, found := cache.m[args.Path] + cache.RUnlock() + + if found { + return v, nil + } + + imp, err := resolveImport(args) + if err != nil { + return imp, err + } + + cache.Lock() + defer cache.Unlock() + + cache.m[args.Path] = imp + + return imp, nil + + }) + }, + } + + params := opts.Params + if params == nil { + // This way @params will always resolve to something. + params = make(map[string]interface{}) + } + + b, err := json.Marshal(params) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal params") + } + 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: "params", + }, nil + }) + build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: "params"}, + 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 + 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, + Define: defines, + + External: 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 + +} diff --git a/resources/resource_transformers/js/options_test.go b/resources/resource_transformers/js/options_test.go new file mode 100644 index 000000000..89d362ab9 --- /dev/null +++ b/resources/resource_transformers/js/options_test.go @@ -0,0 +1,105 @@ +// 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 ( + "testing" + + "github.com/gohugoio/hugo/media" + + "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]interface{}{ + "TargetPath": "foo", + "Target": "es2018", + } + + key := (&buildTransformation{optsm: opts}).Key() + + c.Assert(key.Value(), qt.Equals, "jsbuild_7891849149754191852") +} + +func TestToBuildOptions(t *testing.T) { + c := qt.New(t) + + opts, err := toBuildOptions(Options{mediaType: media.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.JavascriptType}) + 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.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.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, + }, + }) + +} |