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