aboutsummaryrefslogtreecommitdiffhomepage
path: root/resources/resource_transformers/js/options.go
blob: 1f57709cd9510e1049371e21e96afca944e94c68 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
// 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"
	"os"
	"path/filepath"
	"strings"

	"github.com/gohugoio/hugo/common/maps"
	"github.com/spf13/afero"

	"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"
)

const (
	nsImportHugo = "ns-hugo"
	nsParams     = "ns-params"

	stdinImporter = "<stdin>"
)

// 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

	// 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

	// 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

	// 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

	mediaType  media.Type
	outDir     string
	contents   string
	sourceDir  string
	resolveDir string
	tsConfig   string
}

func decodeOptions(m map[string]any) (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
}

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,
}

func loaderFromFilename(filename string) api.Loader {
	l, found := extensionToLoaderMap[filepath.Ext(filename)]
	if found {
		return l
	}
	return api.LoaderJS
}

func resolveComponentInAssets(fs afero.Fs, impPath string) *hugofs.FileMeta {
	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 strings.HasSuffix(impPath, ext) {
				// Import of foo.js.js need the full name.
				continue
			}
			if fi, err := fs.Stat(base + ext); err == nil {
				return fi.(hugofs.FileMetaInfo).Meta()
			}
		}

		// Not found.
		return nil
	}

	var m *hugofs.FileMeta

	// 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.
	m = findFirst(impPath)
	if m != nil {
		return m
	}

	base := filepath.Base(impPath)
	if base == "index" {
		// try index.esm.js etc.
		m = findFirst(impPath + ".esm")
		if m != nil {
			return m
		}
	}

	// Check the path as is.
	fi, err := fs.Stat(impPath)

	if err == nil {
		if fi.IsDir() {
			m = findFirst(filepath.Join(impPath, "index"))
			if m == nil {
				m = findFirst(filepath.Join(impPath, "index.esm"))
			}
		} else {
			m = fi.(hugofs.FileMetaInfo).Meta()
		}
	} else if strings.HasSuffix(base, ".js") {
		m = findFirst(strings.TrimSuffix(impPath, ".js"))
	}

	return m
}

func createBuildPlugins(c *Client, opts Options) ([]api.Plugin, error) {
	fs := c.rs.Assets

	resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
		impPath := args.Path
		if opts.Shims != nil {
			override, found := opts.Shims[impPath]
			if found {
				impPath = override
			}
		}
		isStdin := args.Importer == stdinImporter
		var relDir string
		if !isStdin {
			rel, found := fs.MakePathRelative(args.Importer)
			if !found {
				// Not in any of the /assets folders.
				// This is an import from a node_modules, let
				// ESBuild resolve this.
				return api.OnResolveResult{}, nil
			}
			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 := resolveComponentInAssets(fs.Fs, 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: nsImportHugo}, 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: nsImportHugo},
				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:     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)
	}
	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: nsParams,
					}, nil
				})
			build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsParams},
				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.Builtin.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.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 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 "":
		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,

		// Note: We're not passing Sourcefile to ESBuild.
		// This makes ESBuild pass `stdin` as the Importer to the import
		// resolver, which is what we need/expect.
		Stdin: &api.StdinOptions{
			Contents:   opts.contents,
			ResolveDir: opts.resolveDir,
			Loader:     loader,
		},
	}
	return
}