github.com/linchen2chris/hugo@v0.0.0-20230307053224-cec209389705/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/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 continue 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 // We need to check if this is a regular file imported without an extension. 167 // There may be ambiguous situations where both foo.js and foo/index.js exists. 168 // This import order is in line with both how Node and ESBuild's native 169 // import resolver works. 170 171 // It may be a regular file imported without an extension, e.g. 172 // foo or foo/index. 173 m = findFirst(impPath) 174 if m != nil { 175 return m 176 } 177 178 base := filepath.Base(impPath) 179 if base == "index" { 180 // try index.esm.js etc. 181 m = findFirst(impPath + ".esm") 182 if m != nil { 183 return m 184 } 185 } 186 187 // Check the path as is. 188 fi, err := fs.Stat(impPath) 189 190 if err == nil { 191 if fi.IsDir() { 192 m = findFirst(filepath.Join(impPath, "index")) 193 if m == nil { 194 m = findFirst(filepath.Join(impPath, "index.esm")) 195 } 196 } else { 197 m = fi.(hugofs.FileMetaInfo).Meta() 198 } 199 } else if strings.HasSuffix(base, ".js") { 200 m = findFirst(strings.TrimSuffix(impPath, ".js")) 201 } 202 203 return m 204 } 205 206 func createBuildPlugins(c *Client, opts Options) ([]api.Plugin, error) { 207 fs := c.rs.Assets 208 209 resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) { 210 impPath := args.Path 211 if opts.Shims != nil { 212 override, found := opts.Shims[impPath] 213 if found { 214 impPath = override 215 } 216 } 217 isStdin := args.Importer == stdinImporter 218 var relDir string 219 if !isStdin { 220 rel, found := fs.MakePathRelative(args.Importer) 221 if !found { 222 // Not in any of the /assets folders. 223 // This is an import from a node_modules, let 224 // ESBuild resolve this. 225 return api.OnResolveResult{}, nil 226 } 227 relDir = filepath.Dir(rel) 228 } else { 229 relDir = opts.sourceDir 230 } 231 232 // Imports not starting with a "." is assumed to live relative to /assets. 233 // Hugo makes no assumptions about the directory structure below /assets. 234 if relDir != "" && strings.HasPrefix(impPath, ".") { 235 impPath = filepath.Join(relDir, impPath) 236 } 237 238 m := resolveComponentInAssets(fs.Fs, impPath) 239 240 if m != nil { 241 // Store the source root so we can create a jsconfig.json 242 // to help intellisense when the build is done. 243 // This should be a small number of elements, and when 244 // in server mode, we may get stale entries on renames etc., 245 // but that shouldn't matter too much. 246 c.rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot) 247 return api.OnResolveResult{Path: m.Filename, Namespace: nsImportHugo}, nil 248 } 249 250 // Fall back to ESBuild's resolve. 251 return api.OnResolveResult{}, nil 252 } 253 254 importResolver := api.Plugin{ 255 Name: "hugo-import-resolver", 256 Setup: func(build api.PluginBuild) { 257 build.OnResolve(api.OnResolveOptions{Filter: `.*`}, 258 func(args api.OnResolveArgs) (api.OnResolveResult, error) { 259 return resolveImport(args) 260 }) 261 build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsImportHugo}, 262 func(args api.OnLoadArgs) (api.OnLoadResult, error) { 263 b, err := os.ReadFile(args.Path) 264 if err != nil { 265 return api.OnLoadResult{}, fmt.Errorf("failed to read %q: %w", args.Path, err) 266 } 267 c := string(b) 268 return api.OnLoadResult{ 269 // See https://github.com/evanw/esbuild/issues/502 270 // This allows all modules to resolve dependencies 271 // in the main project's node_modules. 272 ResolveDir: opts.resolveDir, 273 Contents: &c, 274 Loader: loaderFromFilename(args.Path), 275 }, nil 276 }) 277 }, 278 } 279 280 params := opts.Params 281 if params == nil { 282 // This way @params will always resolve to something. 283 params = make(map[string]any) 284 } 285 286 b, err := json.Marshal(params) 287 if err != nil { 288 return nil, fmt.Errorf("failed to marshal params: %w", err) 289 } 290 bs := string(b) 291 paramsPlugin := api.Plugin{ 292 Name: "hugo-params-plugin", 293 Setup: func(build api.PluginBuild) { 294 build.OnResolve(api.OnResolveOptions{Filter: `^@params$`}, 295 func(args api.OnResolveArgs) (api.OnResolveResult, error) { 296 return api.OnResolveResult{ 297 Path: args.Path, 298 Namespace: nsParams, 299 }, nil 300 }) 301 build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsParams}, 302 func(args api.OnLoadArgs) (api.OnLoadResult, error) { 303 return api.OnLoadResult{ 304 Contents: &bs, 305 Loader: api.LoaderJSON, 306 }, nil 307 }) 308 }, 309 } 310 311 return []api.Plugin{importResolver, paramsPlugin}, nil 312 } 313 314 func toBuildOptions(opts Options) (buildOptions api.BuildOptions, err error) { 315 var target api.Target 316 switch opts.Target { 317 case "", "esnext": 318 target = api.ESNext 319 case "es5": 320 target = api.ES5 321 case "es6", "es2015": 322 target = api.ES2015 323 case "es2016": 324 target = api.ES2016 325 case "es2017": 326 target = api.ES2017 327 case "es2018": 328 target = api.ES2018 329 case "es2019": 330 target = api.ES2019 331 case "es2020": 332 target = api.ES2020 333 default: 334 err = fmt.Errorf("invalid target: %q", opts.Target) 335 return 336 } 337 338 mediaType := opts.mediaType 339 if mediaType.IsZero() { 340 mediaType = media.JavascriptType 341 } 342 343 var loader api.Loader 344 switch mediaType.SubType { 345 // TODO(bep) ESBuild support a set of other loaders, but I currently fail 346 // to see the relevance. That may change as we start using this. 347 case media.JavascriptType.SubType: 348 loader = api.LoaderJS 349 case media.TypeScriptType.SubType: 350 loader = api.LoaderTS 351 case media.TSXType.SubType: 352 loader = api.LoaderTSX 353 case media.JSXType.SubType: 354 loader = api.LoaderJSX 355 default: 356 err = fmt.Errorf("unsupported Media Type: %q", opts.mediaType) 357 return 358 } 359 360 var format api.Format 361 // One of: iife, cjs, esm 362 switch opts.Format { 363 case "", "iife": 364 format = api.FormatIIFE 365 case "esm": 366 format = api.FormatESModule 367 case "cjs": 368 format = api.FormatCommonJS 369 default: 370 err = fmt.Errorf("unsupported script output format: %q", opts.Format) 371 return 372 } 373 374 var defines map[string]string 375 if opts.Defines != nil { 376 defines = maps.ToStringMapString(opts.Defines) 377 } 378 379 // By default we only need to specify outDir and no outFile 380 outDir := opts.outDir 381 outFile := "" 382 var sourceMap api.SourceMap 383 switch opts.SourceMap { 384 case "inline": 385 sourceMap = api.SourceMapInline 386 case "external": 387 sourceMap = api.SourceMapExternal 388 case "": 389 sourceMap = api.SourceMapNone 390 default: 391 err = fmt.Errorf("unsupported sourcemap type: %q", opts.SourceMap) 392 return 393 } 394 395 buildOptions = api.BuildOptions{ 396 Outfile: outFile, 397 Bundle: true, 398 399 Target: target, 400 Format: format, 401 Sourcemap: sourceMap, 402 403 MinifyWhitespace: opts.Minify, 404 MinifyIdentifiers: opts.Minify, 405 MinifySyntax: opts.Minify, 406 407 Outdir: outDir, 408 Define: defines, 409 410 External: opts.Externals, 411 412 JSXFactory: opts.JSXFactory, 413 JSXFragment: opts.JSXFragment, 414 415 Tsconfig: opts.tsConfig, 416 417 // Note: We're not passing Sourcefile to ESBuild. 418 // This makes ESBuild pass `stdin` as the Importer to the import 419 // resolver, which is what we need/expect. 420 Stdin: &api.StdinOptions{ 421 Contents: opts.contents, 422 ResolveDir: opts.resolveDir, 423 Loader: loader, 424 }, 425 } 426 return 427 }