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()