diff --git a/hugolib/js_test.go b/hugolib/js_test.go index 1e59927c79f..fb7c125975b 100644 --- a/hugolib/js_test.go +++ b/hugolib/js_test.go @@ -15,6 +15,7 @@ package hugolib import ( "os" + "os/exec" "path/filepath" "testing" @@ -29,7 +30,7 @@ import ( "github.com/gohugoio/hugo/common/loggers" ) -func TestJS_Build(t *testing.T) { +func TestJSBuild(t *testing.T) { if !isCI() { t.Skip("skip (relative) long running modules test when running locally") } @@ -43,13 +44,44 @@ func TestJS_Build(t *testing.T) { mainJS := ` import "./included"; + import { toCamelCase } from "to-camel-case"; + console.log("main"); - ` + console.log("To camel:", toCamelCase("space case")); +` includedJS := ` console.log("included"); + ` - workDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-test-babel") + jsxContent := ` +import * as React from 'react' +import * as ReactDOM from 'react-dom' + + ReactDOM.render( +

Hello, world!

, + document.getElementById('root') + ); +` + + tsContent := `function greeter(person: string) { + return "Hello, " + person; +} + +let user = [0, 1, 2]; + +document.body.textContent = greeter(user);` + + packageJSON := `{ + "scripts": {}, + + "dependencies": { + "to-camel-case": "1.0.0" + } +} +` + + workDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-test-js") c.Assert(err, qt.IsNil) defer clean() @@ -65,23 +97,38 @@ func TestJS_Build(t *testing.T) { b.WithContent("p1.md", "") b.WithTemplates("index.html", ` - {{ $options := dict "minify" true }} - {{ $transpiled := resources.Get "js/main.js" | js.Build $options }} - Built: {{ $transpiled.Content | safeJS }} - `) +{{ $options := dict "minify" false "externals" (slice "react" "react-dom") }} +{{ $js := resources.Get "js/main.js" | js.Build $options }} +JS: {{ template "print" $js }} +{{ $jsx := resources.Get "js/myjsx.jsx" | js.Build $options }} +JSX: {{ template "print" $jsx }} +{{ $ts := resources.Get "js/myts.ts" | js.Build }} +TS: {{ template "print" $ts }} + +{{ define "print" }}RelPermalink: {{.RelPermalink}}|MIME: {{ .MediaType }}|Content: {{ .Content | safeJS }}{{ end }} + +`) jsDir := filepath.Join(workDir, "assets", "js") b.Assert(os.MkdirAll(jsDir, 0777), qt.IsNil) + b.Assert(os.Chdir(workDir), qt.IsNil) + b.WithSourceFile("package.json", packageJSON) b.WithSourceFile("assets/js/main.js", mainJS) + b.WithSourceFile("assets/js/myjsx.jsx", jsxContent) + b.WithSourceFile("assets/js/myts.ts", tsContent) + b.WithSourceFile("assets/js/included.js", includedJS) - _, err = captureStdout(func() error { - return b.BuildE(BuildCfg{}) - }) - b.Assert(err, qt.IsNil) + out, err := exec.Command("npm", "install").CombinedOutput() + b.Assert(err, qt.IsNil, qt.Commentf(string(out))) + + b.Build(BuildCfg{}) b.AssertFileContent("public/index.html", ` - Built: (()=>{console.log("included");console.log("main");})(); - `) +console.log("included"); +if (hasSpace.test(string)) +const React = __toModule(require("react")); +function greeter(person) { +`) } diff --git a/media/mediaType.go b/media/mediaType.go index e33583a0e45..8a2efc4a454 100644 --- a/media/mediaType.go +++ b/media/mediaType.go @@ -45,7 +45,6 @@ type Type struct { Delimiter string `json:"delimiter"` // e.g. "." - // TODO(bep) make this a string to make it hashable + method Suffixes []string `json:"suffixes"` // Set when doing lookup by suffix. @@ -130,13 +129,17 @@ var ( CSVType = Type{MainType: "text", SubType: "csv", Suffixes: []string{"csv"}, Delimiter: defaultDelimiter} HTMLType = Type{MainType: "text", SubType: "html", Suffixes: []string{"html"}, Delimiter: defaultDelimiter} JavascriptType = Type{MainType: "application", SubType: "javascript", Suffixes: []string{"js"}, Delimiter: defaultDelimiter} - JSONType = Type{MainType: "application", SubType: "json", Suffixes: []string{"json"}, Delimiter: defaultDelimiter} - RSSType = Type{MainType: "application", SubType: "rss", mimeSuffix: "xml", Suffixes: []string{"xml"}, Delimiter: defaultDelimiter} - XMLType = Type{MainType: "application", SubType: "xml", Suffixes: []string{"xml"}, Delimiter: defaultDelimiter} - SVGType = Type{MainType: "image", SubType: "svg", mimeSuffix: "xml", Suffixes: []string{"svg"}, Delimiter: defaultDelimiter} - TextType = Type{MainType: "text", SubType: "plain", Suffixes: []string{"txt"}, Delimiter: defaultDelimiter} - TOMLType = Type{MainType: "application", SubType: "toml", Suffixes: []string{"toml"}, Delimiter: defaultDelimiter} - YAMLType = Type{MainType: "application", SubType: "yaml", Suffixes: []string{"yaml", "yml"}, Delimiter: defaultDelimiter} + TypeScriptType = Type{MainType: "application", SubType: "typescript", Suffixes: []string{"ts"}, Delimiter: defaultDelimiter} + TSXType = Type{MainType: "text", SubType: "tsx", Suffixes: []string{"tsx"}, Delimiter: defaultDelimiter} + JSXType = Type{MainType: "text", SubType: "jsx", Suffixes: []string{"jsx"}, Delimiter: defaultDelimiter} + + JSONType = Type{MainType: "application", SubType: "json", Suffixes: []string{"json"}, Delimiter: defaultDelimiter} + RSSType = Type{MainType: "application", SubType: "rss", mimeSuffix: "xml", Suffixes: []string{"xml"}, Delimiter: defaultDelimiter} + XMLType = Type{MainType: "application", SubType: "xml", Suffixes: []string{"xml"}, Delimiter: defaultDelimiter} + SVGType = Type{MainType: "image", SubType: "svg", mimeSuffix: "xml", Suffixes: []string{"svg"}, Delimiter: defaultDelimiter} + TextType = Type{MainType: "text", SubType: "plain", Suffixes: []string{"txt"}, Delimiter: defaultDelimiter} + TOMLType = Type{MainType: "application", SubType: "toml", Suffixes: []string{"toml"}, Delimiter: defaultDelimiter} + YAMLType = Type{MainType: "application", SubType: "yaml", Suffixes: []string{"yaml", "yml"}, Delimiter: defaultDelimiter} // Common image types PNGType = Type{MainType: "image", SubType: "png", Suffixes: []string{"png"}, Delimiter: defaultDelimiter} @@ -165,6 +168,9 @@ var DefaultTypes = Types{ SASSType, HTMLType, JavascriptType, + TypeScriptType, + TSXType, + JSXType, JSONType, RSSType, XMLType, diff --git a/media/mediaType_test.go b/media/mediaType_test.go index f18fd90bb0a..ee7d4407afe 100644 --- a/media/mediaType_test.go +++ b/media/mediaType_test.go @@ -40,6 +40,9 @@ func TestDefaultTypes(t *testing.T) { {CSVType, "text", "csv", "csv", "text/csv", "text/csv"}, {HTMLType, "text", "html", "html", "text/html", "text/html"}, {JavascriptType, "application", "javascript", "js", "application/javascript", "application/javascript"}, + {TypeScriptType, "application", "typescript", "ts", "application/typescript", "application/typescript"}, + {TSXType, "text", "tsx", "tsx", "text/tsx", "text/tsx"}, + {JSXType, "text", "jsx", "jsx", "text/jsx", "text/jsx"}, {JSONType, "application", "json", "json", "application/json", "application/json"}, {RSSType, "application", "rss", "xml", "application/rss+xml", "application/rss+xml"}, {SVGType, "image", "svg", "svg", "image/svg+xml", "image/svg+xml"}, @@ -58,7 +61,7 @@ func TestDefaultTypes(t *testing.T) { } - c.Assert(len(DefaultTypes), qt.Equals, 23) + c.Assert(len(DefaultTypes), qt.Equals, 26) } diff --git a/resources/resource_transformers/js/build.go b/resources/resource_transformers/js/build.go index 6224ee178ce..7591816962c 100644 --- a/resources/resource_transformers/js/build.go +++ b/resources/resource_transformers/js/build.go @@ -17,8 +17,11 @@ import ( "fmt" "io/ioutil" "path" + "strings" + "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/hugolib/filesystems" + "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/resources/internal" "github.com/mitchellh/mapstructure" @@ -28,15 +31,46 @@ import ( "github.com/gohugoio/hugo/resources/resource" ) +const defaultTarget = "esnext" + type Options struct { + // If not set, the source path will be used as the base target path. + // Note that the target path's extension may change if the target MIME type + // is different, e.g. when the source is TypeScript. + TargetPath string + + // Whether to minify to output. + Minify bool + + // The language target. + // One of: es2015, es2016, es2017, es2018, es2019, es2020 or esnext. + // Default is esnext. + Target string + + // External dependencies, e.g. "react". + Externals []string `hash:"set"` + + // What to use instead of React.createElement. + JSXFactory string + + // What to use instead of React.Fragment. + JSXFragment string +} + +type internalOptions struct { + TargetPath string Minify bool - Externals []string Target string - Loader string - Defines map[string]string JSXFactory string JSXFragment string - TSConfig string + + Externals []string `hash:"set"` + + // These are currently not exposed in the public Options struct, + // but added here to make the options hash as stable as possible for + // whenever we do. + Defines map[string]string + TSConfig string } func DecodeOptions(m map[string]interface{}) (opts Options, err error) { @@ -44,6 +78,13 @@ func DecodeOptions(m map[string]interface{}) (opts Options, err error) { return } err = mapstructure.WeakDecode(m, &opts) + + if opts.TargetPath != "" { + opts.TargetPath = helpers.ToSlashTrimLeading(opts.TargetPath) + } + + opts.Target = strings.ToLower(opts.Target) + return } @@ -57,7 +98,7 @@ func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client { } type buildTransformation struct { - options Options + options internalOptions rs *resources.Spec sfs *filesystems.SourceFilesystem } @@ -67,9 +108,17 @@ func (t *buildTransformation) Key() internal.ResourceTransformationKey { } func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { + ctx.OutMediaType = media.JavascriptType + + if t.options.TargetPath != "" { + ctx.OutPath = t.options.TargetPath + } else { + ctx.ReplaceOutPathExtension(".js") + } + var target api.Target switch t.options.Target { - case "", "esnext": + case defaultTarget: target = api.ESNext case "es6", "es2015": target = api.ES2015 @@ -88,29 +137,20 @@ func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx } var loader api.Loader - switch t.options.Loader { - case "", "js": + switch ctx.InMediaType.SubType { + // TODO(bep) ESBuild support a set of other loaders, but I currently fail + // to see the relevance. That may change as we start using this. + case media.JavascriptType.SubType: loader = api.LoaderJS - case "jsx": - loader = api.LoaderJSX - case "ts": + case media.TypeScriptType.SubType: loader = api.LoaderTS - case "tsx": + case media.TSXType.SubType: loader = api.LoaderTSX - case "json": - loader = api.LoaderJSON - case "text": - loader = api.LoaderText - case "base64": - loader = api.LoaderBase64 - case "dataURL": - loader = api.LoaderDataURL - case "file": - loader = api.LoaderFile - case "binary": - loader = api.LoaderBinary + case media.JSXType.SubType: + loader = api.LoaderJSX default: - return fmt.Errorf("invalid loader: %q", t.options.Loader) + return fmt.Errorf("unsupported Media Type: %q", ctx.InMediaType) + } src, err := ioutil.ReadAll(ctx.From) @@ -159,8 +199,22 @@ func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx return nil } -func (c *Client) Process(res resources.ResourceTransformer, options Options) (resource.Resource, error) { +func (c *Client) Process(res resources.ResourceTransformer, opts Options) (resource.Resource, error) { return res.Transform( - &buildTransformation{rs: c.rs, sfs: c.sfs, options: options}, + &buildTransformation{rs: c.rs, sfs: c.sfs, options: toInternalOptions(opts)}, ) } + +func toInternalOptions(opts Options) internalOptions { + target := opts.Target + if target == "" { + target = defaultTarget + } + return internalOptions{ + TargetPath: opts.TargetPath, + Target: target, + Externals: opts.Externals, + JSXFactory: opts.JSXFactory, + JSXFragment: opts.JSXFragment, + } +} diff --git a/resources/resource_transformers/js/build_test.go b/resources/resource_transformers/js/build_test.go new file mode 100644 index 00000000000..ddb249a2587 --- /dev/null +++ b/resources/resource_transformers/js/build_test.go @@ -0,0 +1,68 @@ +// Copyright 2020 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package js + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +// This test is added to test/warn against breaking the "stability" of the +// cache key. It's sometimes needed to break this, but should be avoided if possible. +func TestOptionKey(t *testing.T) { + c := qt.New(t) + + opts := internalOptions{ + TargetPath: "foo", + } + + key := (&buildTransformation{options: opts}).Key() + + c.Assert(key.Value(), qt.Equals, "jsbuild_9405671309963492201") +} + +func TestToInternalOptions(t *testing.T) { + c := qt.New(t) + + o := Options{ + TargetPath: "v1", + Target: "v2", + JSXFactory: "v3", + JSXFragment: "v4", + Externals: []string{"react"}, + } + + c.Assert(toInternalOptions(o), qt.DeepEquals, internalOptions{ + TargetPath: "v1", + Minify: false, + Target: "v2", + JSXFactory: "v3", + JSXFragment: "v4", + Externals: []string{"react"}, + Defines: nil, + TSConfig: "", + }) + + c.Assert(toInternalOptions(Options{}), qt.DeepEquals, internalOptions{ + TargetPath: "", + Minify: false, + Target: "esnext", + JSXFactory: "", + JSXFragment: "", + Externals: nil, + Defines: nil, + TSConfig: "", + }) +} diff --git a/tpl/internal/resourcehelpers/helpers.go b/tpl/internal/resourcehelpers/helpers.go new file mode 100644 index 00000000000..4f8b7539a74 --- /dev/null +++ b/tpl/internal/resourcehelpers/helpers.go @@ -0,0 +1,71 @@ +// Copyright 2020 The Hugo Authors. All rights reserved. +// +// Portions Copyright The Go Authors. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resourcehelpers + +import ( + "errors" + "fmt" + + _errors "github.com/pkg/errors" + + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/resources" +) + +// We allow string or a map as the first argument in some cases. +func ResolveIfFirstArgIsString(args []interface{}) (resources.ResourceTransformer, string, bool) { + if len(args) != 2 { + return nil, "", false + } + + v1, ok1 := args[0].(string) + if !ok1 { + return nil, "", false + } + v2, ok2 := args[1].(resources.ResourceTransformer) + + return v2, v1, ok2 +} + +// This roundabout way of doing it is needed to get both pipeline behaviour and options as arguments. +func ResolveArgs(args []interface{}) (resources.ResourceTransformer, map[string]interface{}, error) { + if len(args) == 0 { + return nil, nil, errors.New("no Resource provided in transformation") + } + + if len(args) == 1 { + r, ok := args[0].(resources.ResourceTransformer) + if !ok { + return nil, nil, fmt.Errorf("type %T not supported in Resource transformations", args[0]) + } + return r, nil, nil + } + + r, ok := args[1].(resources.ResourceTransformer) + if !ok { + if _, ok := args[1].(map[string]interface{}); !ok { + return nil, nil, fmt.Errorf("no Resource provided in transformation") + } + return nil, nil, fmt.Errorf("type %T not supported in Resource transformations", args[0]) + } + + m, err := maps.ToStringMapE(args[0]) + if err != nil { + return nil, nil, _errors.Wrap(err, "invalid options type") + } + + return r, m, nil +} diff --git a/tpl/js/js.go b/tpl/js/js.go index d8ba35a76ee..4dc97a70765 100644 --- a/tpl/js/js.go +++ b/tpl/js/js.go @@ -15,15 +15,12 @@ package js import ( - "errors" - "fmt" - - "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/resources/resource_transformers/js" - _errors "github.com/pkg/errors" + "github.com/gohugoio/hugo/tpl/internal/resourcehelpers" ) // New returns a new instance of the js-namespaced template functions. @@ -41,50 +38,33 @@ type Namespace struct { // Build processes the given Resource with ESBuild. func (ns *Namespace) Build(args ...interface{}) (resource.Resource, error) { - r, m, err := ns.resolveArgs(args) - if err != nil { - return nil, err - } - var options js.Options - if m != nil { - options, err = js.DecodeOptions(m) + var ( + r resources.ResourceTransformer + m map[string]interface{} + targetPath string + err error + ok bool + ) + + r, targetPath, ok = resourcehelpers.ResolveIfFirstArgIsString(args) + if !ok { + r, m, err = resourcehelpers.ResolveArgs(args) if err != nil { return nil, err } } - return ns.client.Process(r, options) - -} - -// This roundabout way of doing it is needed to get both pipeline behaviour and options as arguments. -// This is a copy of tpl/resources/resolveArgs -func (ns *Namespace) resolveArgs(args []interface{}) (resources.ResourceTransformer, map[string]interface{}, error) { - if len(args) == 0 { - return nil, nil, errors.New("no Resource provided in transformation") - } - - if len(args) == 1 { - r, ok := args[0].(resources.ResourceTransformer) - if !ok { - return nil, nil, fmt.Errorf("type %T not supported in Resource transformations", args[0]) - } - return r, nil, nil - } - - r, ok := args[1].(resources.ResourceTransformer) - if !ok { - if _, ok := args[1].(map[string]interface{}); !ok { - return nil, nil, fmt.Errorf("no Resource provided in transformation") + var options js.Options + if targetPath != "" { + options.TargetPath = helpers.ToSlashTrimLeading(targetPath) + } else if m != nil { + options, err = js.DecodeOptions(m) + if err != nil { + return nil, err } - return nil, nil, fmt.Errorf("type %T not supported in Resource transformations", args[0]) } - m, err := maps.ToStringMapE(args[0]) - if err != nil { - return nil, nil, _errors.Wrap(err, "invalid options type") - } + return ns.client.Process(r, options) - return r, m, nil } diff --git a/tpl/resources/resources.go b/tpl/resources/resources.go index 6625702ab26..cdde6bd5d31 100644 --- a/tpl/resources/resources.go +++ b/tpl/resources/resources.go @@ -19,13 +19,14 @@ import ( "fmt" "path/filepath" + "github.com/gohugoio/hugo/tpl/internal/resourcehelpers" + + "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/resources/postpub" - "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" - _errors "github.com/pkg/errors" "github.com/gohugoio/hugo/resources/resource_factories/bundler" "github.com/gohugoio/hugo/resources/resource_factories/create" @@ -239,10 +240,10 @@ func (ns *Namespace) ToCSS(args ...interface{}) (resource.Resource, error) { ok bool ) - r, targetPath, ok = ns.resolveIfFirstArgIsString(args) + r, targetPath, ok = resourcehelpers.ResolveIfFirstArgIsString(args) if !ok { - r, m, err = ns.resolveArgs(args) + r, m, err = resourcehelpers.ResolveArgs(args) if err != nil { return nil, err } @@ -250,7 +251,7 @@ func (ns *Namespace) ToCSS(args ...interface{}) (resource.Resource, error) { var options scss.Options if targetPath != "" { - options.TargetPath = targetPath + options.TargetPath = helpers.ToSlashTrimLeading(targetPath) } else if m != nil { options, err = scss.DecodeOptions(m) if err != nil { @@ -263,7 +264,7 @@ func (ns *Namespace) ToCSS(args ...interface{}) (resource.Resource, error) { // PostCSS processes the given Resource with PostCSS func (ns *Namespace) PostCSS(args ...interface{}) (resource.Resource, error) { - r, m, err := ns.resolveArgs(args) + r, m, err := resourcehelpers.ResolveArgs(args) if err != nil { return nil, err } @@ -285,7 +286,7 @@ func (ns *Namespace) PostProcess(r resource.Resource) (postpub.PostPublishedReso // Babel processes the given Resource with Babel. func (ns *Namespace) Babel(args ...interface{}) (resource.Resource, error) { - r, m, err := ns.resolveArgs(args) + r, m, err := resourcehelpers.ResolveArgs(args) if err != nil { return nil, err } @@ -301,48 +302,3 @@ func (ns *Namespace) Babel(args ...interface{}) (resource.Resource, error) { return ns.babelClient.Process(r, options) } - -// We allow string or a map as the first argument in some cases. -func (ns *Namespace) resolveIfFirstArgIsString(args []interface{}) (resources.ResourceTransformer, string, bool) { - if len(args) != 2 { - return nil, "", false - } - - v1, ok1 := args[0].(string) - if !ok1 { - return nil, "", false - } - v2, ok2 := args[1].(resources.ResourceTransformer) - - return v2, v1, ok2 -} - -// This roundabout way of doing it is needed to get both pipeline behaviour and options as arguments. -func (ns *Namespace) resolveArgs(args []interface{}) (resources.ResourceTransformer, map[string]interface{}, error) { - if len(args) == 0 { - return nil, nil, errors.New("no Resource provided in transformation") - } - - if len(args) == 1 { - r, ok := args[0].(resources.ResourceTransformer) - if !ok { - return nil, nil, fmt.Errorf("type %T not supported in Resource transformations", args[0]) - } - return r, nil, nil - } - - r, ok := args[1].(resources.ResourceTransformer) - if !ok { - if _, ok := args[1].(map[string]interface{}); !ok { - return nil, nil, fmt.Errorf("no Resource provided in transformation") - } - return nil, nil, fmt.Errorf("type %T not supported in Resource transformations", args[0]) - } - - m, err := maps.ToStringMapE(args[0]) - if err != nil { - return nil, nil, _errors.Wrap(err, "invalid options type") - } - - return r, m, nil -}