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