options.go (11145B)
1 // Copyright 2020 The Hugo Authors. All rights reserved.
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 // http://www.apache.org/licenses/LICENSE-2.0
7 //
8 // Unless required by applicable law or agreed to in writing, software
9 // distributed under the License is distributed on an "AS IS" BASIS,
10 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 // See the License for the specific language governing permissions and
12 // limitations under the License.
13
14 package js
15
16 import (
17 "encoding/json"
18 "fmt"
19 "io/ioutil"
20 "path/filepath"
21 "strings"
22
23 "github.com/gohugoio/hugo/common/maps"
24 "github.com/spf13/afero"
25
26 "github.com/evanw/esbuild/pkg/api"
27
28 "github.com/gohugoio/hugo/helpers"
29 "github.com/gohugoio/hugo/hugofs"
30 "github.com/gohugoio/hugo/media"
31 "github.com/mitchellh/mapstructure"
32 )
33
34 const (
35 nsImportHugo = "ns-hugo"
36 nsParams = "ns-params"
37
38 stdinImporter = "<stdin>"
39 )
40
41 // Options esbuild configuration
42 type Options struct {
43 // If not set, the source path will be used as the base target path.
44 // Note that the target path's extension may change if the target MIME type
45 // is different, e.g. when the source is TypeScript.
46 TargetPath string
47
48 // Whether to minify to output.
49 Minify bool
50
51 // Whether to write mapfiles
52 SourceMap string
53
54 // The language target.
55 // One of: es2015, es2016, es2017, es2018, es2019, es2020 or esnext.
56 // Default is esnext.
57 Target string
58
59 // The output format.
60 // One of: iife, cjs, esm
61 // Default is to esm.
62 Format string
63
64 // External dependencies, e.g. "react".
65 Externals []string
66
67 // This option allows you to automatically replace a global variable with an import from another file.
68 // The filenames must be relative to /assets.
69 // See https://esbuild.github.io/api/#inject
70 Inject []string
71
72 // User defined symbols.
73 Defines map[string]any
74
75 // Maps a component import to another.
76 Shims map[string]string
77
78 // User defined params. Will be marshaled to JSON and available as "@params", e.g.
79 // import * as params from '@params';
80 Params any
81
82 // What to use instead of React.createElement.
83 JSXFactory string
84
85 // What to use instead of React.Fragment.
86 JSXFragment string
87
88 // There is/was a bug in WebKit with severe performance issue with the tracking
89 // of TDZ checks in JavaScriptCore.
90 //
91 // Enabling this flag removes the TDZ and `const` assignment checks and
92 // may improve performance of larger JS codebases until the WebKit fix
93 // is in widespread use.
94 //
95 // See https://bugs.webkit.org/show_bug.cgi?id=199866
96 // Deprecated: This no longer have any effect and will be removed.
97 // TODO(bep) remove. See https://github.com/evanw/esbuild/commit/869e8117b499ca1dbfc5b3021938a53ffe934dba
98 AvoidTDZ bool
99
100 mediaType media.Type
101 outDir string
102 contents string
103 sourceDir string
104 resolveDir string
105 tsConfig string
106 }
107
108 func decodeOptions(m map[string]any) (Options, error) {
109 var opts Options
110
111 if err := mapstructure.WeakDecode(m, &opts); err != nil {
112 return opts, err
113 }
114
115 if opts.TargetPath != "" {
116 opts.TargetPath = helpers.ToSlashTrimLeading(opts.TargetPath)
117 }
118
119 opts.Target = strings.ToLower(opts.Target)
120 opts.Format = strings.ToLower(opts.Format)
121
122 return opts, nil
123 }
124
125 var extensionToLoaderMap = map[string]api.Loader{
126 ".js": api.LoaderJS,
127 ".mjs": api.LoaderJS,
128 ".cjs": api.LoaderJS,
129 ".jsx": api.LoaderJSX,
130 ".ts": api.LoaderTS,
131 ".tsx": api.LoaderTSX,
132 ".css": api.LoaderCSS,
133 ".json": api.LoaderJSON,
134 ".txt": api.LoaderText,
135 }
136
137 func loaderFromFilename(filename string) api.Loader {
138 l, found := extensionToLoaderMap[filepath.Ext(filename)]
139 if found {
140 return l
141 }
142 return api.LoaderJS
143 }
144
145 func resolveComponentInAssets(fs afero.Fs, impPath string) *hugofs.FileMeta {
146 findFirst := func(base string) *hugofs.FileMeta {
147 // This is the most common sub-set of ESBuild's default extensions.
148 // We assume that imports of JSON, CSS etc. will be using their full
149 // name with extension.
150 for _, ext := range []string{".js", ".ts", ".tsx", ".jsx"} {
151 if strings.HasSuffix(impPath, ext) {
152 // Import of foo.js.js need the full name.
153 return nil
154 }
155 if fi, err := fs.Stat(base + ext); err == nil {
156 return fi.(hugofs.FileMetaInfo).Meta()
157 }
158 }
159
160 // Not found.
161 return nil
162 }
163
164 var m *hugofs.FileMeta
165
166 // See issue #8949.
167 // We need to check if this is a regular file imported without an extension.
168 // There may be ambigous situations where both foo.js and foo/index.js exists.
169 // This import order is in line with both how Node and ESBuild's native
170 // import resolver works.
171 // This was fixed in Hugo 0.88.
172
173 // It may be a regular file imported without an extension, e.g.
174 // foo or foo/index.
175 m = findFirst(impPath)
176 if m != nil {
177 return m
178 }
179 if filepath.Base(impPath) == "index" {
180 m = findFirst(impPath + ".esm")
181 if m != nil {
182 return m
183 }
184 }
185
186 // Finally check the path as is.
187 fi, err := fs.Stat(impPath)
188
189 if err == nil {
190 if fi.IsDir() {
191 m = findFirst(filepath.Join(impPath, "index"))
192 if m == nil {
193 m = findFirst(filepath.Join(impPath, "index.esm"))
194 }
195 } else {
196 m = fi.(hugofs.FileMetaInfo).Meta()
197 }
198 }
199
200 return m
201 }
202
203 func createBuildPlugins(c *Client, opts Options) ([]api.Plugin, error) {
204 fs := c.rs.Assets
205
206 resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
207 impPath := args.Path
208 if opts.Shims != nil {
209 override, found := opts.Shims[impPath]
210 if found {
211 impPath = override
212 }
213 }
214 isStdin := args.Importer == stdinImporter
215 var relDir string
216 if !isStdin {
217 rel, found := fs.MakePathRelative(args.Importer)
218 if !found {
219 // Not in any of the /assets folders.
220 // This is an import from a node_modules, let
221 // ESBuild resolve this.
222 return api.OnResolveResult{}, nil
223 }
224 relDir = filepath.Dir(rel)
225 } else {
226 relDir = opts.sourceDir
227 }
228
229 // Imports not starting with a "." is assumed to live relative to /assets.
230 // Hugo makes no assumptions about the directory structure below /assets.
231 if relDir != "" && strings.HasPrefix(impPath, ".") {
232 impPath = filepath.Join(relDir, impPath)
233 }
234
235 m := resolveComponentInAssets(fs.Fs, impPath)
236
237 if m != nil {
238 // Store the source root so we can create a jsconfig.json
239 // to help intellisense when the build is done.
240 // This should be a small number of elements, and when
241 // in server mode, we may get stale entries on renames etc.,
242 // but that shouldn't matter too much.
243 c.rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)
244 return api.OnResolveResult{Path: m.Filename, Namespace: nsImportHugo}, nil
245 }
246
247 // Fall back to ESBuild's resolve.
248 return api.OnResolveResult{}, nil
249 }
250
251 importResolver := api.Plugin{
252 Name: "hugo-import-resolver",
253 Setup: func(build api.PluginBuild) {
254 build.OnResolve(api.OnResolveOptions{Filter: `.*`},
255 func(args api.OnResolveArgs) (api.OnResolveResult, error) {
256 return resolveImport(args)
257 })
258 build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsImportHugo},
259 func(args api.OnLoadArgs) (api.OnLoadResult, error) {
260 b, err := ioutil.ReadFile(args.Path)
261 if err != nil {
262 return api.OnLoadResult{}, fmt.Errorf("failed to read %q: %w", args.Path, err)
263 }
264 c := string(b)
265 return api.OnLoadResult{
266 // See https://github.com/evanw/esbuild/issues/502
267 // This allows all modules to resolve dependencies
268 // in the main project's node_modules.
269 ResolveDir: opts.resolveDir,
270 Contents: &c,
271 Loader: loaderFromFilename(args.Path),
272 }, nil
273 })
274 },
275 }
276
277 params := opts.Params
278 if params == nil {
279 // This way @params will always resolve to something.
280 params = make(map[string]any)
281 }
282
283 b, err := json.Marshal(params)
284 if err != nil {
285 return nil, fmt.Errorf("failed to marshal params: %w", err)
286 }
287 bs := string(b)
288 paramsPlugin := api.Plugin{
289 Name: "hugo-params-plugin",
290 Setup: func(build api.PluginBuild) {
291 build.OnResolve(api.OnResolveOptions{Filter: `^@params$`},
292 func(args api.OnResolveArgs) (api.OnResolveResult, error) {
293 return api.OnResolveResult{
294 Path: args.Path,
295 Namespace: nsParams,
296 }, nil
297 })
298 build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsParams},
299 func(args api.OnLoadArgs) (api.OnLoadResult, error) {
300 return api.OnLoadResult{
301 Contents: &bs,
302 Loader: api.LoaderJSON,
303 }, nil
304 })
305 },
306 }
307
308 return []api.Plugin{importResolver, paramsPlugin}, nil
309 }
310
311 func toBuildOptions(opts Options) (buildOptions api.BuildOptions, err error) {
312 var target api.Target
313 switch opts.Target {
314 case "", "esnext":
315 target = api.ESNext
316 case "es5":
317 target = api.ES5
318 case "es6", "es2015":
319 target = api.ES2015
320 case "es2016":
321 target = api.ES2016
322 case "es2017":
323 target = api.ES2017
324 case "es2018":
325 target = api.ES2018
326 case "es2019":
327 target = api.ES2019
328 case "es2020":
329 target = api.ES2020
330 default:
331 err = fmt.Errorf("invalid target: %q", opts.Target)
332 return
333 }
334
335 mediaType := opts.mediaType
336 if mediaType.IsZero() {
337 mediaType = media.JavascriptType
338 }
339
340 var loader api.Loader
341 switch mediaType.SubType {
342 // TODO(bep) ESBuild support a set of other loaders, but I currently fail
343 // to see the relevance. That may change as we start using this.
344 case media.JavascriptType.SubType:
345 loader = api.LoaderJS
346 case media.TypeScriptType.SubType:
347 loader = api.LoaderTS
348 case media.TSXType.SubType:
349 loader = api.LoaderTSX
350 case media.JSXType.SubType:
351 loader = api.LoaderJSX
352 default:
353 err = fmt.Errorf("unsupported Media Type: %q", opts.mediaType)
354 return
355 }
356
357 var format api.Format
358 // One of: iife, cjs, esm
359 switch opts.Format {
360 case "", "iife":
361 format = api.FormatIIFE
362 case "esm":
363 format = api.FormatESModule
364 case "cjs":
365 format = api.FormatCommonJS
366 default:
367 err = fmt.Errorf("unsupported script output format: %q", opts.Format)
368 return
369 }
370
371 var defines map[string]string
372 if opts.Defines != nil {
373 defines = maps.ToStringMapString(opts.Defines)
374 }
375
376 // By default we only need to specify outDir and no outFile
377 outDir := opts.outDir
378 outFile := ""
379 var sourceMap api.SourceMap
380 switch opts.SourceMap {
381 case "inline":
382 sourceMap = api.SourceMapInline
383 case "external":
384 sourceMap = api.SourceMapExternal
385 case "":
386 sourceMap = api.SourceMapNone
387 default:
388 err = fmt.Errorf("unsupported sourcemap type: %q", opts.SourceMap)
389 return
390 }
391
392 buildOptions = api.BuildOptions{
393 Outfile: outFile,
394 Bundle: true,
395
396 Target: target,
397 Format: format,
398 Sourcemap: sourceMap,
399
400 MinifyWhitespace: opts.Minify,
401 MinifyIdentifiers: opts.Minify,
402 MinifySyntax: opts.Minify,
403
404 Outdir: outDir,
405 Define: defines,
406
407 External: opts.Externals,
408
409 JSXFactory: opts.JSXFactory,
410 JSXFragment: opts.JSXFragment,
411
412 Tsconfig: opts.tsConfig,
413
414 // Note: We're not passing Sourcefile to ESBuild.
415 // This makes ESBuild pass `stdin` as the Importer to the import
416 // resolver, which is what we need/expect.
417 Stdin: &api.StdinOptions{
418 Contents: opts.contents,
419 ResolveDir: opts.resolveDir,
420 Loader: loader,
421 },
422 }
423 return
424 }