mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-24 07:18:54 +00:00
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:
+2
-1
@@ -4,4 +4,5 @@ imports.*
|
||||
dist/
|
||||
public/
|
||||
.DS_Store
|
||||
cache/filecache/_gen/
|
||||
cache/filecache/_gen/
|
||||
.claude/
|
||||
@@ -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|",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user