From: Bjørn Erik Pedersen Date: Tue, 14 Apr 2026 14:33:38 +0000 (+0200) Subject: Fix it so we never auto-fallback to page resources in other roles/versions X-Git-Url: http://git.maquefel.me/?a=commitdiff_plain;h=974772422258f1e8aab1c7603fc09bbb1a2c2571;p=brevno-suite%2Fhugo Fix it so we never auto-fallback to page resources in other roles/versions This is some logic that's left behind from when we had only one dimension (language) where the common case would be to have one resource set (e.g. an image) and many content translation. After this commit: * For sites matrix defined in the content filename (e.g. data.en.js) or in its mount definition, we may use that as a fallback for e.g. German languages if we don't find a better match. * For content adapters, this is not relevant: Here you must be explicit about this. * We never auto-fallback on resources from a role/version to another. * When a page bundle spans multiple roles (e.g. via roles = "*"), we clone its resources to all roles so each gets role-specific paths. Fixes #14749 Fixes #14752 --- diff --git a/hugofs/fileinfo.go b/hugofs/fileinfo.go index d0d008317..8b3270b47 100644 --- a/hugofs/fileinfo.go +++ b/hugofs/fileinfo.go @@ -156,6 +156,30 @@ func (m *FileMeta) ModulePath() string { return m.Module.Path() } +func (m *FileMeta) MatchSiteVectorCoarse(v sitesmatrix.Vector) bool { + language := v.Language() + if !(m.SitesMatrix.HasLanguage(language) || m.SitesComplements.HasLanguage(language)) { + return false + } + + return m.MatchSiteVectorCoarseExcludeLanguage(v) +} + +func (m *FileMeta) MatchSiteVectorCoarseExcludeLanguage(v sitesmatrix.Vector) bool { + version := v.Version() + if !(m.SitesMatrix.HasVersion(version) || m.SitesComplements.HasVersion(version)) { + return false + } + + role := v.Role() + // lint:ignore S1008 preserve the symmetry from above. + if !(m.SitesMatrix.HasRole(role) || m.SitesComplements.HasRole(role)) { + return false + } + + return true +} + type FileMetaInfo interface { fs.DirEntry MetaProvider diff --git a/hugolib/content_map.go b/hugolib/content_map.go index c1e11df12..fe7f2f5fc 100644 --- a/hugolib/content_map.go +++ b/hugolib/content_map.go @@ -168,6 +168,19 @@ func (p *resourceSource) lookupContentNode(v sitesmatrix.Vector) contentNode { func (p *resourceSource) lookupContentNodes(siteVector sitesmatrix.Vector, fallback bool) iter.Seq[contentNodeForSite] { if siteVector == p.sv { + found := p.rc == nil + if !found { + // For content adapter resources with explicit sites.matrix config, + // verify the vector is allowed even on exact match, + // since sv is the creating site's vector, not necessarily the target. + // In the exact-match path (siteVector == p.sv), always use strict matching. + // The coarse language-excluding fallback only applies for cross-site matching. + found = p.rc.Sites.Matrix.IsZero() || p.rc.MatchSiteVector(siteVector) + } + if !found { + return nil + } + return func(yield func(n contentNodeForSite) bool) { yield(p) } @@ -184,19 +197,19 @@ func (p *resourceSource) lookupContentNodes(siteVector sitesmatrix.Vector, fallb } } - if !found && pc != nil { - if !pc.MatchLanguageCoarse(siteVector) { - return nil - } - if !pc.MatchVersionCoarse(siteVector) { - return nil - } - if !pc.MatchRoleCoarse(siteVector) { - return nil + if !found { + if pc != nil { + // Content adapter resources have explicit site matrix config; respect all dimensions. + found = pc.MatchSiteVectorCoarse(siteVector) + } else if p.fi != nil { + // File-based resources: exclude the language dimension in the coarse match. + // The typical setup is to provide translated content files but one resource (e.g. an image) that is shared between the translations. + // To give all the languages a full set, we ignore that dimension for this last fallback check. + found = p.fi.Meta().MatchSiteVectorCoarseExcludeLanguage(siteVector) } } - if !found && !fallback { + if !found { return nil } diff --git a/hugolib/content_map_page.go b/hugolib/content_map_page.go index bfab1c8ff..a8714949b 100644 --- a/hugolib/content_map_page.go +++ b/hugolib/content_map_page.go @@ -561,14 +561,15 @@ func (m *pageMap) forEachResourceInPage( func (m *pageMap) getResourcesForPage(ps *pageState) (resource.Resources, error) { var res resource.Resources + m.forEachResourceInPage(ps, doctree.LockTypeNone, true, nil, func(resourceKey string, n contentNode) (bool, error) { switch n := n.(type) { case *resourceSource: r := n.r + if r == nil { panic(fmt.Sprintf("getResourcesForPage: resource %q for page %q has no resource, sites matrix %v/%v", resourceKey, ps.Path(), ps.siteVector(), n.sv)) } - res = append(res, r) case *pageState: res = append(res, n) diff --git a/hugolib/content_map_page_assembler.go b/hugolib/content_map_page_assembler.go index db0fb0ede..3c7769224 100644 --- a/hugolib/content_map_page_assembler.go +++ b/hugolib/content_map_page_assembler.go @@ -791,6 +791,38 @@ func (a *allPagesAssembler) doCreatePages(prefix string, depth int) error { }, ) + // Duplicate resources across roles when owner pages share the same source. + // This ensures resources in a page bundle are available to all roles + // that the page spans, avoiding 404 errors in the rendered output. + if len(nodes) > 0 { + forEeachResourceOwnerPage(func(p *pageState) bool { + if _, found := nodes[p.s.siteVector]; found { + return true + } + // Find a resource from another role whose page shares + // the same pageMetaSource (i.e. same mount/content file). + forEeachResourceOwnerPage(func(donor *pageState) bool { + if donor.s.siteVector == p.s.siteVector { + return true + } + if donor.m.pageMetaSource != p.m.pageMetaSource { + return true + } + // Only clone across role differences, not language or version. + if donor.s.siteVector[sitesmatrix.Language] != p.s.siteVector[sitesmatrix.Language] || + donor.s.siteVector[sitesmatrix.Version] != p.s.siteVector[sitesmatrix.Version] { + return true + } + if rs, ok := nodes[donor.s.siteVector]; ok { + nodes[p.s.siteVector] = rs.(*resourceSource).clone().assignSiteVector(p.s.siteVector) + return false + } + return true + }) + return true + }) + } + return } diff --git a/hugolib/sitesmatrix/dimensions.go b/hugolib/sitesmatrix/dimensions.go index 2dbc745c0..d7a96a038 100644 --- a/hugolib/sitesmatrix/dimensions.go +++ b/hugolib/sitesmatrix/dimensions.go @@ -229,6 +229,13 @@ type ToVectorStoreProvider interface { ToVectorStore() VectorStore } +// CoarseSiteVectorMatcher is implemented by types that can match a site vector in a coarse way, i.e. it first checks for a +// sites matrix match and then falls back to checking the sites complements. +type CoarseSiteVectorMatcher interface { + MatchSiteVectorCoarse(Vector) bool + MatchSiteVectorCoarseExcludeLanguage(Vector) bool +} + func VectorIteratorToStore(vi VectorIterator) VectorStore { switch v := vi.(type) { case VectorStore: diff --git a/hugolib/sitesmatrix/sitematrix_integration_test.go b/hugolib/sitesmatrix/sitematrix_integration_test.go index cf20088d5..84908a190 100644 --- a/hugolib/sitesmatrix/sitematrix_integration_test.go +++ b/hugolib/sitesmatrix/sitematrix_integration_test.go @@ -399,6 +399,163 @@ sites: b.AssertFileContent("public/guest/v1.4.0/en/p2/index.html", "title: EN p2|") } +func TestContentFilesMountSitesMatrixResourcesVersionsAndLanguages(t *testing.T) { + // The assertions below seems reasonable, but it's also a constructed and very rare corner case + // that's hard to support without adding too much complexity. + // Keep the test for now in case I can come up with something simple. + t.Skip("TODO") + t.Parallel() + + filesTemplate := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap", "section"] +defaultContentLanguage = "en" +defaultContentLanguageInSubDir = true +defaultContentVersion = "v1.2.3" +defaultContentVersionInSubDir = true +[languages] +[languages.en] +weight = 1 +[languages.nn] +weight = 2 +[versions] +[versions."v1.2.3"] +[versions."v2.0.0"] + +[[module.mounts]] +source = 'content/v1' +target = 'content' +[module.mounts.sites.matrix] +versions = ["v1.2.*"] +languages = ["*"] +[[module.mounts]] +source = 'content/v2' +target = 'content' +[module.mounts.sites.matrix] +versions = ["v2.0.*"] +languages = ["*"] +-- content/v1/p1/index.en.md -- +--- +title: "Title English v1" +--- +-- content/v2/p1/index.en.md -- +--- +title: "Title English v2" +--- +-- content/v1/p1/index.nn.md -- +--- +title: "Tittel Nynorsk" +--- +-- content/v2/p1/mytext.nn.txt -- +Tekst Nynorsk +-- layouts/all.html -- +Resources: {{ range .Resources }}{{ .RelPermalink }}|{{ end }}$ +` + + b := hugolib.Test(t, filesTemplate) + b.AssertFileContent("public/v1.2.3/nn/p1/index.html", "Resources: $") + b.AssertFileContent("public/v1.2.3/en/p1/index.html", "Resources: $") + b.AssertFileContent("public/v2.0.0/en/p1/index.html", "Resources: /v2.0.0/en/p1/mytext.nn.txt|$") +} + +func TestFileMountSitesMatrixResourcesRoles(t *testing.T) { + filesTemplate := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap", "section"] +defaultContentRole = "guest" +defaultContentRoleInSubDir = true +[roles] +[roles.guest] +weight = 300 +[roles.member] +weight = 200 +[mounts] +[[module.mounts]] +source = 'content/guest' +target = 'content' +[module.mounts.sites.matrix] +roles = "guest" +[[module.mounts]] +source = 'content/member' +target = 'content' +[module.mounts.sites.matrix] +roles = "member" +-- layouts/all.html -- +{{ .Title }}|{{ .RelPermalink }}|Resources: {{ range .Resources }}{{ .RelPermalink }}|{{ end }}$ +-- content/guest/p1/index.md -- +--- +title: "Guest Gallery" +--- +-- content/member/p1/index.md -- +--- +title: "Member Gallery" +--- +-- content/guest/p1/mytext.txt -- +Text Guest +-- content/member/p1/mytext2.txt -- +Text Member + +` + + t.Run("Issue 1", func(t *testing.T) { + t.Parallel() + files := strings.Replace(filesTemplate, "content/member/p1/index.md", "content/member/p1_removed/index.md", 1) + files = strings.Replace(files, `roles = "guest"`, `roles = "*"`, 1) + b := hugolib.Test(t, files) + + // The current behavior is well intended: We avoid copying the same resources to multiple places. + // But for the typical role use case, this typically leads to 404 errors for shared resources in the member section. + b.AssertFileContent("public/guest/p1/index.html", "Guest Gallery|/guest/p1/|Resources: /guest/p1/mytext.txt|/guest/p1/mytext2.txt|$") + b.AssertFileContent("public/member/p1/index.html", "Guest Gallery|/member/p1/|Resources: /member/p1/mytext.txt|/member/p1/mytext2.txt|$") + }) + + t.Run("Issue 2", func(t *testing.T) { + t.Parallel() + files := filesTemplate + b := hugolib.Test(t, files) + + // This comes from how we handled languages before we added version and role: + // You would typically add 1 image resources and then translate the markdown files to multiple languages. + // To make sure that all languages got a complete set when doing Page.Resources, we pull in missing resources from, in this case, the member section. + // This obviously doesn't work for the role dimension, but it works for the language dimension, and we need to make sure that we don't break that. + b.AssertFileContent("public/guest/p1/index.html", "Guest Gallery|/guest/p1/|Resources: /guest/p1/mytext.txt|$") + b.AssertFileContent("public/member/p1/index.html", "Member Gallery|/member/p1/|Resources: /member/p1/mytext2.txt|$") + }) +} + +func TestFileMountSitesMatrixResourcesRolesContentAdapter(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap", "section"] +defaultContentRole = "guest" +defaultContentRoleInSubDir = true +[roles] +[roles.guest] +weight = 300 +[roles.member] +weight = 200 +-- layouts/all.html -- +{{ .Title }}|{{ .RelPermalink }}|Resources: {{ range .Resources }}{{ .RelPermalink }}|{{ end }}$ +-- content/_content.gotmpl -- +{{ $guest := dict "roles" "guest" }} +{{ $member := dict "roles" "member" }} +{{ $contentMarkdownGuest := dict "value" "**Guest**" "mediaType" "text/markdown" }} +{{ $contentMarkdownMember := dict "value" "**Member**" "mediaType" "text/markdown" }} +{{ $contentTextGuest:= dict "value" "Guest" "mediaType" "text/plain" }} +{{ $contentTextMember:= dict "value" "Member" "mediaType" "text/plain" }} +{{ .AddPage (dict "path" "p1" "title" "P1 guest" "content" $contentMarkdownGuest "sites" (dict "matrix" $guest )) }} +{{ .AddPage (dict "path" "p1" "title" "P1 member" "content" $contentMarkdownMember "sites" (dict "matrix" $member )) }} + +{{ .AddResource (dict "path" "p1/hello1.txt" "title" "Hello guest" "content" $contentTextGuest "sites" (dict "matrix" $guest )) }} +{{ .AddResource (dict "path" "p1/hello2.txt" "title" "Hello member" "content" $contentTextMember "sites" (dict "matrix" $member )) }} +{{ .AddResource (dict "path" "p1/hello3.txt" "title" "Hello member 2" "content" $contentTextMember "sites" (dict "matrix" $member )) }} + +` + b := hugolib.Test(t, files) + b.AssertFileContent("public/member/p1/index.html", "P1 member|/member/p1/|Resources: /member/p1/hello2.txt|/member/p1/hello3.txt|$") + b.AssertFileContent("public/guest/p1/index.html", "P1 guest|/guest/p1/|Resources: /guest/p1/hello1.txt|$") +} + func TestGetPageAndRef(t *testing.T) { t.Parallel() @@ -1605,8 +1762,6 @@ defaultContentVersionInSubDir = true [versions] [versions."v1"] [versions."v2"] - -[module] [[module.mounts]] source = 'content' target = 'content' diff --git a/resources/page/pagemeta/page_frontmatter.go b/resources/page/pagemeta/page_frontmatter.go index 7c32e89e6..53788d9b3 100644 --- a/resources/page/pagemeta/page_frontmatter.go +++ b/resources/page/pagemeta/page_frontmatter.go @@ -215,6 +215,14 @@ func (p *SitesMatrixAndComplements) MatchVersionCoarse(siteVector sitesmatrix.Ve return p.SitesMatrix.HasVersion(i) || p.SitesComplements.HasVersion(i) } +func (p *SitesMatrixAndComplements) MatchSiteVectorCoarse(siteVector sitesmatrix.Vector) bool { + return p.MatchLanguageCoarse(siteVector) && p.MatchSiteVectorCoarseExcludeLanguage(siteVector) +} + +func (p *SitesMatrixAndComplements) MatchSiteVectorCoarseExcludeLanguage(siteVector sitesmatrix.Vector) bool { + return p.MatchRoleCoarse(siteVector) && p.MatchVersionCoarse(siteVector) +} + func DefaultPageConfig() *PageConfigLate { return &PageConfigLate{ Build: DefaultBuildConfig,