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 }