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  }