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,