From e51e761d9c07897f1c7b28891eb053a05642fa40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Fri, 8 May 2026 16:48:28 +0200 Subject: [PATCH] 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 --- .gitignore | 3 +- common/hexec/exec_integration_test.go | 16 +-- config/allconfig/configlanguage.go | 9 ++ hugolib/paths/paths.go | 33 +++++- internal/js/esbuild/build.go | 5 + internal/js/esbuild/options.go | 9 +- resources/resource_transformers/js/build.go | 53 ++++++++- tpl/css/build_integration_test.go | 120 +++++++++++++++++++- 8 files changed, 226 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index ed5bfdcb3..0e812bb3e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ imports.* dist/ public/ .DS_Store -cache/filecache/_gen/ \ No newline at end of file +cache/filecache/_gen/ +.claude/ \ No newline at end of file diff --git a/common/hexec/exec_integration_test.go b/common/hexec/exec_integration_test.go index b76527bfa..3c820b570 100644 --- a/common/hexec/exec_integration_test.go +++ b/common/hexec/exec_integration_test.go @@ -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|", ) } diff --git a/config/allconfig/configlanguage.go b/config/allconfig/configlanguage.go index c52d5da3d..fa2a18681 100644 --- a/config/allconfig/configlanguage.go +++ b/config/allconfig/configlanguage.go @@ -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 } diff --git a/hugolib/paths/paths.go b/hugolib/paths/paths.go index e761e1246..0c533828b 100644 --- a/hugolib/paths/paths.go +++ b/hugolib/paths/paths.go @@ -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 diff --git a/internal/js/esbuild/build.go b/internal/js/esbuild/build.go index a3173a22c..355732503 100644 --- a/internal/js/esbuild/build.go +++ b/internal/js/esbuild/build.go @@ -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 diff --git a/internal/js/esbuild/options.go b/internal/js/esbuild/options.go index 246e70701..f5112f4cd 100644 --- a/internal/js/esbuild/options.go +++ b/internal/js/esbuild/options.go @@ -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, diff --git a/resources/resource_transformers/js/build.go b/resources/resource_transformers/js/build.go index 41d9dd0dd..f0ea8ea8b 100644 --- a/resources/resource_transformers/js/build.go +++ b/resources/resource_transformers/js/build.go @@ -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 +} diff --git a/tpl/css/build_integration_test.go b/tpl/css/build_integration_test.go index 4f8fbd405..31b692a8e 100644 --- a/tpl/css/build_integration_test.go +++ b/tpl/css/build_integration_test.go @@ -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: +LINKED: +{{ 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: +LINKED: +{{ 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()