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