]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Fix it so we never auto-fallback to page resources in other roles/versions
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Tue, 14 Apr 2026 14:33:38 +0000 (16:33 +0200)
committerGitHub <noreply@github.com>
Tue, 14 Apr 2026 14:33:38 +0000 (16:33 +0200)
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

hugofs/fileinfo.go
hugolib/content_map.go
hugolib/content_map_page.go
hugolib/content_map_page_assembler.go
hugolib/sitesmatrix/dimensions.go
hugolib/sitesmatrix/sitematrix_integration_test.go
resources/page/pagemeta/page_frontmatter.go

index d0d008317d97f9f8fa03ec043bae2ea7136a331d..8b3270b47179badb5a645a5d5f0c4f6034e6cf16 100644 (file)
@@ -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
index c1e11df12a923a2dd375d513067e53609d15dd55..fe7f2f5fcbac6b827facbcc4c13c82c212f8208b 100644 (file)
@@ -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
        }
 
index bfab1c8ffdc34ad23481fc728b962e77163da9fd..a8714949b74ab00be03c5a253a36bca0bb53261d 100644 (file)
@@ -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)
index db0fb0ede7ba1e3b32d7670c5f1d9089c3dbacc4..3c776922462d2659f69d7d0be85fd53a7ee7b203 100644 (file)
@@ -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
        }
 
index 2dbc745c0850135222a4c6c448f63d60c008597f..d7a96a038169800d4eba83842893826d396d7f10 100644 (file)
@@ -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:
index cf20088d514d809026c1dc2780a09c73dc058460..84908a1904865238cef7e0fbf30aebeb9529fa1d 100644 (file)
@@ -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'
index 7c32e89e61a70052d7d65cab5ac74529d488785e..53788d9b3c01c5ba7c342b6495d991b9d590bcce 100644 (file)
@@ -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,