diff options
author | Bjørn Erik Pedersen <[email protected]> | 2024-12-10 16:22:08 +0100 |
---|---|---|
committer | Bjørn Erik Pedersen <[email protected]> | 2024-12-12 21:43:17 +0100 |
commit | e293e7ca6dcc34cded7eb90a644b5c720c2179cf (patch) | |
tree | dee8e8d272660d23aa7b76576e8267fc70c34f78 /resources | |
parent | 157d86414d43f6801e2a6996108f67d28679eac5 (diff) | |
download | hugo-e293e7ca6dcc34cded7eb90a644b5c720c2179cf.tar.gz hugo-e293e7ca6dcc34cded7eb90a644b5c720c2179cf.zip |
Add js.Batch
Fixes #12626
Closes #7499
Closes #9978
Closes #12879
Closes #13113
Fixes #13116
Diffstat (limited to 'resources')
-rw-r--r-- | resources/image.go | 5 | ||||
-rw-r--r-- | resources/page/page.go | 3 | ||||
-rw-r--r-- | resources/resource.go | 54 | ||||
-rw-r--r-- | resources/resource/resource_helpers.go | 3 | ||||
-rw-r--r-- | resources/resource/resources.go | 184 | ||||
-rw-r--r-- | resources/resource/resources_integration_test.go | 105 | ||||
-rw-r--r-- | resources/resource/resources_test.go | 122 | ||||
-rw-r--r-- | resources/resource_factories/create/create.go | 19 | ||||
-rw-r--r-- | resources/resource_metadata.go | 4 | ||||
-rw-r--r-- | resources/resource_test.go | 18 | ||||
-rw-r--r-- | resources/resource_transformers/js/build.go | 200 | ||||
-rw-r--r-- | resources/resource_transformers/js/build_test.go | 14 | ||||
-rw-r--r-- | resources/resource_transformers/js/js_integration_test.go | 28 | ||||
-rw-r--r-- | resources/resource_transformers/js/options.go | 461 | ||||
-rw-r--r-- | resources/resource_transformers/js/options_test.go | 241 | ||||
-rw-r--r-- | resources/resource_transformers/js/transform.go | 68 | ||||
-rw-r--r-- | resources/resource_transformers/tocss/dartsass/transform.go | 4 | ||||
-rw-r--r-- | resources/transform.go | 15 |
18 files changed, 638 insertions, 910 deletions
diff --git a/resources/image.go b/resources/image.go index 4595866d4..686f70e27 100644 --- a/resources/image.go +++ b/resources/image.go @@ -51,6 +51,7 @@ var ( _ resource.Source = (*imageResource)(nil) _ resource.Cloner = (*imageResource)(nil) _ resource.NameNormalizedProvider = (*imageResource)(nil) + _ targetPathProvider = (*imageResource)(nil) ) // imageResource represents an image resource. @@ -160,6 +161,10 @@ func (i *imageResource) Colors() ([]images.Color, error) { return i.dominantColors, nil } +func (i *imageResource) targetPath() string { + return i.TargetPath() +} + // Clone is for internal use. func (i *imageResource) Clone() resource.Resource { gr := i.baseResource.Clone().(baseResource) diff --git a/resources/page/page.go b/resources/page/page.go index 032ee320d..40e15dc6f 100644 --- a/resources/page/page.go +++ b/resources/page/page.go @@ -63,8 +63,7 @@ type ChildCareProvider interface { // section. RegularPagesRecursive() Pages - // Resources returns a list of all resources. - Resources() resource.Resources + resource.ResourcesProvider } type MarkupProvider interface { diff --git a/resources/resource.go b/resources/resource.go index cc7008e5a..6025cbf4c 100644 --- a/resources/resource.go +++ b/resources/resource.go @@ -47,6 +47,8 @@ var ( _ resource.Cloner = (*genericResource)(nil) _ resource.ResourcesLanguageMerger = (*resource.Resources)(nil) _ resource.Identifier = (*genericResource)(nil) + _ targetPathProvider = (*genericResource)(nil) + _ sourcePathProvider = (*genericResource)(nil) _ identity.IdentityGroupProvider = (*genericResource)(nil) _ identity.DependencyManagerProvider = (*genericResource)(nil) _ identity.Identity = (*genericResource)(nil) @@ -79,6 +81,7 @@ type ResourceSourceDescriptor struct { TargetPath string BasePathRelPermalink string BasePathTargetPath string + SourceFilenameOrPath string // Used for error logging. // The Data to associate with this resource. Data map[string]any @@ -463,6 +466,17 @@ func (l *genericResource) Key() string { return key } +func (l *genericResource) targetPath() string { + return l.paths.TargetPath() +} + +func (l *genericResource) sourcePath() string { + if p := l.sd.SourceFilenameOrPath; p != "" { + return p + } + return "" +} + func (l *genericResource) MediaType() media.Type { return l.sd.MediaType } @@ -660,3 +674,43 @@ func (r *resourceHash) init(l hugio.ReadSeekCloserProvider) error { func hashImage(r io.ReadSeeker) (uint64, int64, error) { return hashing.XXHashFromReader(r) } + +// InternalResourceTargetPath is used internally to get the target path for a Resource. +func InternalResourceTargetPath(r resource.Resource) string { + return r.(targetPathProvider).targetPath() +} + +// InternalResourceSourcePathBestEffort is used internally to get the source path for a Resource. +// It returns an empty string if the source path is not available. +func InternalResourceSourcePath(r resource.Resource) string { + if sp, ok := r.(sourcePathProvider); ok { + if p := sp.sourcePath(); p != "" { + return p + } + } + return "" +} + +// InternalResourceSourcePathBestEffort is used internally to get the source path for a Resource. +// Used for error messages etc. +// It will fall back to the target path if the source path is not available. +func InternalResourceSourcePathBestEffort(r resource.Resource) string { + if s := InternalResourceSourcePath(r); s != "" { + return s + } + return InternalResourceTargetPath(r) +} + +type targetPathProvider interface { + // targetPath is the relative path to this resource. + // In most cases this will be the same as the RelPermalink(), + // but it will not trigger any lazy publishing. + targetPath() string +} + +// Optional interface implemented by resources that can provide the source path. +type sourcePathProvider interface { + // sourcePath is the source path to this resource's source. + // This is used in error messages etc. + sourcePath() string +} diff --git a/resources/resource/resource_helpers.go b/resources/resource/resource_helpers.go index c2bb463c8..8575ae79e 100644 --- a/resources/resource/resource_helpers.go +++ b/resources/resource/resource_helpers.go @@ -14,10 +14,11 @@ package resource import ( - "github.com/gohugoio/hugo/common/maps" "strings" "time" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/helpers" "github.com/pelletier/go-toml/v2" diff --git a/resources/resource/resources.go b/resources/resource/resources.go index 32bcdbb08..480c703b5 100644 --- a/resources/resource/resources.go +++ b/resources/resource/resources.go @@ -16,8 +16,11 @@ package resource import ( "fmt" + "path" "strings" + "github.com/gohugoio/hugo/common/hreflect" + "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/hugofs/glob" "github.com/spf13/cast" @@ -29,6 +32,51 @@ var _ ResourceFinder = (*Resources)(nil) // I.e. both pages and images etc. type Resources []Resource +// Mount mounts the given resources from base to the given target path. +// Note that leading slashes in target marks an absolute path. +// This method is currently only useful in js.Batch. +func (r Resources) Mount(base, target string) ResourceGetter { + return resourceGetterFunc(func(namev any) Resource { + name1, err := cast.ToStringE(namev) + if err != nil { + panic(err) + } + + isTargetAbs := strings.HasPrefix(target, "/") + + if target != "" { + name1 = strings.TrimPrefix(name1, target) + if !isTargetAbs { + name1 = paths.TrimLeading(name1) + } + } + + if base != "" && isTargetAbs { + name1 = path.Join(base, name1) + } + + for _, res := range r { + name2 := res.Name() + + if base != "" && !isTargetAbs { + name2 = paths.TrimLeading(strings.TrimPrefix(name2, base)) + } + + if strings.EqualFold(name1, name2) { + return res + } + + } + + return nil + }) +} + +type ResourcesProvider interface { + // Resources returns a list of all resources. + Resources() Resources +} + // var _ resource.ResourceFinder = (*Namespace)(nil) // ResourcesConverter converts a given slice of Resource objects to Resources. type ResourcesConverter interface { @@ -63,13 +111,25 @@ func (r Resources) Get(name any) Resource { panic(err) } - namestr = paths.AddLeadingSlash(namestr) + isDotCurrent := strings.HasPrefix(namestr, "./") + if isDotCurrent { + namestr = strings.TrimPrefix(namestr, "./") + } else { + namestr = paths.AddLeadingSlash(namestr) + } + + check := func(name string) bool { + if !isDotCurrent { + name = paths.AddLeadingSlash(name) + } + return strings.EqualFold(namestr, name) + } // First check the Name. // Note that this can be modified by the user in the front matter, // also, it does not contain any language code. for _, resource := range r { - if strings.EqualFold(namestr, paths.AddLeadingSlash(resource.Name())) { + if check(resource.Name()) { return resource } } @@ -77,7 +137,7 @@ func (r Resources) Get(name any) Resource { // Finally, check the normalized name. for _, resource := range r { if nop, ok := resource.(NameNormalizedProvider); ok { - if strings.EqualFold(namestr, paths.AddLeadingSlash(nop.NameNormalized())) { + if check(nop.NameNormalized()) { return resource } } @@ -197,14 +257,35 @@ type Source interface { Publish() error } -// ResourceFinder provides methods to find Resources. -// Note that GetRemote (as found in resources.GetRemote) is -// not covered by this interface, as this is only available as a global template function. -type ResourceFinder interface { +type ResourceGetter interface { // Get locates the Resource with the given name in the current context (e.g. in .Page.Resources). // // It returns nil if no Resource could found, panics if name is invalid. Get(name any) Resource +} + +type IsProbablySameResourceGetter interface { + IsProbablySameResourceGetter(other ResourceGetter) bool +} + +// StaleInfoResourceGetter is a ResourceGetter that also provides information about +// whether the underlying resources are stale. +type StaleInfoResourceGetter interface { + StaleInfo + ResourceGetter +} + +type resourceGetterFunc func(name any) Resource + +func (f resourceGetterFunc) Get(name any) Resource { + return f(name) +} + +// ResourceFinder provides methods to find Resources. +// Note that GetRemote (as found in resources.GetRemote) is +// not covered by this interface, as this is only available as a global template function. +type ResourceFinder interface { + ResourceGetter // GetMatch finds the first Resource matching the given pattern, or nil if none found. // @@ -235,3 +316,92 @@ type ResourceFinder interface { // It returns nil if no Resources could found, panics if typ is invalid. ByType(typ any) Resources } + +// NewCachedResourceGetter creates a new ResourceGetter from the given objects. +// If multiple objects are provided, they are merged into one where +// the first match wins. +func NewCachedResourceGetter(os ...any) *cachedResourceGetter { + var getters multiResourceGetter + for _, o := range os { + if g, ok := unwrapResourceGetter(o); ok { + getters = append(getters, g) + } + } + + return &cachedResourceGetter{ + cache: maps.NewCache[string, Resource](), + delegate: getters, + } +} + +type multiResourceGetter []ResourceGetter + +func (m multiResourceGetter) Get(name any) Resource { + for _, g := range m { + if res := g.Get(name); res != nil { + return res + } + } + return nil +} + +var ( + _ ResourceGetter = (*cachedResourceGetter)(nil) + _ IsProbablySameResourceGetter = (*cachedResourceGetter)(nil) +) + +type cachedResourceGetter struct { + cache *maps.Cache[string, Resource] + delegate ResourceGetter +} + +func (c *cachedResourceGetter) Get(name any) Resource { + namestr, err := cast.ToStringE(name) + if err != nil { + panic(err) + } + v, _ := c.cache.GetOrCreate(namestr, func() (Resource, error) { + v := c.delegate.Get(name) + return v, nil + }) + return v +} + +func (c *cachedResourceGetter) IsProbablySameResourceGetter(other ResourceGetter) bool { + isProbablyEq := true + c.cache.ForEeach(func(k string, v Resource) bool { + if v != other.Get(k) { + isProbablyEq = false + return false + } + return true + }) + + return isProbablyEq +} + +func unwrapResourceGetter(v any) (ResourceGetter, bool) { + if v == nil { + return nil, false + } + switch vv := v.(type) { + case ResourceGetter: + return vv, true + case ResourcesProvider: + return vv.Resources(), true + case func(name any) Resource: + return resourceGetterFunc(vv), true + default: + vvv, ok := hreflect.ToSliceAny(v) + if !ok { + return nil, false + } + var getters multiResourceGetter + for _, vv := range vvv { + if g, ok := unwrapResourceGetter(vv); ok { + getters = append(getters, g) + } + } + return getters, len(getters) > 0 + } +} diff --git a/resources/resource/resources_integration_test.go b/resources/resource/resources_integration_test.go new file mode 100644 index 000000000..920fc7397 --- /dev/null +++ b/resources/resource/resources_integration_test.go @@ -0,0 +1,105 @@ +// Copyright 2024 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 resource_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestResourcesMount(t *testing.T) { + files := ` +-- hugo.toml -- +-- assets/text/txt1.txt -- +Text 1. +-- assets/text/txt2.txt -- +Text 2. +-- assets/text/sub/txt3.txt -- +Text 3. +-- assets/text/sub/txt4.txt -- +Text 4. +-- content/mybundle/index.md -- +--- +title: "My Bundle" +--- +-- content/mybundle/txt1.txt -- +Text 1. +-- content/mybundle/sub/txt2.txt -- +Text 1. +-- layouts/index.html -- +{{ $mybundle := site.GetPage "mybundle" }} +{{ $subResources := resources.Match "/text/sub/*.*" }} +{{ $subResourcesMount := $subResources.Mount "/text/sub" "/newroot" }} +resources:text/txt1.txt:{{ with resources.Get "text/txt1.txt" }}{{ .Name }}{{ end }}| +resources:text/txt2.txt:{{ with resources.Get "text/txt2.txt" }}{{ .Name }}{{ end }}| +resources:text/sub/txt3.txt:{{ with resources.Get "text/sub/txt3.txt" }}{{ .Name }}{{ end }}| +subResources.range:{{ range $subResources }}{{ .Name }}|{{ end }}| +subResources:"text/sub/txt3.txt:{{ with $subResources.Get "text/sub/txt3.txt" }}{{ .Name }}{{ end }}| +subResourcesMount:/newroot/txt3.txt:{{ with $subResourcesMount.Get "/newroot/txt3.txt" }}{{ .Name }}{{ end }}| +page:txt1.txt:{{ with $mybundle.Resources.Get "txt1.txt" }}{{ .Name }}{{ end }}| +page:./txt1.txt:{{ with $mybundle.Resources.Get "./txt1.txt" }}{{ .Name }}{{ end }}| +page:sub/txt2.txt:{{ with $mybundle.Resources.Get "sub/txt2.txt" }}{{ .Name }}{{ end }}| +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", ` +resources:text/txt1.txt:/text/txt1.txt| +resources:text/txt2.txt:/text/txt2.txt| +resources:text/sub/txt3.txt:/text/sub/txt3.txt| +subResources:"text/sub/txt3.txt:/text/sub/txt3.txt| +subResourcesMount:/newroot/txt3.txt:/text/sub/txt3.txt| +page:txt1.txt:txt1.txt| +page:./txt1.txt:txt1.txt| +page:sub/txt2.txt:sub/txt2.txt| +`) +} + +func TestResourcesMountOnRename(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "home", "sitemap"] +-- content/mybundle/index.md -- +--- +title: "My Bundle" +resources: +- name: /foo/bars.txt + src: foo/txt1.txt +- name: foo/bars2.txt + src: foo/txt2.txt +--- +-- content/mybundle/foo/txt1.txt -- +Text 1. +-- content/mybundle/foo/txt2.txt -- +Text 2. +-- layouts/_default/single.html -- +Single. +{{ $mybundle := site.GetPage "mybundle" }} +Resources:{{ range $mybundle.Resources }}Name: {{ .Name }}|{{ end }}$ +{{ $subResourcesMount := $mybundle.Resources.Mount "/foo" "/newroot" }} + {{ $subResourcesMount2 := $mybundle.Resources.Mount "foo" "/newroot" }} +{{ $subResourcesMount3 := $mybundle.Resources.Mount "foo" "." }} +subResourcesMount:/newroot/bars.txt:{{ with $subResourcesMount.Get "/newroot/bars.txt" }}{{ .Name }}{{ end }}| +subResourcesMount:/newroot/bars2.txt:{{ with $subResourcesMount.Get "/newroot/bars2.txt" }}{{ .Name }}{{ end }}| +subResourcesMount2:/newroot/bars2.txt:{{ with $subResourcesMount2.Get "/newroot/bars2.txt" }}{{ .Name }}{{ end }}| +subResourcesMount3:bars2.txt:{{ with $subResourcesMount3.Get "bars2.txt" }}{{ .Name }}{{ end }}| +` + b := hugolib.Test(t, files) + b.AssertFileContent("public/mybundle/index.html", + "Resources:Name: foo/bars.txt|Name: foo/bars2.txt|$", + "subResourcesMount:/newroot/bars.txt:|\nsubResourcesMount:/newroot/bars2.txt:|", + "subResourcesMount2:/newroot/bars2.txt:foo/bars2.txt|", + "subResourcesMount3:bars2.txt:foo/bars2.txt|", + ) +} diff --git a/resources/resource/resources_test.go b/resources/resource/resources_test.go new file mode 100644 index 000000000..ebadbb312 --- /dev/null +++ b/resources/resource/resources_test.go @@ -0,0 +1,122 @@ +// Copyright 2024 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 resource + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestResourcesMount(t *testing.T) { + c := qt.New(t) + c.Assert(true, qt.IsTrue) + + var m ResourceGetter + var r Resources + + check := func(in, expect string) { + c.Helper() + r := m.Get(in) + c.Assert(r, qt.Not(qt.IsNil)) + c.Assert(r.Name(), qt.Equals, expect) + } + + checkNil := func(in string) { + c.Helper() + r := m.Get(in) + c.Assert(r, qt.IsNil) + } + + // Misc tests. + r = Resources{ + testResource{name: "/foo/theme.css"}, + } + + m = r.Mount("/foo", ".") + check("./theme.css", "/foo/theme.css") + + // Relative target. + r = Resources{ + testResource{name: "/a/b/c/d.txt"}, + testResource{name: "/a/b/c/e/f.txt"}, + testResource{name: "/a/b/d.txt"}, + testResource{name: "/a/b/e.txt"}, + } + + m = r.Mount("/a/b/c", "z") + check("z/d.txt", "/a/b/c/d.txt") + check("z/e/f.txt", "/a/b/c/e/f.txt") + + m = r.Mount("/a/b", "") + check("d.txt", "/a/b/d.txt") + m = r.Mount("/a/b", ".") + check("d.txt", "/a/b/d.txt") + m = r.Mount("/a/b", "./") + check("d.txt", "/a/b/d.txt") + check("./d.txt", "/a/b/d.txt") + + m = r.Mount("/a/b", ".") + check("./d.txt", "/a/b/d.txt") + + // Absolute target. + m = r.Mount("/a/b/c", "/z") + check("/z/d.txt", "/a/b/c/d.txt") + check("/z/e/f.txt", "/a/b/c/e/f.txt") + checkNil("/z/f.txt") + + m = r.Mount("/a/b", "/z") + check("/z/c/d.txt", "/a/b/c/d.txt") + check("/z/c/e/f.txt", "/a/b/c/e/f.txt") + check("/z/d.txt", "/a/b/d.txt") + checkNil("/z/f.txt") + + m = r.Mount("", "") + check("/a/b/c/d.txt", "/a/b/c/d.txt") + check("/a/b/c/e/f.txt", "/a/b/c/e/f.txt") + check("/a/b/d.txt", "/a/b/d.txt") + checkNil("/a/b/f.txt") + + m = r.Mount("/a/b", "/a/b") + check("/a/b/c/d.txt", "/a/b/c/d.txt") + check("/a/b/c/e/f.txt", "/a/b/c/e/f.txt") + check("/a/b/d.txt", "/a/b/d.txt") + checkNil("/a/b/f.txt") + + // Resources with relative paths. + r = Resources{ + testResource{name: "a/b/c/d.txt"}, + testResource{name: "a/b/c/e/f.txt"}, + testResource{name: "a/b/d.txt"}, + testResource{name: "a/b/e.txt"}, + testResource{name: "n.txt"}, + } + + m = r.Mount("a/b", "z") + check("z/d.txt", "a/b/d.txt") + checkNil("/z/d.txt") +} + +type testResource struct { + Resource + name string +} + +func (r testResource) Name() string { + return r.name +} + +func (r testResource) NameNormalized() string { + return r.name +} diff --git a/resources/resource_factories/create/create.go b/resources/resource_factories/create/create.go index 2d868bd15..7dd26f4c0 100644 --- a/resources/resource_factories/create/create.go +++ b/resources/resource_factories/create/create.go @@ -143,16 +143,18 @@ func (c *Client) Get(pathname string) (resource.Resource, error) { return nil, err } - pi := fi.(hugofs.FileMetaInfo).Meta().PathInfo + meta := fi.(hugofs.FileMetaInfo).Meta() + pi := meta.PathInfo return c.rs.NewResource(resources.ResourceSourceDescriptor{ LazyPublish: true, OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) { return c.rs.BaseFs.Assets.Fs.Open(filename) }, - Path: pi, - GroupIdentity: pi, - TargetPath: pathname, + Path: pi, + GroupIdentity: pi, + TargetPath: pathname, + SourceFilenameOrPath: meta.Filename, }) }) } @@ -196,10 +198,11 @@ func (c *Client) match(name, pattern string, matchFunc func(r resource.Resource) OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) { return meta.Open() }, - NameNormalized: meta.PathInfo.Path(), - NameOriginal: meta.PathInfo.Unnormalized().Path(), - GroupIdentity: meta.PathInfo, - TargetPath: meta.PathInfo.Unnormalized().Path(), + NameNormalized: meta.PathInfo.Path(), + NameOriginal: meta.PathInfo.Unnormalized().Path(), + GroupIdentity: meta.PathInfo, + TargetPath: meta.PathInfo.Unnormalized().Path(), + SourceFilenameOrPath: meta.Filename, }) if err != nil { return true, err diff --git a/resources/resource_metadata.go b/resources/resource_metadata.go index 7d2459225..8861ded5c 100644 --- a/resources/resource_metadata.go +++ b/resources/resource_metadata.go @@ -15,6 +15,7 @@ package resources import ( "fmt" + "path/filepath" "strconv" "strings" @@ -26,6 +27,7 @@ import ( "github.com/spf13/cast" "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" ) var ( @@ -172,6 +174,8 @@ func assignMetadata(metadata []map[string]any, ma *metaResource) error { name, found := meta["name"] if found { name := cast.ToString(name) + // Bundled resources in sub folders are relative paths with forward slashes. Make sure any renames also matches that format: + name = paths.TrimLeading(filepath.ToSlash(name)) if !nameCounterFound { nameCounterFound = strings.Contains(name, counterPlaceHolder) } diff --git a/resources/resource_test.go b/resources/resource_test.go index ccce8eff7..d07770456 100644 --- a/resources/resource_test.go +++ b/resources/resource_test.go @@ -16,8 +16,26 @@ package resources import ( "os" "testing" + + qt "github.com/frankban/quicktest" ) +func TestAtomicStaler(t *testing.T) { + c := qt.New(t) + + type test struct { + AtomicStaler + } + + var v test + + c.Assert(v.StaleVersion(), qt.Equals, uint32(0)) + v.MarkStale() + c.Assert(v.StaleVersion(), qt.Equals, uint32(1)) + v.MarkStale() + c.Assert(v.StaleVersion(), qt.Equals, uint32(2)) +} + func BenchmarkHashImage(b *testing.B) { f, err := os.Open("testdata/sunset.jpg") if err != nil { diff --git a/resources/resource_transformers/js/build.go b/resources/resource_transformers/js/build.go index cc68d2253..bd943461f 100644 --- a/resources/resource_transformers/js/build.go +++ b/resources/resource_transformers/js/build.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -14,209 +14,69 @@ package js import ( - "errors" - "fmt" - "io" - "os" "path" - "path/filepath" "regexp" - "strings" - - "github.com/spf13/afero" - - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/media" - - "github.com/gohugoio/hugo/common/herrors" - "github.com/gohugoio/hugo/common/text" + "github.com/evanw/esbuild/pkg/api" "github.com/gohugoio/hugo/hugolib/filesystems" - "github.com/gohugoio/hugo/resources/internal" + "github.com/gohugoio/hugo/internal/js/esbuild" - "github.com/evanw/esbuild/pkg/api" "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" ) // Client context for ESBuild. type Client struct { - rs *resources.Spec - sfs *filesystems.SourceFilesystem + c *esbuild.BuildClient } // New creates a new client context. func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client { return &Client{ - rs: rs, - sfs: fs, + c: esbuild.NewBuildClient(fs, rs), } } -type buildTransformation struct { - optsm map[string]any - c *Client -} - -func (t *buildTransformation) Key() internal.ResourceTransformationKey { - return internal.NewResourceTransformationKey("jsbuild", t.optsm) +// Process processes a resource with the user provided options. +func (c *Client) Process(res resources.ResourceTransformer, opts map[string]any) (resource.Resource, error) { + return res.Transform( + &buildTransformation{c: c, optsm: opts}, + ) } -func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { - ctx.OutMediaType = media.Builtin.JavascriptType - - opts, err := decodeOptions(t.optsm) - if err != nil { - return err +func (c *Client) transform(opts esbuild.Options, transformCtx *resources.ResourceTransformationCtx) (api.BuildResult, error) { + if transformCtx.DependencyManager != nil { + opts.DependencyManager = transformCtx.DependencyManager } - if opts.TargetPath != "" { - ctx.OutPath = opts.TargetPath - } else { - ctx.ReplaceOutPathExtension(".js") - } + opts.StdinSourcePath = transformCtx.SourcePath - src, err := io.ReadAll(ctx.From) + result, err := c.c.Build(opts) if err != nil { - return err + return result, err } - opts.sourceDir = filepath.FromSlash(path.Dir(ctx.SourcePath)) - opts.resolveDir = t.c.rs.Cfg.BaseConfig().WorkingDir // where node_modules gets resolved - opts.contents = string(src) - opts.mediaType = ctx.InMediaType - opts.tsConfig = t.c.rs.ResolveJSConfigFile("tsconfig.json") - - buildOptions, err := toBuildOptions(opts) - if err != nil { - return err - } - - buildOptions.Plugins, err = createBuildPlugins(ctx.DependencyManager, t.c, opts) - if err != nil { - return err - } - - if buildOptions.Sourcemap == api.SourceMapExternal && buildOptions.Outdir == "" { - buildOptions.Outdir, err = os.MkdirTemp(os.TempDir(), "compileOutput") - if err != nil { - return err - } - defer os.Remove(buildOptions.Outdir) - } - - if opts.Inject != nil { - // Resolve the absolute filenames. - for i, ext := range opts.Inject { - impPath := filepath.FromSlash(ext) - if filepath.IsAbs(impPath) { - return fmt.Errorf("inject: absolute paths not supported, must be relative to /assets") - } - - m := resolveComponentInAssets(t.c.rs.Assets.Fs, impPath) - - if m == nil { - return fmt.Errorf("inject: file %q not found", ext) - } - - opts.Inject[i] = m.Filename - - } - - buildOptions.Inject = opts.Inject - - } - - result := api.Build(buildOptions) - - if len(result.Errors) > 0 { - - createErr := func(msg api.Message) error { - loc := msg.Location - if loc == nil { - return errors.New(msg.Text) - } - path := loc.File - if path == stdinImporter { - path = ctx.SourcePath - } - - errorMessage := msg.Text - errorMessage = strings.ReplaceAll(errorMessage, nsImportHugo+":", "") - - var ( - f afero.File - err error - ) - - if strings.HasPrefix(path, nsImportHugo) { - path = strings.TrimPrefix(path, nsImportHugo+":") - f, err = hugofs.Os.Open(path) - } else { - var fi os.FileInfo - fi, err = t.c.sfs.Fs.Stat(path) - if err == nil { - m := fi.(hugofs.FileMetaInfo).Meta() - path = m.Filename - f, err = m.Open() - } - - } - - if err == nil { - fe := herrors. - NewFileErrorFromName(errors.New(errorMessage), path). - UpdatePosition(text.Position{Offset: -1, LineNumber: loc.Line, ColumnNumber: loc.Column}). - UpdateContent(f, nil) - - f.Close() - return fe - } - - return fmt.Errorf("%s", errorMessage) - } - - var errors []error - - for _, msg := range result.Errors { - errors = append(errors, createErr(msg)) - } - - // Return 1, log the rest. - for i, err := range errors { - if i > 0 { - t.c.rs.Logger.Errorf("js.Build failed: %s", err) - } - } - - return errors[0] - } - - if buildOptions.Sourcemap == api.SourceMapExternal { + if opts.ExternalOptions.SourceMap == "linked" || opts.ExternalOptions.SourceMap == "external" { content := string(result.OutputFiles[1].Contents) - symPath := path.Base(ctx.OutPath) + ".map" - re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`) - content = re.ReplaceAllString(content, "//# sourceMappingURL="+symPath+"\n") + if opts.ExternalOptions.SourceMap == "linked" { + symPath := path.Base(transformCtx.OutPath) + ".map" + re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`) + content = re.ReplaceAllString(content, "//# sourceMappingURL="+symPath+"\n") + } - if err = ctx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil { - return err + if err = transformCtx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil { + return result, err } - _, err := ctx.To.Write([]byte(content)) + _, err := transformCtx.To.Write([]byte(content)) if err != nil { - return err + return result, err } } else { - _, err := ctx.To.Write(result.OutputFiles[0].Contents) + _, err := transformCtx.To.Write(result.OutputFiles[0].Contents) if err != nil { - return err + return result, err } - } - return nil -} -// Process process esbuild transform -func (c *Client) Process(res resources.ResourceTransformer, opts map[string]any) (resource.Resource, error) { - return res.Transform( - &buildTransformation{c: c, optsm: opts}, - ) + } + return result, nil } diff --git a/resources/resource_transformers/js/build_test.go b/resources/resource_transformers/js/build_test.go deleted file mode 100644 index 30a4490ed..000000000 --- a/resources/resource_transformers/js/build_test.go +++ /dev/null @@ -1,14 +0,0 @@ -// 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 diff --git a/resources/resource_transformers/js/js_integration_test.go b/resources/resource_transformers/js/js_integration_test.go index 304c51d33..c62312ef5 100644 --- a/resources/resource_transformers/js/js_integration_test.go +++ b/resources/resource_transformers/js/js_integration_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -14,13 +14,16 @@ package js_test import ( + "os" "path/filepath" "strings" "testing" qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/htesting" "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/internal/js/esbuild" ) func TestBuildVariants(t *testing.T) { @@ -173,7 +176,7 @@ hello: hello: other: "Bonjour" -- layouts/index.html -- -{{ $options := dict "minify" false "externals" (slice "react" "react-dom") }} +{{ $options := dict "minify" false "externals" (slice "react" "react-dom") "sourcemap" "linked" }} {{ $js := resources.Get "js/main.js" | js.Build $options }} JS: {{ template "print" $js }} {{ $jsx := resources.Get "js/myjsx.jsx" | js.Build $options }} @@ -201,14 +204,31 @@ TS2: {{ template "print" $ts2 }} TxtarString: files, }).Build() - b.AssertFileContent("public/js/myts.js", `//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJz`) - b.AssertFileContent("public/js/myts2.js.map", `"version": 3,`) + b.AssertFileContent("public/js/main.js", `//# sourceMappingURL=main.js.map`) + b.AssertFileContent("public/js/main.js.map", `"version":3`, "! ns-hugo") // linked + b.AssertFileContent("public/js/myts.js", `//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJz`) // inline b.AssertFileContent("public/index.html", ` console.log("included"); if (hasSpace.test(string)) var React = __toESM(__require("react")); function greeter(person) { `) + + checkMap := func(p string, expectLen int) { + s := b.FileContent(p) + sources := esbuild.SourcesFromSourceMap(s) + b.Assert(sources, qt.HasLen, expectLen) + + // Check that all source files exist. + for _, src := range sources { + filename, ok := paths.UrlStringToFilename(src) + b.Assert(ok, qt.IsTrue) + _, err := os.Stat(filename) + b.Assert(err, qt.IsNil, qt.Commentf("src: %q", src)) + } + } + + checkMap("public/js/main.js.map", 4) } func TestBuildError(t *testing.T) { diff --git a/resources/resource_transformers/js/options.go b/resources/resource_transformers/js/options.go deleted file mode 100644 index 8c271d032..000000000 --- a/resources/resource_transformers/js/options.go +++ /dev/null @@ -1,461 +0,0 @@ -// 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 ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/gohugoio/hugo/common/maps" - "github.com/gohugoio/hugo/common/paths" - "github.com/gohugoio/hugo/identity" - "github.com/spf13/afero" - - "github.com/evanw/esbuild/pkg/api" - - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/media" - "github.com/mitchellh/mapstructure" -) - -const ( - nsImportHugo = "ns-hugo" - nsParams = "ns-params" - - stdinImporter = "<stdin>" -) - -// Options esbuild configuration -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 - - // Whether to write mapfiles - SourceMap string - - // The language target. - // One of: es2015, es2016, es2017, es2018, es2019, es2020 or esnext. - // Default is esnext. - Target string - - // The output format. - // One of: iife, cjs, esm - // Default is to esm. - Format string - - // External dependencies, e.g. "react". - Externals []string - - // This option allows you to automatically replace a global variable with an import from another file. - // The filenames must be relative to /assets. - // See https://esbuild.github.io/api/#inject - Inject []string - - // User defined symbols. - Defines map[string]any - - // Maps a component import to another. - Shims map[string]string - - // User defined params. Will be marshaled to JSON and available as "@params", e.g. - // import * as params from '@params'; - Params any - - // What to use instead of React.createElement. - JSXFactory string - - // What to use instead of React.Fragment. - JSXFragment string - - // What to do about JSX syntax. - // See https://esbuild.github.io/api/#jsx - JSX string - - // Which library to use to automatically import JSX helper functions from. Only works if JSX is set to automatic. - // See https://esbuild.github.io/api/#jsx-import-source - JSXImportSource string - - // There is/was a bug in WebKit with severe performance issue with the tracking - // of TDZ checks in JavaScriptCore. - // - // Enabling this flag removes the TDZ and `const` assignment checks and - // may improve performance of larger JS codebases until the WebKit fix - // is in widespread use. - // - // See https://bugs.webkit.org/show_bug.cgi?id=199866 - // Deprecated: This no longer have any effect and will be removed. - // TODO(bep) remove. See https://github.com/evanw/esbuild/commit/869e8117b499ca1dbfc5b3021938a53ffe934dba - AvoidTDZ bool - - mediaType media.Type - outDir string - contents string - sourceDir string - resolveDir string - tsConfig string -} - -func decodeOptions(m map[string]any) (Options, error) { - var opts Options - - if err := mapstructure.WeakDecode(m, &opts); err != nil { - return opts, err - } - - if opts.TargetPath != "" { - opts.TargetPath = paths.ToSlashTrimLeading(opts.TargetPath) - } - - opts.Target = strings.ToLower(opts.Target) - opts.Format = strings.ToLower(opts.Format) - - return opts, nil -} - -var extensionToLoaderMap = map[string]api.Loader{ - ".js": api.LoaderJS, - ".mjs": api.LoaderJS, - ".cjs": api.LoaderJS, - ".jsx": api.LoaderJSX, - ".ts": api.LoaderTS, - ".tsx": api.LoaderTSX, - ".css": api.LoaderCSS, - ".json": api.LoaderJSON, - ".txt": api.LoaderText, -} - -func loaderFromFilename(filename string) api.Loader { - l, found := extensionToLoaderMap[filepath.Ext(filename)] - if found { - return l - } - return api.LoaderJS -} - -func resolveComponentInAssets(fs afero.Fs, impPath string) *hugofs.FileMeta { - findFirst := func(base string) *hugofs.FileMeta { - // This is the most common sub-set of ESBuild's default extensions. - // We assume that imports of JSON, CSS etc. will be using their full - // name with extension. - for _, ext := range []string{".js", ".ts", ".tsx", ".jsx"} { - if strings.HasSuffix(impPath, ext) { - // Import of foo.js.js need the full name. - continue - } - if fi, err := fs.Stat(base + ext); err == nil { - return fi.(hugofs.FileMetaInfo).Meta() - } - } - - // Not found. - return nil - } - - var m *hugofs.FileMeta - - // We need to check if this is a regular file imported without an extension. - // There may be ambiguous situations where both foo.js and foo/index.js exists. - // This import order is in line with both how Node and ESBuild's native - // import resolver works. - - // It may be a regular file imported without an extension, e.g. - // foo or foo/index. - m = findFirst(impPath) - if m != nil { - return m - } - - base := filepath.Base(impPath) - if base == "index" { - // try index.esm.js etc. - m = findFirst(impPath + ".esm") - if m != nil { - return m - } - } - - // Check the path as is. - fi, err := fs.Stat(impPath) - - if err == nil { - if fi.IsDir() { - m = findFirst(filepath.Join(impPath, "index")) - if m == nil { - m = findFirst(filepath.Join(impPath, "index.esm")) - } - } else { - m = fi.(hugofs.FileMetaInfo).Meta() - } - } else if strings.HasSuffix(base, ".js") { - m = findFirst(strings.TrimSuffix(impPath, ".js")) - } - - return m -} - -func createBuildPlugins(depsManager identity.Manager, c *Client, opts Options) ([]api.Plugin, error) { - fs := c.rs.Assets - - resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) { - impPath := args.Path - if opts.Shims != nil { - override, found := opts.Shims[impPath] - if found { - impPath = override - } - } - isStdin := args.Importer == stdinImporter - var relDir string - if !isStdin { - rel, found := fs.MakePathRelative(args.Importer, true) - if !found { - // Not in any of the /assets folders. - // This is an import from a node_modules, let - // ESBuild resolve this. - return api.OnResolveResult{}, nil - } - - relDir = filepath.Dir(rel) - } else { - relDir = opts.sourceDir - } - - // Imports not starting with a "." is assumed to live relative to /assets. - // Hugo makes no assumptions about the directory structure below /assets. - if relDir != "" && strings.HasPrefix(impPath, ".") { - impPath = filepath.Join(relDir, impPath) - } - - m := resolveComponentInAssets(fs.Fs, impPath) - - if m != nil { - depsManager.AddIdentity(m.PathInfo) - - // Store the source root so we can create a jsconfig.json - // to help IntelliSense when the build is done. - // This should be a small number of elements, and when - // in server mode, we may get stale entries on renames etc., - // but that shouldn't matter too much. - c.rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot) - return api.OnResolveResult{Path: m.Filename, Namespace: nsImportHugo}, nil - } - - // Fall back to ESBuild's resolve. - return api.OnResolveResult{}, nil - } - - importResolver := api.Plugin{ - Name: "hugo-import-resolver", - Setup: func(build api.PluginBuild) { - build.OnResolve(api.OnResolveOptions{Filter: `.*`}, - func(args api.OnResolveArgs) (api.OnResolveResult, error) { - return resolveImport(args) - }) - build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsImportHugo}, - func(args api.OnLoadArgs) (api.OnLoadResult, error) { - b, err := os.ReadFile(args.Path) - if err != nil { - return api.OnLoadResult{}, fmt.Errorf("failed to read %q: %w", args.Path, err) - } - c := string(b) - return api.OnLoadResult{ - // See https://github.com/evanw/esbuild/issues/502 - // This allows all modules to resolve dependencies - // in the main project's node_modules. - ResolveDir: opts.resolveDir, - Contents: &c, - Loader: loaderFromFilename(args.Path), - }, nil - }) - }, - } - - params := opts.Params - if params == nil { - // This way @params will always resolve to something. - params = make(map[string]any) - } - - b, err := json.Marshal(params) - if err != nil { - return nil, fmt.Errorf("failed to marshal params: %w", err) - } - bs := string(b) - paramsPlugin := api.Plugin{ - Name: "hugo-params-plugin", - Setup: func(build api.PluginBuild) { - build.OnResolve(api.OnResolveOptions{Filter: `^@params$`}, - func(args api.OnResolveArgs) (api.OnResolveResult, error) { - return api.OnResolveResult{ - Path: args.Path, - Namespace: nsParams, - }, nil - }) - build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsParams}, - func(args api.OnLoadArgs) (api.OnLoadResult, error) { - return api.OnLoadResult{ - Contents: &bs, - Loader: api.LoaderJSON, - }, nil - }) - }, - } - - return []api.Plugin{importResolver, paramsPlugin}, nil -} - -func toBuildOptions(opts Options) (buildOptions api.BuildOptions, err error) { - var target api.Target - switch opts.Target { - case "", "esnext": - target = api.ESNext - case "es5": - target = api.ES5 - case "es6", "es2015": - target = api.ES2015 - case "es2016": - target = api.ES2016 - case "es2017": - target = api.ES2017 - case "es2018": - target = api.ES2018 - case "es2019": - target = api.ES2019 - case "es2020": - target = api.ES2020 - case "es2021": - target = api.ES2021 - case "es2022": - target = api.ES2022 - case "es2023": - target = api.ES2023 - default: - err = fmt.Errorf("invalid target: %q", opts.Target) - return - } - - mediaType := opts.mediaType - if mediaType.IsZero() { - mediaType = media.Builtin.JavascriptType - } - - var loader api.Loader - switch mediaType.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.Builtin.JavascriptType.SubType: - loader = api.LoaderJS - case media.Builtin.TypeScriptType.SubType: - loader = api.LoaderTS - case media.Builtin.TSXType.SubType: - loader = api.LoaderTSX - case media.Builtin.JSXType.SubType: - loader = api.LoaderJSX - default: - err = fmt.Errorf("unsupported Media Type: %q", opts.mediaType) - return - } - - var format api.Format - // One of: iife, cjs, esm - switch opts.Format { - case "", "iife": - format = api.FormatIIFE - case "esm": - format = api.FormatESModule - case "cjs": - format = api.FormatCommonJS - default: - err = fmt.Errorf("unsupported script output format: %q", opts.Format) - return - } - - var jsx api.JSX - switch opts.JSX { - case "", "transform": - jsx = api.JSXTransform - case "preserve": - jsx = api.JSXPreserve - case "automatic": - jsx = api.JSXAutomatic - default: - err = fmt.Errorf("unsupported jsx type: %q", opts.JSX) - return - } - - var defines map[string]string - if opts.Defines != nil { - defines = maps.ToStringMapString(opts.Defines) - } - - // By default we only need to specify outDir and no outFile - outDir := opts.outDir - outFile := "" - var sourceMap api.SourceMap - switch opts.SourceMap { - case "inline": - sourceMap = api.SourceMapInline - case "external": - sourceMap = api.SourceMapExternal - case "": - sourceMap = api.SourceMapNone - default: - err = fmt.Errorf("unsupported sourcemap type: %q", opts.SourceMap) - return - } - - buildOptions = api.BuildOptions{ - Outfile: outFile, - Bundle: true, - - Target: target, - Format: format, - Sourcemap: sourceMap, - - MinifyWhitespace: opts.Minify, - MinifyIdentifiers: opts.Minify, - MinifySyntax: opts.Minify, - - Outdir: outDir, - Define: defines, - - External: opts.Externals, - - JSXFactory: opts.JSXFactory, - JSXFragment: opts.JSXFragment, - - JSX: jsx, - JSXImportSource: opts.JSXImportSource, - - Tsconfig: opts.tsConfig, - - // Note: We're not passing Sourcefile to ESBuild. - // This makes ESBuild pass `stdin` as the Importer to the import - // resolver, which is what we need/expect. - Stdin: &api.StdinOptions{ - Contents: opts.contents, - ResolveDir: opts.resolveDir, - Loader: loader, - }, - } - return -} diff --git a/resources/resource_transformers/js/options_test.go b/resources/resource_transformers/js/options_test.go deleted file mode 100644 index 53aa9b6bb..000000000 --- a/resources/resource_transformers/js/options_test.go +++ /dev/null @@ -1,241 +0,0 @@ -// 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 ( - "path" - "path/filepath" - "testing" - - "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/config/testconfig" - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/hugolib/filesystems" - "github.com/gohugoio/hugo/hugolib/paths" - "github.com/gohugoio/hugo/media" - - "github.com/spf13/afero" - - "github.com/evanw/esbuild/pkg/api" - - 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 := map[string]any{ - "TargetPath": "foo", - "Target": "es2018", - } - - key := (&buildTransformation{optsm: opts}).Key() - - c.Assert(key.Value(), qt.Equals, "jsbuild_1533819657654811600") -} - -func TestToBuildOptions(t *testing.T) { - c := qt.New(t) - - opts, err := toBuildOptions(Options{mediaType: media.Builtin.JavascriptType}) - - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ESNext, - Format: api.FormatIIFE, - Stdin: &api.StdinOptions{ - Loader: api.LoaderJS, - }, - }) - - opts, err = toBuildOptions(Options{ - Target: "es2018", - Format: "cjs", - Minify: true, - mediaType: media.Builtin.JavascriptType, - AvoidTDZ: true, - }) - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ES2018, - Format: api.FormatCommonJS, - MinifyIdentifiers: true, - MinifySyntax: true, - MinifyWhitespace: true, - Stdin: &api.StdinOptions{ - Loader: api.LoaderJS, - }, - }) - - opts, err = toBuildOptions(Options{ - Target: "es2018", Format: "cjs", Minify: true, mediaType: media.Builtin.JavascriptType, - SourceMap: "inline", - }) - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ES2018, - Format: api.FormatCommonJS, - MinifyIdentifiers: true, - MinifySyntax: true, - MinifyWhitespace: true, - Sourcemap: api.SourceMapInline, - Stdin: &api.StdinOptions{ - Loader: api.LoaderJS, - }, - }) - - opts, err = toBuildOptions(Options{ - Target: "es2018", Format: "cjs", Minify: true, mediaType: media.Builtin.JavascriptType, - SourceMap: "inline", - }) - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ES2018, - Format: api.FormatCommonJS, - MinifyIdentifiers: true, - MinifySyntax: true, - MinifyWhitespace: true, - Sourcemap: api.SourceMapInline, - Stdin: &api.StdinOptions{ - Loader: api.LoaderJS, - }, - }) - - opts, err = toBuildOptions(Options{ - Target: "es2018", Format: "cjs", Minify: true, mediaType: media.Builtin.JavascriptType, - SourceMap: "external", - }) - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ES2018, - Format: api.FormatCommonJS, - MinifyIdentifiers: true, - MinifySyntax: true, - MinifyWhitespace: true, - Sourcemap: api.SourceMapExternal, - Stdin: &api.StdinOptions{ - Loader: api.LoaderJS, - }, - }) - - opts, err = toBuildOptions(Options{ - mediaType: media.Builtin.JavascriptType, - JSX: "automatic", JSXImportSource: "preact", - }) - c.Assert(err, qt.IsNil) - c.Assert(opts, qt.DeepEquals, api.BuildOptions{ - Bundle: true, - Target: api.ESNext, - Format: api.FormatIIFE, - Stdin: &api.StdinOptions{ - Loader: api.LoaderJS, - }, - JSX: api.JSXAutomatic, - JSXImportSource: "preact", - }) -} - -func TestToBuildOptionsTarget(t *testing.T) { - c := qt.New(t) - - for _, test := range []struct { - target string - expect api.Target - }{ - {"es2015", api.ES2015}, - {"es2016", api.ES2016}, - {"es2017", api.ES2017}, - {"es2018", api.ES2018}, - {"es2019", api.ES2019}, - {"es2020", api.ES2020}, - {"es2021", api.ES2021}, - {"es2022", api.ES2022}, - {"es2023", api.ES2023}, - {"", api.ESNext}, - {"esnext", api.ESNext}, - } { - c.Run(test.target, func(c *qt.C) { - opts, err := toBuildOptions(Options{ - Target: test.target, - mediaType: media.Builtin.JavascriptType, - }) - c.Assert(err, qt.IsNil) - c.Assert(opts.Target, qt.Equals, test.expect) - }) - } -} - -func TestResolveComponentInAssets(t *testing.T) { - c := qt.New(t) - - for _, test := range []struct { - name string - files []string - impPath string - expect string - }{ - {"Basic, extension", []string{"foo.js", "bar.js"}, "foo.js", "foo.js"}, - {"Basic, no extension", []string{"foo.js", "bar.js"}, "foo", "foo.js"}, - {"Basic, no extension, typescript", []string{"foo.ts", "bar.js"}, "foo", "foo.ts"}, - {"Not found", []string{"foo.js", "bar.js"}, "moo.js", ""}, - {"Not found, double js extension", []string{"foo.js.js", "bar.js"}, "foo.js", ""}, - {"Index file, folder only", []string{"foo/index.js", "bar.js"}, "foo", "foo/index.js"}, - {"Index file, folder and index", []string{"foo/index.js", "bar.js"}, "foo/index", "foo/index.js"}, - {"Index file, folder and index and suffix", []string{"foo/index.js", "bar.js"}, "foo/index.js", "foo/index.js"}, - {"Index ESM file, folder only", []string{"foo/index.esm.js", "bar.js"}, "foo", "foo/index.esm.js"}, - {"Index ESM file, folder and index", []string{"foo/index.esm.js", "bar.js"}, "foo/index", "foo/index.esm.js"}, - {"Index ESM file, folder and index and suffix", []string{"foo/index.esm.js", "bar.js"}, "foo/index.esm.js", "foo/index.esm.js"}, - // We added these index.esm.js cases in v0.101.0. The case below is unlikely to happen in the wild, but add a test - // to document Hugo's behavior. We pick the file with the name index.js; anything else would be breaking. - {"Index and Index ESM file, folder only", []string{"foo/index.esm.js", "foo/index.js", "bar.js"}, "foo", "foo/index.js"}, - - // Issue #8949 - {"Check file before directory", []string{"foo.js", "foo/index.js"}, "foo", "foo.js"}, - } { - c.Run(test.name, func(c *qt.C) { - baseDir := "assets" - mfs := afero.NewMemMapFs() - - for _, filename := range test.files { - c.Assert(afero.WriteFile(mfs, filepath.Join(baseDir, filename), []byte("let foo='bar';"), 0o777), qt.IsNil) - } - - conf := testconfig.GetTestConfig(mfs, config.New()) - fs := hugofs.NewFrom(mfs, conf.BaseConfig()) - - p, err := paths.New(fs, conf) - c.Assert(err, qt.IsNil) - bfs, err := filesystems.NewBase(p, nil) - c.Assert(err, qt.IsNil) - - got := resolveComponentInAssets(bfs.Assets.Fs, test.impPath) - - gotPath := "" - expect := test.expect - if got != nil { - gotPath = filepath.ToSlash(got.Filename) - expect = path.Join(baseDir, test.expect) - } - - c.Assert(gotPath, qt.Equals, expect) - }) - } -} diff --git a/resources/resource_transformers/js/transform.go b/resources/resource_transformers/js/transform.go new file mode 100644 index 000000000..13909e54c --- /dev/null +++ b/resources/resource_transformers/js/transform.go @@ -0,0 +1,68 @@ +// Copyright 2024 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 ( + "io" + "path" + "path/filepath" + + "github.com/gohugoio/hugo/internal/js/esbuild" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/internal" +) + +type buildTransformation struct { + optsm map[string]any + c *Client +} + +func (t *buildTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("jsbuild", t.optsm) +} + +func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { + ctx.OutMediaType = media.Builtin.JavascriptType + + var opts esbuild.Options + + if t.optsm != nil { + optsExt, err := esbuild.DecodeExternalOptions(t.optsm) + if err != nil { + return err + } + opts.ExternalOptions = optsExt + } + + if opts.TargetPath != "" { + ctx.OutPath = opts.TargetPath + } else { + ctx.ReplaceOutPathExtension(".js") + } + + src, err := io.ReadAll(ctx.From) + if err != nil { + return err + } + + opts.SourceDir = filepath.FromSlash(path.Dir(ctx.SourcePath)) + opts.Contents = string(src) + opts.MediaType = ctx.InMediaType + opts.Stdin = true + + _, err = t.c.transform(opts, ctx) + + return err +} diff --git a/resources/resource_transformers/tocss/dartsass/transform.go b/resources/resource_transformers/tocss/dartsass/transform.go index 0d7b57062..77bacc115 100644 --- a/resources/resource_transformers/tocss/dartsass/transform.go +++ b/resources/resource_transformers/tocss/dartsass/transform.go @@ -139,7 +139,7 @@ func (t importResolver) CanonicalizeURL(url string) (string, error) { return url, nil } - filePath, isURL := paths.UrlToFilename(url) + filePath, isURL := paths.UrlStringToFilename(url) var prevDir string var pathDir string if isURL { @@ -195,7 +195,7 @@ func (t importResolver) Load(url string) (godartsass.Import, error) { if url == sass.HugoVarsNamespace { return t.varsStylesheet, nil } - filename, _ := paths.UrlToFilename(url) + filename, _ := paths.UrlStringToFilename(url) b, err := afero.ReadFile(hugofs.Os, filename) sourceSyntax := godartsass.SourceSyntaxSCSS diff --git a/resources/transform.go b/resources/transform.go index 336495e6d..4214067bd 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -52,6 +52,8 @@ var ( _ identity.IdentityGroupProvider = (*resourceAdapterInner)(nil) _ resource.Source = (*resourceAdapter)(nil) _ resource.Identifier = (*resourceAdapter)(nil) + _ targetPathProvider = (*resourceAdapter)(nil) + _ sourcePathProvider = (*resourceAdapter)(nil) _ resource.ResourceNameTitleProvider = (*resourceAdapter)(nil) _ resource.WithResourceMetaProvider = (*resourceAdapter)(nil) _ identity.DependencyManagerProvider = (*resourceAdapter)(nil) @@ -277,6 +279,19 @@ func (r *resourceAdapter) Key() string { return r.target.(resource.Identifier).Key() } +func (r *resourceAdapter) targetPath() string { + r.init(false, false) + return r.target.(targetPathProvider).targetPath() +} + +func (r *resourceAdapter) sourcePath() string { + r.init(false, false) + if sp, ok := r.target.(sourcePathProvider); ok { + return sp.sourcePath() + } + return "" +} + func (r *resourceAdapter) MediaType() media.Type { r.init(false, false) return r.target.MediaType() |