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