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  }