css: Make css.Build's file-loader URLs absolute to web context root

When CSS imports assets via the file loader (fonts, images), the emitted
URLs were relative to the CSS output directory. That broke when the CSS
was inlined into HTML, since browsers then resolved the URLs against the
page rather than the CSS file.

Set esbuild's PublicPath to the CSS output directory joined with the
site base path so URLs work whether the CSS is published as a file or
inlined.

Fixes #14849
This commit is contained in:
Bjørn Erik Pedersen
2026-05-08 16:48:28 +02:00
parent 7011239205
commit e51e761d9c
8 changed files with 226 additions and 22 deletions
+2 -1
View File
@@ -4,4 +4,5 @@ imports.*
dist/
public/
.DS_Store
cache/filecache/_gen/
cache/filecache/_gen/
.claude/
+8 -8
View File
@@ -45,17 +45,17 @@ body { color: blue }
-- layouts/home.html --
{{ with resources.Get "css/main1.css" }}
{{ with . | css.PostCSS }}
CSS1 size: {{ .Content | len }}|{{ .RelPermalink }}|
CSS1: {{ .RelPermalink }}|{{ gt (.Content | len) 10 }}|
{{ end }}
{{ end }}
{{ with resources.Get "css/main2.css" }}
{{ with resources.Get "css/main2.css" }}
{{ with . | css.TailwindCSS }}
CSS2 size: {{ .Content | len }}|{{ .RelPermalink }}|
CSS2: {{ .RelPermalink }}|{{ gt (.Content | len) 10 }}|
{{ end }}
{{ end }}
{{ with resources.Get "js/main.js" }}
{{ with resources.Get "js/main.js" }}
{{ with . | js.Babel }}
JS size: {{ .Content | len }}|{{ .RelPermalink }}|
JS: {{ .RelPermalink }}|{{ gt (.Content | len) 10 }}|
{{ end }}
{{ end }}
`
@@ -67,8 +67,8 @@ body { color: blue }
))
b.AssertFileContent("public/index.html",
"CSS1 size: 233|/css/main1.css|",
"CSS2 size: 4557|/css/main2.css|",
"JS size: 31|/js/main.js|",
"CSS1: /css/main1.css|true|",
"CSS2: /css/main2.css|true|",
"JS: /js/main.js|true|",
)
}
+9
View File
@@ -67,6 +67,15 @@ func (c ConfigLanguage) BaseURLLiveReload() urls.BaseURL {
return c.config.C.BaseURLLiveReload
}
// AllBaseURLs returns the BaseURL for each enabled language, ordered as Languages().
func (c ConfigLanguage) AllBaseURLs() []urls.BaseURL {
bs := make([]urls.BaseURL, len(c.m.configLangs))
for i, p := range c.m.configLangs {
bs[i] = p.BaseURL()
}
return bs
}
func (c ConfigLanguage) Environment() string {
return c.config.Environment
}
+31 -2
View File
@@ -18,6 +18,7 @@ import (
"strings"
hpaths "github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/common/urls"
"github.com/gohugoio/hugo/langs"
"github.com/gohugoio/hugo/config"
@@ -40,6 +41,22 @@ type Paths struct {
// When in multihost mode, this returns a list of base paths below PublishDir
// for each language.
MultihostTargetBasePaths []string
// When in multihost mode, this is the URL base path (the path part of baseURL,
// without trailing slash) for each language, parallel to MultihostTargetBasePaths.
MultihostBasePaths []string
// When in multihost mode, this is the deepest of the per-host base paths
// in MultihostBasePaths. css.Build uses this so file-loader URLs are
// reachable on every host (each host's base path is a prefix).
MultihostLongestBasePath string
}
// allBaseURLsProvider is satisfied by allconfig.ConfigLanguage and lets us
// access per-host BaseURLs without importing allconfig (which would create an
// import cycle via helpers).
type allBaseURLsProvider interface {
AllBaseURLs() []urls.BaseURL
}
func New(fs *hugofs.Fs, cfg config.AllProvider) (*Paths, error) {
@@ -65,10 +82,20 @@ func New(fs *hugofs.Fs, cfg config.AllProvider) (*Paths, error) {
absResourcesDir = FilePathSeparator
}
var multihostTargetBasePaths []string
var (
multihostTargetBasePaths []string
multihostBasePaths []string
multihostLongestBasePath string
)
if cfg.IsMultihost() && len(cfg.Languages().(langs.Languages)) > 1 {
for _, l := range cfg.Languages().(langs.Languages) {
baseURLs := cfg.(allBaseURLsProvider).AllBaseURLs()
for i, l := range cfg.Languages().(langs.Languages) {
multihostTargetBasePaths = append(multihostTargetBasePaths, hpaths.ToSlashPreserveLeading(l.Lang))
bp := baseURLs[i].BasePathNoTrailingSlash
multihostBasePaths = append(multihostBasePaths, bp)
if len(bp) > len(multihostLongestBasePath) {
multihostLongestBasePath = bp
}
}
}
@@ -78,6 +105,8 @@ func New(fs *hugofs.Fs, cfg config.AllProvider) (*Paths, error) {
AbsResourcesDir: absResourcesDir,
AbsPublishDir: absPublishDir,
MultihostTargetBasePaths: multihostTargetBasePaths,
MultihostBasePaths: multihostBasePaths,
MultihostLongestBasePath: multihostLongestBasePath,
}
return p, nil
+5
View File
@@ -47,6 +47,11 @@ type BuildClient struct {
CssMode bool
}
// Spec returns the resources.Spec for this client.
func (c *BuildClient) Spec() *resources.Spec {
return c.rs
}
// Build builds the given JavaScript resources using esbuild with the given options.
func (c *BuildClient) Build(opts Options) (api.BuildResult, error) {
dependencyManager := opts.DependencyManager
+7 -2
View File
@@ -234,6 +234,10 @@ type InternalOptions struct {
AbsWorkingDir string
Metafile bool
// Used as a prefix for asset references that go through the file loader.
// See https://esbuild.github.io/api/#public-path
PublicPath string
StdinSourcePath string
DependencyManager identity.Manager
@@ -443,8 +447,9 @@ OUTER:
MinifyIdentifiers: opts.Minify,
MinifySyntax: opts.Minify,
Outdir: outDir,
Splitting: opts.Splitting,
Outdir: outDir,
PublicPath: opts.PublicPath,
Splitting: opts.Splitting,
Define: defines,
External: opts.Externals,
+50 -3
View File
@@ -21,6 +21,7 @@ import (
"strings"
"github.com/evanw/esbuild/pkg/api"
"github.com/gohugoio/hugo/helpers"
"github.com/gohugoio/hugo/hugolib/filesystems"
"github.com/gohugoio/hugo/internal/js/esbuild"
@@ -54,6 +55,28 @@ func (c *Client) transform(opts esbuild.Options, transformCtx *resources.Resourc
opts.StdinSourcePath = transformCtx.SourcePath
pathSpec := c.c.Spec().PathSpec
outDir := path.Dir(transformCtx.OutPath)
if opts.IsCSS && opts.PublicPath == "" {
// Make file-loader artifact URLs (e.g. fonts, images) absolute relative
// to the web context root, so they resolve correctly whether the CSS is
// published as a file or inlined into HTML. See issue #14849.
// In multihost we build once for all hosts; using the deepest of the
// per-host base paths keeps the URL reachable on every host (each
// host's base path is a prefix).
var basePath string
if pathSpec.MultihostLongestBasePath != "" {
basePath = pathSpec.MultihostLongestBasePath
} else {
basePath = pathSpec.GetBasePath(false)
}
dir := outDir
if dir == "." {
dir = ""
}
opts.PublicPath = "/" + strings.TrimPrefix(path.Join(basePath, dir), "/")
}
result, err := c.c.Build(opts)
if err != nil {
return result, err
@@ -65,7 +88,6 @@ func (c *Client) transform(opts esbuild.Options, transformCtx *resources.Resourc
// Classify output files by path rather than relying on array ordering,
// which esbuild does not guarantee.
var mainOutput []byte
outDir := path.Dir(transformCtx.OutPath)
for _, file := range result.OutputFiles {
basePath := path.Base(filepath.ToSlash(file.Path))
if strings.HasSuffix(basePath, ".map") {
@@ -78,8 +100,7 @@ func (c *Client) transform(opts esbuild.Options, transformCtx *resources.Resourc
mainOutput = file.Contents
} else {
// File-loader artifact; publish directly.
target := path.Join(outDir, basePath)
if err = transformCtx.PublishTo(target, file.Contents); err != nil {
if err = publishFileLoaderArtifact(pathSpec, opts.PublicPath, outDir, basePath, file.Contents, transformCtx); err != nil {
return result, err
}
}
@@ -112,3 +133,29 @@ func (c *Client) transform(opts esbuild.Options, transformCtx *resources.Resourc
func isStdinEntryOutput(basePath string) bool {
return strings.HasPrefix(basePath, "stdin.")
}
// publishFileLoaderArtifact writes a file-loader artifact (e.g. a font or image
// referenced from CSS) to the publish directory. In multihost mode the URL is
// the same on all hosts, but each host has its own base path; the file is
// placed where each host's URL resolution will find it.
func publishFileLoaderArtifact(pathSpec *helpers.PathSpec, publicPath, outDir, basePath string, content []byte, transformCtx *resources.ResourceTransformationCtx) error {
if len(pathSpec.MultihostTargetBasePaths) == 0 {
return transformCtx.PublishTo(path.Join(outDir, basePath), content)
}
urlPath := path.Join(publicPath, basePath)
if !strings.HasPrefix(urlPath, "/") {
urlPath = "/" + urlPath
}
filenames := make([]string, len(pathSpec.MultihostTargetBasePaths))
for i, langPrefix := range pathSpec.MultihostTargetBasePaths {
filenames[i] = filepath.FromSlash(langPrefix + strings.TrimPrefix(urlPath, pathSpec.MultihostBasePaths[i]))
}
fw, err := helpers.OpenFilesForWriting(pathSpec.BaseFs.PublishFs, filenames...)
if err != nil {
return err
}
defer fw.Close()
_, err = fw.Write(content)
return err
}
+114 -6
View File
@@ -116,12 +116,12 @@ All. No CSS here.
`
b := hugolib.TestRunning(t, files, hugolib.TestOptOsFs())
b.AssertFileContent("public/css/main.css", `.foo{background:green}@layer mylayer{.bar{background-image:url("./bar-Y35ORVQM.svg")}}`)
b.AssertFileContent("public/css/main.css", `.foo{background:green}@layer mylayer{.bar{background-image:url("/css/bar-Y35ORVQM.svg")}}`)
// Edit svg
b.EditFileReplaceAll("assets/images/bar.svg", "barsvg", "newbarsvg").Build()
b.AssertRenderCountPage(1)
b.AssertFileContent("public/css/main.css", `bar-LVHHRPN5.svg`) // new hash.
b.AssertFileContent("public/css/main.css", `/css/bar-LVHHRPN5.svg`) // new hash.
b.AssertFileContent("public/css/bar-LVHHRPN5.svg", "newbarsvg")
// Edit foo.css
@@ -132,7 +132,7 @@ All. No CSS here.
// Edit bar.css
b.EditFileReplaceAll("assets/css/bar.css", "bar.svg", "foo.svg").Build()
b.AssertRenderCountPage(1)
b.AssertFileContent("public/css/main.css", `foo-52JTT5GU.svg`)
b.AssertFileContent("public/css/main.css", `/css/foo-52JTT5GU.svg`)
b.AssertFileContent("public/css/foo-52JTT5GU.svg", "foosvg")
// Edit main.css
@@ -342,7 +342,7 @@ CSS: {{ .RelPermalink }}|{{ .Content }}
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
for _, lang := range []string{"en", "fr"} {
b.AssertFileContent("public/"+lang+"/css/main.css", `./pixel-NJRUOINY.png`)
b.AssertFileContent("public/"+lang+"/css/main.css", `/css/pixel-NJRUOINY.png`)
b.AssertFileExists("public/"+lang+"/css/pixel-NJRUOINY.png", true)
}
}
@@ -372,12 +372,120 @@ div {
`
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
b.AssertFileContent("public/css/main.css", `./pixel-NJRUOINY.png`)
b.AssertFileContent("public/css/main.css", `/css/pixel-NJRUOINY.png`)
b.AssertFileExists("public/css/pixel-NJRUOINY.png", true)
b.AssertFileContent("public/css/main.css", `url("./issue14619-NJRUOINY.png")`)
b.AssertFileContent("public/css/main.css", `url("/css/issue14619-NJRUOINY.png")`)
b.AssertFileExists("public/css/issue14619-NJRUOINY.png", true)
}
// Issue #14849
func TestCSSBuildFileLoaderURLRelativeToWebContextRoot(t *testing.T) {
t.Parallel()
filesTemplate := `
-- hugo.toml --
BASEURL
disableKinds = ['rss','section','sitemap','taxonomy','term']
-- assets/css/main.css --
@import "components/header.css";
@import "/fonts/comic_neue/fonts.css";
body { color: #222; }
-- assets/css/components/header.css --
header { border-bottom: 1px solid #222; }
-- assets/fonts/comic_neue/fonts.css --
@font-face {
font-family: 'Comic Neue';
src: url(ComicNeue-Regular.ttf) format('truetype');
}
-- assets/fonts/comic_neue/ComicNeue-Regular.ttf --
fakefontdata
-- layouts/home.html --
{{ with resources.Get "css/main.css" }}
{{ with . | css.Build (dict "minify" true) }}
INLINE: <style>{{ .Content | safeCSS }}</style>
LINKED: <link rel="stylesheet" href="{{ .RelPermalink }}" />
{{ end }}
{{ end }}
`
// Default baseURL: URLs in inlined CSS must be resolvable from any page,
// not just relative to the (non-existent) /css/main.css location.
files := strings.ReplaceAll(filesTemplate, "BASEURL", "")
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
b.AssertFileContent("public/index.html", `url("/css/ComicNeue-Regular-`)
b.AssertFileContent("public/css/main.css", `url("/css/ComicNeue-Regular-`)
// baseURL with a subpath: URLs must include that subpath.
files = strings.ReplaceAll(filesTemplate, "BASEURL", `baseURL = "https://example.org/mysite/"`)
b = hugolib.Test(t, files, hugolib.TestOptOsFs())
b.AssertFileContent("public/index.html", `url("/mysite/css/ComicNeue-Regular-`)
b.AssertFileContent("public/css/main.css", `url("/mysite/css/ComicNeue-Regular-`)
}
// Issue #14849
func TestCSSBuildFileLoaderURLRelativeToWebContextRootMultihost(t *testing.T) {
t.Parallel()
// The CSS is built once for all hosts. The deepest base path among the
// hosts is baked into the file-loader URLs so the URL is reachable on every
// host, and the artifact is published at a per-host compensated path so the
// URL resolves correctly on each host. EN_WEIGHT/FR_WEIGHT swap which host
// renders first, to verify the result is independent of build order.
filesTemplate := `
-- hugo.toml --
defaultContentLanguage = "en"
defaultContentLanguageInSubdir = false
disableKinds = ['rss','section','sitemap','taxonomy','term']
[languages]
[languages.en]
baseURL = "https://example.com/docs/"
weight = EN_WEIGHT
[languages.fr]
baseURL = "https://example.fr/"
weight = FR_WEIGHT
-- assets/css/main.css --
@import "/fonts/comic_neue/fonts.css";
body { color: #222; }
-- assets/fonts/comic_neue/fonts.css --
@font-face {
font-family: 'Comic Neue';
src: url(ComicNeue-Regular.ttf) format('truetype');
}
-- assets/fonts/comic_neue/ComicNeue-Regular.ttf --
fakefontdata
-- layouts/home.html --
{{ with resources.Get "css/main.css" }}
{{ with . | css.Build (dict "minify" true) }}
INLINE: <style>{{ .Content | safeCSS }}</style>
LINKED: <link rel="stylesheet" href="{{ .RelPermalink }}" />
{{ end }}
{{ end }}
`
assertResult := func(t *testing.T, b *hugolib.IntegrationTestBuilder) {
t.Helper()
b.Assert(b.H.Conf.IsMultihost(), qt.Equals, true)
b.AssertFileContent("public/en/index.html", `url("/docs/css/ComicNeue-Regular-`)
b.AssertFileContent("public/en/css/main.css", `url("/docs/css/ComicNeue-Regular-`)
b.AssertFileContent("public/fr/index.html", `url("/docs/css/ComicNeue-Regular-`)
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")
}
t.Run("en first", func(t *testing.T) {
t.Parallel()
files := strings.NewReplacer("EN_WEIGHT", "10", "FR_WEIGHT", "20").Replace(filesTemplate)
assertResult(t, hugolib.Test(t, files, hugolib.TestOptOsFs()))
})
t.Run("fr first", func(t *testing.T) {
t.Parallel()
files := strings.NewReplacer("EN_WEIGHT", "20", "FR_WEIGHT", "10").Replace(filesTemplate)
assertResult(t, hugolib.Test(t, files, hugolib.TestOptOsFs()))
})
}
// Issue #14623
func TestCSSBuildLoadersPartial(t *testing.T) {
t.Parallel()