diff --git a/media/builtin.go b/media/builtin.go index 80c4df6fd..6595acbe4 100644 --- a/media/builtin.go +++ b/media/builtin.go @@ -21,6 +21,7 @@ type BuiltinTypes struct { TextType Type TOMLType Type YAMLType Type + SourceMapType Type // Common image types PNGType Type @@ -36,6 +37,8 @@ type BuiltinTypes struct { // Common font types TrueTypeFontType Type OpenTypeFontType Type + WOFFFontType Type + WOFF2FontType Type // Common document types PDFType Type @@ -80,6 +83,7 @@ var Builtin = BuiltinTypes{ TextType: Type{Type: "text/plain"}, TOMLType: Type{Type: "application/toml"}, YAMLType: Type{Type: "application/yaml"}, + SourceMapType: Type{Type: "application/source-map"}, // Common image types PNGType: Type{Type: "image/png"}, @@ -95,6 +99,8 @@ var Builtin = BuiltinTypes{ // Common font types TrueTypeFontType: Type{Type: "font/ttf"}, OpenTypeFontType: Type{Type: "font/otf"}, + WOFFFontType: Type{Type: "font/woff"}, + WOFF2FontType: Type{Type: "font/woff2"}, // Common document types PDFType: Type{Type: "application/pdf"}, @@ -139,6 +145,7 @@ var defaultMediaTypesConfig = map[string]any{ "text/plain": map[string]any{"suffixes": []string{"txt"}}, "application/toml": map[string]any{"suffixes": []string{"toml"}}, "application/yaml": map[string]any{"suffixes": []string{"yaml", "yml"}}, + "application/source-map": map[string]any{"suffixes": []string{"map"}}, // Common image types "image/png": map[string]any{"suffixes": []string{"png"}}, @@ -152,8 +159,10 @@ var defaultMediaTypesConfig = map[string]any{ "image/heic": map[string]any{"suffixes": []string{"heic"}}, // Common font types - "font/ttf": map[string]any{"suffixes": []string{"ttf"}}, - "font/otf": map[string]any{"suffixes": []string{"otf"}}, + "font/ttf": map[string]any{"suffixes": []string{"ttf"}}, + "font/otf": map[string]any{"suffixes": []string{"otf"}}, + "font/woff": map[string]any{"suffixes": []string{"woff"}}, + "font/woff2": map[string]any{"suffixes": []string{"woff2"}}, // Common document types "application/pdf": map[string]any{"suffixes": []string{"pdf"}}, diff --git a/media/config_test.go b/media/config_test.go index 6e626ba64..b7190973a 100644 --- a/media/config_test.go +++ b/media/config_test.go @@ -142,6 +142,9 @@ func TestDefaultTypes(t *testing.T) { {Builtin.PDFType, "application", "pdf", "pdf", "application/pdf", "application/pdf"}, {Builtin.TrueTypeFontType, "font", "ttf", "ttf", "font/ttf", "font/ttf"}, {Builtin.OpenTypeFontType, "font", "otf", "otf", "font/otf", "font/otf"}, + {Builtin.WOFFFontType, "font", "woff", "woff", "font/woff", "font/woff"}, + {Builtin.WOFF2FontType, "font", "woff2", "woff2", "font/woff2", "font/woff2"}, + {Builtin.SourceMapType, "application", "source-map", "map", "application/source-map", "application/source-map"}, } { c.Assert(test.tp.MainType, qt.Equals, test.expectedMainType) c.Assert(test.tp.SubType, qt.Equals, test.expectedSubType) @@ -151,5 +154,5 @@ func TestDefaultTypes(t *testing.T) { } - c.Assert(len(DefaultTypes), qt.Equals, 44) + c.Assert(len(DefaultTypes), qt.Equals, 47) } diff --git a/resources/artifact.go b/resources/artifact.go new file mode 100644 index 000000000..e26163a78 --- /dev/null +++ b/resources/artifact.go @@ -0,0 +1,50 @@ +// Copyright 2025 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 resources + +import ( + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/resources/resource" +) + +// Artifact represents an additional output file published as part of a +// resource transformation, e.g. a source map or a file emitted by ESBuild's +// file loader. It is exposed to templates via Resource.Data.Artifacts. +type Artifact interface { + resource.MediaTypeProvider + resource.ResourceLinksProvider +} + +// NewArtifact creates a new Artifact with the given permalinks and media type. +func NewArtifact(permalink, relPermalink string, mediaType media.Type) Artifact { + return &artifact{permalink: permalink, relPermalink: relPermalink, mediaType: mediaType} +} + +type artifact struct { + permalink string + relPermalink string + mediaType media.Type +} + +func (a *artifact) MediaType() media.Type { + return a.mediaType +} + +func (a *artifact) Permalink() string { + return a.permalink +} + +func (a *artifact) RelPermalink() string { + return a.relPermalink +} diff --git a/resources/resource_transformers/js/build.go b/resources/resource_transformers/js/build.go index 0415b058a..da7b4092f 100644 --- a/resources/resource_transformers/js/build.go +++ b/resources/resource_transformers/js/build.go @@ -18,12 +18,15 @@ import ( "path" "path/filepath" "regexp" + "sort" "strings" "github.com/evanw/esbuild/pkg/api" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/hugolib/filesystems" "github.com/gohugoio/hugo/internal/js/esbuild" + "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" @@ -87,9 +90,27 @@ func (c *Client) transform(opts esbuild.Options, transformCtx *resources.Resourc hasLinkedSourceMap := opts.ExternalOptions.SourceMap == "linked" hasSourceMap := hasLinkedSourceMap || opts.ExternalOptions.SourceMap == "external" + spec := c.c.Spec() + mediaTypes := spec.MediaTypes() + baseURL := spec.Cfg.BaseURL().WithoutPath + newArtifact := func(relPermalink, fileName string) resources.Artifact { + mt, _, found := mediaTypes.GetFirstBySuffix(strings.TrimPrefix(path.Ext(fileName), ".")) + if !found { + mt = media.Builtin.OctetType + } + return resources.NewArtifact(baseURL+relPermalink, relPermalink, mt) + } + fileLoaderRelPermalink := func(base string) string { + if opts.PublicPath != "" { + return strings.TrimSuffix(opts.PublicPath, "/") + "/" + paths.PathEscape(base) + } + return pathSpec.GetBasePath(false) + paths.PathEscape(paths.AddLeadingSlash(path.Join(outDir, base))) + } + // Classify output files by path rather than relying on array ordering, // which esbuild does not guarantee. var mainOutput []byte + var artifacts []resources.Artifact for _, file := range result.OutputFiles { basePath := path.Base(filepath.ToSlash(file.Path)) if strings.HasSuffix(basePath, ".map") { @@ -97,6 +118,7 @@ func (c *Client) transform(opts esbuild.Options, transformCtx *resources.Resourc if err = transformCtx.PublishSourceMap(file.Contents); err != nil { return result, err } + artifacts = append(artifacts, newArtifact(pathSpec.GetBasePath(false)+paths.PathEscape(paths.AddLeadingSlash(transformCtx.OutPath+".map")), basePath)) } } else if isStdinEntryOutput(basePath) { mainOutput = file.Contents @@ -105,9 +127,13 @@ func (c *Client) transform(opts esbuild.Options, transformCtx *resources.Resourc if err = publishFileLoaderArtifact(pathSpec, opts.PublicPath, outDir, basePath, file.Contents, transformCtx); err != nil { return result, err } + artifacts = append(artifacts, newArtifact(fileLoaderRelPermalink(basePath), basePath)) } } + sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].RelPermalink() < artifacts[j].RelPermalink() }) + transformCtx.Data["Artifacts"] = artifacts + if mainOutput == nil { return result, fmt.Errorf("esbuild: entry point output not found") } diff --git a/resources/resource_transformers/js/js_integration_test.go b/resources/resource_transformers/js/js_integration_test.go index a11a6fbe9..2244e8f8e 100644 --- a/resources/resource_transformers/js/js_integration_test.go +++ b/resources/resource_transformers/js/js_integration_test.go @@ -78,6 +78,42 @@ JS Content:{{ $js.Content }}:End: }) } +// Issue #15173. +func TestBuildDataArtifacts(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.org/" +disableKinds=["page", "section", "taxonomy", "term", "sitemap", "robotsTXT"] +-- assets/js/main.js -- +import { hello } from './util'; +hello(); +-- assets/js/util.js -- +export function hello() { + return 'abcd'; +} +-- layouts/home.html -- +{{ with resources.Get "js/main.js" | js.Build (dict "minify" true "sourcemap" "external") }} +COUNT: {{ len .Data.Artifacts }} +{{ range .Data.Artifacts }} +ARTIFACT: {{ .RelPermalink }}|{{ .Permalink }}|{{ .MediaType.Type }} +{{ end }} +{{ end }} +{{ with resources.Get "js/main.js" | js.Build }} +COUNT2: {{ len .Data.Artifacts }} +{{ end }} +` + + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + b.AssertFileContent("public/index.html", + "COUNT: 1", + "ARTIFACT: /js/main.js.map|https://example.org/js/main.js.map|application/source-map", + "COUNT2: 0", + ) + b.AssertFileExists("public/js/main.js.map", true) +} + func TestBuildWithModAndNpm(t *testing.T) { if !htesting.IsCI() { t.Skip("skip (relative) long running modules test when running locally") diff --git a/resources/transform.go b/resources/transform.go index 5dc281f71..18c768b36 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -129,11 +129,12 @@ type ResourceTransformationCtx struct { // The media type of the transformed resource. OutMediaType media.Type - // Data data can be set on the transformed Resource. Not that this need - // to be simple types, as it needs to be serialized to JSON and back. + // Data can be set on the transformed Resource. For transformations + // cached to disk (see transformationsToCacheOnDisk), this needs to be + // simple types, as it will be serialized to JSON and back. Data map[string]any - // This is used to publish additional artifacts, e.g. source hhmaps. + // This is used to publish additional artifacts, e.g. source maps. // We may improve this. OpenResourcePublisher func(relTargetPath string) (io.WriteCloser, error) } diff --git a/tpl/css/build_integration_test.go b/tpl/css/build_integration_test.go index 1818bcf11..6ddb23331 100644 --- a/tpl/css/build_integration_test.go +++ b/tpl/css/build_integration_test.go @@ -458,6 +458,8 @@ fakefontdata {{ with . | css.Build (dict "minify" true) }} INLINE: LINKED: +{{ range .Data.Artifacts }}ARTIFACT: {{ .RelPermalink }}|{{ .MediaType.Type }} +{{ end }} {{ end }} {{ end }} ` @@ -471,6 +473,9 @@ LINKED: b.AssertFileContent("public/fr/css/main.css", `url("/docs/css/ComicNeue-Regular-`) b.AssertPublishDir("en/css/ComicNeue-Regular-UA4ODE7N.ttf") b.AssertPublishDir("fr/docs/css/ComicNeue-Regular-UA4ODE7N.ttf") + // The artifact URL is the same on every host. See issue #15173. + b.AssertFileContent("public/en/index.html", "ARTIFACT: /docs/css/ComicNeue-Regular-UA4ODE7N.ttf|font/ttf") + b.AssertFileContent("public/fr/index.html", "ARTIFACT: /docs/css/ComicNeue-Regular-UA4ODE7N.ttf|font/ttf") } t.Run("en first", func(t *testing.T) { @@ -486,6 +491,52 @@ LINKED: }) } +// Issue #15173. +func TestCSSBuildDataArtifacts(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.org/mysite/" +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +-- assets/css/main.css -- +@import "/fonts/fonts.css"; +body { color: #222; } +-- assets/fonts/fonts.css -- +@font-face { + font-family: 'Comic Neue'; + src: url(ComicNeue-Regular.woff2) format('woff2'), url(ComicNeue-Regular.ttf) format('truetype'); +} +-- assets/fonts/ComicNeue-Regular.ttf -- +fakefontdata +-- assets/fonts/ComicNeue-Regular.woff2 -- +fakefontdata2 +-- layouts/home.html -- +{{ with resources.Get "css/main.css" }} +{{ with . | css.Build (dict "minify" true "sourcemap" "external") }} +{{ range .Data.Artifacts }} +ARTIFACT: {{ .RelPermalink }}|{{ .Permalink }}|{{ .MediaType.Type }} +{{ end }} +{{ range .Data.Artifacts }}{{ if eq .MediaType.MainType "font" }} + +{{ end }}{{ end }} + +{{ end }} +{{ end }} +` + + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + b.AssertFileContent("public/index.html", + "ARTIFACT: /mysite/css/main.css.map|https://example.org/mysite/css/main.css.map|application/source-map", + ".ttf|https://example.org/mysite/css/ComicNeue-Regular-", + ".woff2|https://example.org/mysite/css/ComicNeue-Regular-", + "|font/ttf", + "|font/woff2", + `as="font" type="font/woff2" crossorigin`, + ) + b.AssertFileExists("public/css/main.css.map", true) +} + // Issue #14623 func TestCSSBuildLoadersPartial(t *testing.T) { t.Parallel()