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