]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Add a more flexible filename identifier scheme that also allows setting roles and...
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Wed, 15 Apr 2026 14:44:19 +0000 (16:44 +0200)
committerGitHub <noreply@github.com>
Wed, 15 Apr 2026 14:44:19 +0000 (16:44 +0200)
Fixes #14750

common/paths/pathparser.go
common/paths/pathparser_test.go
common/paths/paths_integration_test.go
common/paths/testdata/fuzz/FuzzParsePathContent/62fadd5d4f836c18 [new file with mode: 0644]
hugolib/sitesmatrix/vectorstores.go

index 5d828df142545cc4b4742bfe7eae8f670a38adcc..9904c596c9ff94724f81ab7bd8440e20fd13e8f9 100644 (file)
@@ -34,6 +34,16 @@ const (
        identifierCustomWrapper = "_"
 )
 
+// Known prefixes for ._prefix_value_. identifiers.
+const (
+       prefixLanguage     = "language_"
+       prefixVersion      = "version_"
+       prefixRole         = "role_"
+       prefixOutputFormat = "outputformat_"
+       prefixKind         = "kind_"
+       prefixLayout       = "layout_"
+)
+
 // isCustomWrapperIdentifier tells whether a supplied path is of the form _xyz_.
 // must have non-empty content between the identifierCustomWrapper's to pass.
 func isCustomWrapperIdentifier(s string) bool {
@@ -42,6 +52,135 @@ func isCustomWrapperIdentifier(s string) bool {
                strings.HasSuffix(s, identifierCustomWrapper)
 }
 
+// parsePrefixIdentifier parses inner content of a wrapper block (with outer underscores stripped)
+// and returns the prefix. The prefix match is case-insensitive.
+// Returns empty string if not a known prefix.
+func parsePrefixIdentifier(inner string) string {
+       innerLower := strings.ToLower(inner)
+       for _, p := range []string{prefixLanguage, prefixVersion, prefixRole, prefixOutputFormat, prefixKind, prefixLayout} {
+               if strings.HasPrefix(innerLower, p) {
+                       return p
+               }
+       }
+       return ""
+}
+
+// findWrapperDotPositions returns positions of dots inside ._..._. wrapper blocks.
+// These dots should be skipped during the main backward dot-scanning loop,
+// so that wrapper blocks are treated as single opaque segments.
+func findWrapperDotPositions(s string) []int {
+       lastSlash := strings.LastIndex(s, "/")
+       if lastSlash == -1 {
+               return nil
+       }
+
+       var skipDots []int
+       i := lastSlash
+       for i < len(s) {
+               // Look for ._ which could start a wrapper block.
+               if i+2 < len(s) && s[i] == '.' && s[i+1] == '_' {
+                       // Find the closing _. or _ at end of string.
+                       end := -1
+                       for j := i + 2; j < len(s); j++ {
+                               if s[j] == '/' {
+                                       break
+                               }
+                               if s[j] == '_' && (j+1 >= len(s) || s[j+1] == '.') {
+                                       end = j
+                                       break
+                               }
+                       }
+                       if end != -1 {
+                               // Record dot positions inside the wrapper block.
+                               for j := i + 2; j < end; j++ {
+                                       if s[j] == '.' {
+                                               skipDots = append(skipDots, j)
+                                       }
+                               }
+                               i = end + 1
+                               continue
+                       }
+               }
+               i++
+       }
+       return skipDots
+}
+
+// isSkippedDot reports whether position i is in the sorted skipDots slice.
+func isSkippedDot(skipDots []int, i int) bool {
+       for _, d := range skipDots {
+               if d == i {
+                       return true
+               }
+               if d > i {
+                       break
+               }
+       }
+       return false
+}
+
+// applyPrefixIdentifier validates and stores a prefix identifier.
+// id is the value-only LowHigh (e.g. pointing at "v1.0.0" in p.s, not the full wrapper).
+// Returns true if the identifier was recognized and applied.
+func (pp *PathParser) applyPrefixIdentifier(component, prefix string, p *Path, id types.LowHigh[string]) bool {
+       value := strings.ToLower(p.s[id.Low:id.High])
+       switch prefix {
+       case prefixLanguage:
+               if pp.LanguageIndex != nil {
+                       if _, ok := pp.LanguageIndex[value]; ok {
+                               p.identifiersKnown = append(p.identifiersKnown, id)
+                               p.posIdentifierPrefixLanguages = append(p.posIdentifierPrefixLanguages, len(p.identifiersKnown)-1)
+                               return true
+                       }
+                       if pp.IsLangDisabled != nil && pp.IsLangDisabled(value) {
+                               p.identifiersKnown = append(p.identifiersKnown, id)
+                               p.posIdentifierPrefixLanguages = append(p.posIdentifierPrefixLanguages, len(p.identifiersKnown)-1)
+                               p.disabled = true
+                               return true
+                       }
+               }
+       case prefixVersion:
+               if pp.ConfiguredDimensions != nil {
+                       if idx := pp.ConfiguredDimensions.ConfiguredVersions.ResolveIndex(value); idx >= 0 {
+                               p.identifiersKnown = append(p.identifiersKnown, id)
+                               p.posIdentifierVersions = append(p.posIdentifierVersions, len(p.identifiersKnown)-1)
+                               return true
+                       }
+               }
+       case prefixRole:
+               if pp.ConfiguredDimensions != nil {
+                       if idx := pp.ConfiguredDimensions.ConfiguredRoles.ResolveIndex(value); idx >= 0 {
+                               p.identifiersKnown = append(p.identifiersKnown, id)
+                               p.posIdentifierRoles = append(p.posIdentifierRoles, len(p.identifiersKnown)-1)
+                               return true
+                       }
+               }
+       case prefixOutputFormat:
+               if component == files.ComponentFolderLayouts && pp.IsOutputFormat != nil {
+                       if pp.IsOutputFormat(value, "") {
+                               p.identifiersKnown = append(p.identifiersKnown, id)
+                               p.posIdentifierPrefixOutputFormat = len(p.identifiersKnown) - 1
+                               return true
+                       }
+               }
+       case prefixKind:
+               if component == files.ComponentFolderLayouts {
+                       if kinds.GetKindMain(value) != "" {
+                               p.identifiersKnown = append(p.identifiersKnown, id)
+                               p.posIdentifierPrefixKind = len(p.identifiersKnown) - 1
+                               return true
+                       }
+               }
+       case prefixLayout:
+               if component == files.ComponentFolderLayouts {
+                       p.identifiersKnown = append(p.identifiersKnown, id)
+                       p.posIdentifierPrefixLayout = len(p.identifiersKnown) - 1
+                       return true
+               }
+       }
+       return false
+}
+
 // PathParser parses and manages paths.
 type PathParser struct {
        // Maps the language code to its index in the languages/sites slice.
@@ -84,14 +223,36 @@ func NormalizePathStringBasic(s string) string {
 
 func (pp *PathParser) SitesMatrixFromPath(p *Path) sitesmatrix.VectorStore {
        pp.init()
-       lang := p.Lang()
-       v, _ := pp.sitesMatrixCache.GetOrCreate(lang, func() (sitesmatrix.VectorStore, error) {
+       langs := p.Langs()
+       versions := p.Versions()
+       roles := p.Roles()
+       lang := p.Lang() // First or dot-based language.
+       // Cache by the full site-selection identity derived from the path:
+       // languages, selected language, versions, and roles.
+       cacheKey := strings.Join(langs, ",") + "/" + lang + "/" + strings.Join(versions, ",") + "|" + strings.Join(roles, ",")
+       v, _ := pp.sitesMatrixCache.GetOrCreate(cacheKey, func() (sitesmatrix.VectorStore, error) {
                builder := sitesmatrix.NewIntSetsBuilder(pp.ConfiguredDimensions)
-               if lang != "" {
+               if len(langs) > 0 {
+                       for _, l := range langs {
+                               if idx, ok := pp.LanguageIndex[l]; ok {
+                                       builder.WithLanguageIndices(idx)
+                               }
+                       }
+               } else if lang != "" {
                        if idx, ok := pp.LanguageIndex[lang]; ok {
                                builder.WithLanguageIndices(idx)
                        }
                }
+               for _, version := range versions {
+                       if idx := pp.ConfiguredDimensions.ConfiguredVersions.ResolveIndex(version); idx >= 0 {
+                               builder.WithVersionIndices(idx)
+                       }
+               }
+               for _, role := range roles {
+                       if idx := pp.ConfiguredDimensions.ConfiguredRoles.ResolveIndex(role); idx >= 0 {
+                               builder.WithRoleIndices(idx)
+                       }
+               }
 
                switch p.Component() {
                case files.ComponentFolderContent:
@@ -197,12 +358,24 @@ func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot,
        sid := p.s[id.Low:id.High]
 
        if isCustomWrapperIdentifier(sid) {
-               p.identifiersKnown = append(p.identifiersKnown, id)
-               p.posIdentifierCustom = len(p.identifiersKnown) - 1
-               found = true
+               inner := sid[1 : len(sid)-1]
+               if prefix := parsePrefixIdentifier(inner); prefix != "" {
+                       // Value-only LowHigh: skip leading _ + prefix, trailing _
+                       valueID := types.LowHigh[string]{Low: id.Low + 1 + len(prefix), High: id.High - 1}
+                       if pp.applyPrefixIdentifier(component, prefix, p, valueID) {
+                               found = true
+                       }
+               }
+               if !found {
+                       p.identifiersKnown = append(p.identifiersKnown, id)
+                       p.posIdentifierCustom = len(p.identifiersKnown) - 1
+                       found = true
+               }
        }
 
-       if len(p.identifiersKnown) == 0 {
+       if found {
+               // Already handled (e.g. prefix wrapper).
+       } else if len(p.identifiersKnown) == 0 {
                // The first is always the extension.
                p.identifiersKnown = append(p.identifiersKnown, id)
                found = true
@@ -276,6 +449,18 @@ func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot,
                }
 
        }
+
+       if found {
+               if isLast {
+                       // The isLast identifier starts right at the container boundary.
+                       // Treat it as part of the name (e.g. layout name "list" in list.no.html).
+                       if p.posNameHigh <= 0 {
+                               p.posNameHigh = lastDot
+                       }
+               } else {
+                       p.posNameHigh = i // The '.' before this identifier.
+               }
+       }
 }
 
 func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) {
@@ -300,10 +485,14 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) {
        }
 
        p.s = s
+
+       // Find dots inside ._..._. wrapper blocks that must be skipped.
+       skipDots := findWrapperDotPositions(s)
+
        slashCount := 0
        lastDot := 0
        lastSlashIdx := strings.LastIndex(s, "/")
-       numDots := strings.Count(s[lastSlashIdx+1:], ".")
+       numDots := strings.Count(s[lastSlashIdx+1:], ".") - len(skipDots)
        if strings.Contains(s, "/_shortcodes/") {
                p.pathType = TypeShortcode
        }
@@ -313,6 +502,9 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) {
 
                switch c {
                case '.':
+                       if isSkippedDot(skipDots, i) {
+                               continue
+                       }
                        pp.parseIdentifier(component, s, p, i, lastDot, numDots, false)
                        lastDot = i
                case '/':
@@ -331,13 +523,19 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) {
                }
        }
 
+       // Compute the name boundary.
+       if p.posNameHigh >= p.posContainerHigh {
+               p.posIdentifierName = types.LowHigh[string]{Low: p.posContainerHigh, High: p.posNameHigh}
+       } else {
+               p.posIdentifierName = types.LowHigh[string]{Low: p.posContainerHigh, High: len(p.s)}
+       }
+
        if len(p.identifiersKnown) > 0 {
                isContentComponent := p.component == files.ComponentFolderContent || p.component == files.ComponentFolderArchetypes
                isContent := isContentComponent && pp.IsContentExt(p.Ext())
-               id := p.identifiersKnown[len(p.identifiersKnown)-1]
 
-               if id.Low > p.posContainerHigh {
-                       b := p.s[p.posContainerHigh : id.Low-1]
+               if p.posIdentifierName.Low >= p.posContainerHigh && p.posIdentifierName.High > p.posIdentifierName.Low {
+                       b := p.s[p.posIdentifierName.Low:p.posIdentifierName.High]
                        if isContent {
                                switch b {
                                case "index":
@@ -445,7 +643,22 @@ type Path struct {
        posIdentifierLayout       int
        posIdentifierBaseof       int
        posIdentifierCustom       int
-       disabled                  bool
+
+       // Prefix identifier positions (indices into identifiersKnown).
+       posIdentifierPrefixLanguages    []int
+       posIdentifierVersions           []int
+       posIdentifierRoles              []int
+       posIdentifierPrefixOutputFormat int
+       posIdentifierPrefixKind         int
+       posIdentifierPrefixLayout       int
+
+       // Name boundary, computed during parse.
+       posIdentifierName types.LowHigh[string]
+       // Position of the dot before the leftmost known identifier.
+       // Set during parseIdentifier, used to compute posIdentifierName.
+       posNameHigh int
+
+       disabled bool
 
        trimLeadingSlash bool
 
@@ -483,6 +696,14 @@ func (p *Path) reset() {
        p.posIdentifierLayout = -1
        p.posIdentifierBaseof = -1
        p.posIdentifierCustom = -1
+       p.posIdentifierPrefixLanguages = p.posIdentifierPrefixLanguages[:0]
+       p.posIdentifierVersions = p.posIdentifierVersions[:0]
+       p.posIdentifierRoles = p.posIdentifierRoles[:0]
+       p.posIdentifierPrefixOutputFormat = -1
+       p.posIdentifierPrefixKind = -1
+       p.posIdentifierPrefixLayout = -1
+       p.posIdentifierName = types.LowHigh[string]{}
+       p.posNameHigh = -1
        p.disabled = false
        p.trimLeadingSlash = false
        p.unnormalized = nil
@@ -576,12 +797,23 @@ func (p *Path) NameNoExt() string {
 
 // Name returns the last element of path without any language identifier.
 func (p *Path) NameNoLang() string {
+       if len(p.posIdentifierPrefixLanguages) > 0 {
+               // Prefix language: the wrapper is outside the name boundary, so Name() without
+               // identifiers is the same as stripping all wrappers. Use the name + extension.
+               if len(p.identifiersKnown) > 0 {
+                       return p.NameNoIdentifier() + p.s[p.identifiersKnown[0].Low-1:p.identifiersKnown[0].High]
+               }
+               return p.Name()
+       }
        i := p.identifierIndex(p.posIdentifierLanguage)
        if i == -1 {
                return p.Name()
        }
-
-       return p.s[p.posContainerHigh:p.identifiersKnown[i].Low-1] + p.s[p.identifiersKnown[i].High:]
+       id := p.identifiersKnown[i]
+       if id.Low-1 < p.posContainerHigh {
+               return p.s[id.High:]
+       }
+       return p.s[p.posContainerHigh:id.Low-1] + p.s[id.High:]
 }
 
 // BaseNameNoIdentifier returns the logical base name for a resource without any identifier (e.g. no extension).
@@ -600,21 +832,7 @@ func (p *Path) NameNoIdentifier() string {
 }
 
 func (p *Path) nameLowHigh() types.LowHigh[string] {
-       if len(p.identifiersKnown) > 0 {
-               lastID := p.identifiersKnown[len(p.identifiersKnown)-1]
-               if p.posContainerHigh == lastID.Low {
-                       // The last identifier is the name.
-                       return lastID
-               }
-               return types.LowHigh[string]{
-                       Low:  p.posContainerHigh,
-                       High: p.identifiersKnown[len(p.identifiersKnown)-1].Low - 1,
-               }
-       }
-       return types.LowHigh[string]{
-               Low:  p.posContainerHigh,
-               High: len(p.s),
-       }
+       return p.posIdentifierName
 }
 
 // Dir returns all but the last element of path, typically the path's directory.
@@ -646,6 +864,9 @@ func (p *Path) Unnormalized() *Path {
 
 // PathNoLang returns the Path but with any language identifier removed.
 func (p *Path) PathNoLang() string {
+       if len(p.posIdentifierPrefixLanguages) > 0 {
+               return p.base(true, false)
+       }
        if p.identifierIndex(p.posIdentifierLanguage) == -1 {
                return p.Path()
        }
@@ -767,21 +988,70 @@ func (p *Path) Ext() string {
 }
 
 func (p *Path) OutputFormat() string {
+       if p.posIdentifierPrefixOutputFormat != -1 {
+               return p.identifierAsString(p.posIdentifierPrefixOutputFormat)
+       }
        return p.identifierAsString(p.posIdentifierOutputFormat)
 }
 
 func (p *Path) Kind() string {
+       if p.posIdentifierPrefixKind != -1 {
+               return p.identifierAsString(p.posIdentifierPrefixKind)
+       }
        return p.identifierAsString(p.posIdentifierKind)
 }
 
 func (p *Path) Layout() string {
+       if p.posIdentifierPrefixLayout != -1 {
+               return p.identifierAsString(p.posIdentifierPrefixLayout)
+       }
        return p.identifierAsString(p.posIdentifierLayout)
 }
 
 func (p *Path) Lang() string {
+       if len(p.posIdentifierPrefixLanguages) > 0 {
+               return p.identifierAsString(p.posIdentifierPrefixLanguages[0])
+       }
        return p.identifierAsString(p.posIdentifierLanguage)
 }
 
+func (p *Path) Langs() []string {
+       return p.identifiersAsStrings(p.posIdentifierPrefixLanguages)
+}
+
+func (p *Path) Version() string {
+       if len(p.posIdentifierVersions) > 0 {
+               return p.identifierAsString(p.posIdentifierVersions[0])
+       }
+       return ""
+}
+
+func (p *Path) Versions() []string {
+       return p.identifiersAsStrings(p.posIdentifierVersions)
+}
+
+func (p *Path) Role() string {
+       if len(p.posIdentifierRoles) > 0 {
+               return p.identifierAsString(p.posIdentifierRoles[0])
+       }
+       return ""
+}
+
+func (p *Path) Roles() []string {
+       return p.identifiersAsStrings(p.posIdentifierRoles)
+}
+
+func (p *Path) identifiersAsStrings(positions []int) []string {
+       if len(positions) == 0 {
+               return nil
+       }
+       ids := make([]string, len(positions))
+       for i, pos := range positions {
+               ids[i] = p.identifierAsString(pos)
+       }
+       return ids
+}
+
 func (p *Path) Custom() string {
        return strings.TrimSuffix(strings.TrimPrefix(p.identifierAsString(p.posIdentifierCustom), identifierCustomWrapper), identifierCustomWrapper)
 }
index 843b286c22455f4d7e96160532eb50dbb143de73..bfff79d9b155ff70c5f5df858d683880abc9136c 100644 (file)
@@ -15,6 +15,7 @@ package paths
 
 import (
        "path/filepath"
+       "reflect"
        "testing"
 
        "github.com/gohugoio/hugo/hugofs/files"
@@ -25,7 +26,7 @@ import (
 )
 
 func newTestParser() *PathParser {
-       dims := sitesmatrix.NewTestingDimensions([]string{"en", "no", "fr"}, []string{"v1", "v2", "v3"}, []string{"admin", "editor", "viewer", "guest"})
+       dims := sitesmatrix.NewTestingDimensions([]string{"en", "no", "fr"}, []string{"v1", "v2", "v3", "v1.0.0"}, []string{"admin", "editor", "viewer", "guest"})
 
        return &PathParser{
                LanguageIndex: map[string]int{
@@ -389,6 +390,145 @@ func TestParse(t *testing.T) {
                                c.Assert(p.Custom(), qt.Equals, "myid")
                        },
                },
+               {
+                       "Prefix language",
+                       "/a/b/p1._language_no_.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Base(), qt.Equals, "/a/b/p1")
+                               c.Assert(p.Lang(), qt.Equals, "no")
+                               c.Assert(p.Ext(), qt.Equals, "md")
+                               c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "no"})
+                       },
+               },
+               {
+                       "Prefix version",
+                       "/a/b/p1._version_v1_.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Base(), qt.Equals, "/a/b/p1")
+                               c.Assert(p.Version(), qt.Equals, "v1")
+                               c.Assert(p.Ext(), qt.Equals, "md")
+                               c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "v1"})
+                       },
+               },
+               {
+                       "Prefix version with dots",
+                       "/a/b/p1._version_v1.0.0_.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Base(), qt.Equals, "/a/b/p1")
+                               c.Assert(p.Version(), qt.Equals, "v1.0.0")
+                               c.Assert(p.Ext(), qt.Equals, "md")
+                       },
+               },
+               {
+                       "Prefix role",
+                       "/a/b/p1._role_admin_.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Base(), qt.Equals, "/a/b/p1")
+                               c.Assert(p.Role(), qt.Equals, "admin")
+                               c.Assert(p.Ext(), qt.Equals, "md")
+                       },
+               },
+               {
+                       "Multiple prefixes",
+                       "/a/b/p1._language_no_._role_admin_._version_v2_.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Base(), qt.Equals, "/a/b/p1")
+                               c.Assert(p.Lang(), qt.Equals, "no")
+                               c.Assert(p.Role(), qt.Equals, "admin")
+                               c.Assert(p.Version(), qt.Equals, "v2")
+                               c.Assert(p.Ext(), qt.Equals, "md")
+                       },
+               },
+               {
+                       "Prefix version with dots and role",
+                       "/a/b/p1._version_v1.0.0_._role_editor_.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Base(), qt.Equals, "/a/b/p1")
+                               c.Assert(p.Version(), qt.Equals, "v1.0.0")
+                               c.Assert(p.Role(), qt.Equals, "editor")
+                               c.Assert(p.Ext(), qt.Equals, "md")
+                       },
+               },
+               {
+                       "Prefix with dot-based lang",
+                       "/a/b/p1._version_v1_.no.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Base(), qt.Equals, "/a/b/p1")
+                               c.Assert(p.Version(), qt.Equals, "v1")
+                               c.Assert(p.Lang(), qt.Equals, "no")
+                               c.Assert(p.Ext(), qt.Equals, "md")
+                       },
+               },
+               {
+                       "Prefix leaf bundle with version",
+                       "/a/b/index._version_v1_.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.IsLeafBundle(), qt.IsTrue)
+                               c.Assert(p.Version(), qt.Equals, "v1")
+                               c.Assert(p.Base(), qt.Equals, "/a/b")
+                               c.Assert(p.BaseNameNoIdentifier(), qt.Equals, "b")
+                       },
+               },
+               {
+                       "Prefix branch bundle with role",
+                       "/a/b/_index._role_admin_.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.IsBranchBundle(), qt.IsTrue)
+                               c.Assert(p.Role(), qt.Equals, "admin")
+                               c.Assert(p.Base(), qt.Equals, "/a/b")
+                       },
+               },
+               {
+                       "Prefix with mixed case, unnormalized",
+                       "/a/b/My Page._Version_V1_.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Base(), qt.Equals, "/a/b/my-page")
+                               c.Assert(p.Version(), qt.Equals, "v1")
+                               pp := p.Unnormalized()
+                               c.Assert(pp.BaseNameNoIdentifier(), qt.Equals, "My Page")
+                               c.Assert(pp.Version(), qt.Equals, "V1")
+                       },
+               },
+               {
+                       "Multiple languages",
+                       "/a/b/p1._language_en_._language_fr_.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Base(), qt.Equals, "/a/b/p1")
+                               // Right-to-left parse order.
+                               c.Assert(p.Lang(), qt.Equals, "fr")
+                               c.Assert(p.Langs(), qt.DeepEquals, []string{"fr", "en"})
+                               c.Assert(p.Ext(), qt.Equals, "md")
+                       },
+               },
+               {
+                       "Multiple roles",
+                       "/a/b/p1._role_guest_._role_admin_.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Base(), qt.Equals, "/a/b/p1")
+                               c.Assert(p.Role(), qt.Equals, "admin")
+                               c.Assert(p.Roles(), qt.DeepEquals, []string{"admin", "guest"})
+                               c.Assert(p.Ext(), qt.Equals, "md")
+                       },
+               },
+               {
+                       "Multiple versions",
+                       "/a/b/p1._version_v1_._version_v2_.md",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Base(), qt.Equals, "/a/b/p1")
+                               c.Assert(p.Version(), qt.Equals, "v2")
+                               c.Assert(p.Versions(), qt.DeepEquals, []string{"v2", "v1"})
+                       },
+               },
+               {
+                       "Unknown prefix not extracted",
+                       "/a/b/p1._unknown_foo_.md",
+                       func(c *qt.C, p *Path) {
+                               // Unknown prefix should be left in path and treated as custom wrapper.
+                               c.Assert(p.Custom(), qt.Equals, "unknown_foo")
+                               c.Assert(p.Version(), qt.Equals, "")
+                               c.Assert(p.Role(), qt.Equals, "")
+                       },
+               },
        }
        parser := newTestParser()
        for _, test := range tests {
@@ -613,14 +753,76 @@ func TestParseLayouts(t *testing.T) {
                                c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{"xy"})
                        },
                },
+               {
+                       "Prefix language layout",
+                       "/page._language_no_.html",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Lang(), qt.Equals, "no")
+                               c.Assert(p.Ext(), qt.Equals, "html")
+                               c.Assert(p.OutputFormat(), qt.Equals, "html")
+                               c.Assert(p.Base(), qt.Equals, "/page.html")
+                       },
+               },
+               {
+                       "Prefix outputformat layout",
+                       "/page._outputformat_amp_.html",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.OutputFormat(), qt.Equals, "amp")
+                               c.Assert(p.Ext(), qt.Equals, "html")
+                               c.Assert(p.Base(), qt.Equals, "/page.html")
+                       },
+               },
+               {
+                       "Prefix kind layout",
+                       "/page._kind_section_.html",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Kind(), qt.Equals, kinds.KindSection)
+                               c.Assert(p.Ext(), qt.Equals, "html")
+                               c.Assert(p.Base(), qt.Equals, "/page.html")
+                       },
+               },
+               {
+                       "Prefix layout layout",
+                       "/page._layout_list_.html",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Layout(), qt.Equals, "list")
+                               c.Assert(p.Ext(), qt.Equals, "html")
+                               c.Assert(p.Base(), qt.Equals, "/page.html")
+                       },
+               },
+               {
+                       "All prefix identifiers in layout",
+                       "/page._language_fr_._kind_section_._outputformat_amp_._layout_list_.html",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Lang(), qt.Equals, "fr")
+                               c.Assert(p.Kind(), qt.Equals, kinds.KindSection)
+                               c.Assert(p.OutputFormat(), qt.Equals, "amp")
+                               c.Assert(p.Layout(), qt.Equals, "list")
+                               c.Assert(p.Ext(), qt.Equals, "html")
+                               c.Assert(p.Base(), qt.Equals, "/page.html")
+                       },
+               },
+               {
+                       "Prefix version in layout",
+                       "/page._version_v2_.html",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Version(), qt.Equals, "v2")
+                               c.Assert(p.Base(), qt.Equals, "/page.html")
+                       },
+               },
+               {
+                       "Prefix role in layout",
+                       "/page._role_guest_.html",
+                       func(c *qt.C, p *Path) {
+                               c.Assert(p.Role(), qt.Equals, "guest")
+                               c.Assert(p.Base(), qt.Equals, "/page.html")
+                       },
+               },
        }
 
        parser := newTestParser()
        for _, test := range tests {
                c.Run(test.name, func(c *qt.C) {
-                       if test.name != "Not lang" {
-                               return
-                       }
                        test.assert(c, parser.Parse(files.ComponentFolderLayouts, test.path))
                })
        }
@@ -642,15 +844,92 @@ func BenchmarkParseIdentity(b *testing.B) {
        }
 }
 
+func BenchmarkParseIdentityPrefixes(b *testing.B) {
+       parser := newTestParser()
+       for b.Loop() {
+               parser.ParseIdentity(files.ComponentFolderLayouts, "/a/b._language_en_._version_v1_._role_admin_._layout_list_._kind_page_.html")
+       }
+}
+
 func TestSitesMatrixFromPath(t *testing.T) {
        c := qt.New(t)
 
        parser := newTestParser()
+
        p := parser.Parse(files.ComponentFolderContent, "/a/b/c.fr.md")
        v := parser.SitesMatrixFromPath(p)
        c.Assert(v.HasLanguage(2), qt.IsTrue)
        c.Assert(v.LenVectors(), qt.Equals, 1)
        c.Assert(v.VectorSample(), qt.Equals, sitesmatrix.Vector{2, 0, 0})
+
+       // With version prefix.
+       p = parser.Parse(files.ComponentFolderContent, "/a/b/c._version_v2_.fr.md")
+       v = parser.SitesMatrixFromPath(p)
+       c.Assert(v.HasLanguage(2), qt.IsTrue)
+       c.Assert(v.HasVersion(1), qt.IsTrue) // v2 is index 1
+       c.Assert(v.LenVectors(), qt.Equals, 1)
+       c.Assert(v.VectorSample(), qt.Equals, sitesmatrix.Vector{2, 1, 0})
+
+       // With version and role prefixes.
+       p = parser.Parse(files.ComponentFolderContent, "/a/b/c._version_v1_._role_editor_.fr.md")
+       v = parser.SitesMatrixFromPath(p)
+       c.Assert(v.HasLanguage(2), qt.IsTrue)
+       c.Assert(v.HasVersion(0), qt.IsTrue) // v1 is index 0
+       c.Assert(v.HasRole(1), qt.IsTrue)    // editor is index 1
+       c.Assert(v.LenVectors(), qt.Equals, 1)
+       c.Assert(v.VectorSample(), qt.Equals, sitesmatrix.Vector{2, 0, 1})
+
+       // With multiple roles.
+       p = parser.Parse(files.ComponentFolderContent, "/a/b/c._role_guest_._role_admin_.fr.md")
+       v = parser.SitesMatrixFromPath(p)
+       c.Assert(v.HasLanguage(2), qt.IsTrue)
+       c.Assert(v.HasRole(0), qt.IsTrue) // admin is index 0
+       c.Assert(v.HasRole(3), qt.IsTrue) // guest is index 3
+       c.Assert(v.LenVectors(), qt.Equals, 2)
+
+       // With multiple languages via prefix.
+       p = parser.Parse(files.ComponentFolderContent, "/a/b/c._language_en_._language_fr_.md")
+       v = parser.SitesMatrixFromPath(p)
+       c.Assert(v.HasLanguage(0), qt.IsFalse) // no is index 0, not included
+       c.Assert(v.HasLanguage(1), qt.IsTrue)  // en is index 1
+       c.Assert(v.HasLanguage(2), qt.IsTrue)  // fr is index 2
+       c.Assert(v.LenVectors(), qt.Equals, 2)
+}
+
+func FuzzParsePath(f *testing.F) {
+       componentPaths := []struct {
+               component string
+               path      string
+       }{
+               {files.ComponentFolderContent, "/a/b/c.fr.md"},
+               {files.ComponentFolderContent, "/a/b/c._version_v2_.fr.md"},
+               {files.ComponentFolderContent, "/a/b/c._version_v1_._role_editor_.fr.md"},
+               {files.ComponentFolderContent, "/a/b/c._role_guest_._role_admin_.fr.md"},
+               {files.ComponentFolderContent, "/a/b/c._language_en_._language_fr_.md"},
+               {files.ComponentFolderLayouts, "/list.no.html"},
+               {files.ComponentFolderLayouts, "/page._language_fr_._kind_section_._outputformat_amp_._layout_list_.html"},
+       }
+
+       for _, cp := range componentPaths {
+               f.Add(cp.component, cp.path)
+       }
+
+       parser := newTestParser()
+
+       f.Fuzz(func(t *testing.T, c, s string) {
+               p := parser.Parse(c, s)
+               if p == nil {
+                       t.Fatalf("Parse returned nil for path: %q", s)
+               }
+               // Execute all the methods using reflection to ensure they don't panic.
+               v := reflect.ValueOf(p)
+               for i := 0; i < v.NumMethod(); i++ {
+                       method := v.Type().Method(i)
+                       if method.Type.NumIn() == 1 {
+                               method.Func.Call([]reflect.Value{v})
+                       }
+               }
+       })
 }
 
 func BenchmarkSitesMatrixFromPath(b *testing.B) {
index 3f3f786f9f5b0bc95a7c0fc1e50e8d1b1d827a04..2cbd7343d43f3cc173cbe471b4d0c3e4981ec035 100644 (file)
@@ -79,6 +79,72 @@ disablePathToLower = true
        b.AssertFileContent("public/fr/MySection/MyBundle/index.html", "fr|Single")
 }
 
+func TestPrefixIdentifiersContent(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term"]
+defaultContentLanguage = "en"
+defaultContentLanguageInSubdir = true
+[languages]
+[languages.en]
+weight = 1
+[languages.fr]
+weight = 2
+-- content/p1.md --
+---
+title: p1 default
+---
+-- content/p1._language_fr_.md --
+---
+title: p1 french
+---
+-- layouts/single.html --
+{{ .Language.Lang }}|{{ .Title }}|Single.
+-- layouts/list.html --
+List
+`
+       b := hugolib.Test(t, files)
+
+       b.AssertFileContent("public/en/p1/index.html", "en|p1 default|Single")
+       b.AssertFileContent("public/fr/p1/index.html", "fr|p1 french|Single")
+}
+
+func TestPrefixIdentifiersLayouts(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term"]
+defaultContentLanguage = "en"
+defaultContentLanguageInSubdir = true
+[languages]
+[languages.en]
+weight = 1
+[languages.fr]
+weight = 2
+-- content/p1.md --
+---
+title: p1
+---
+-- content/p1.fr.md --
+---
+title: p1 fr
+---
+-- layouts/single.html --
+default|{{ .Title }}|
+-- layouts/single._language_fr_.html --
+french layout|{{ .Title }}|
+-- layouts/list.html --
+List
+`
+       b := hugolib.Test(t, files)
+
+       b.AssertFileContent("public/en/p1/index.html", "default|p1|")
+       b.AssertFileContent("public/fr/p1/index.html", "french layout|p1 fr|")
+}
+
 func TestIssue13596(t *testing.T) {
        t.Parallel()
 
diff --git a/common/paths/testdata/fuzz/FuzzParsePathContent/62fadd5d4f836c18 b/common/paths/testdata/fuzz/FuzzParsePathContent/62fadd5d4f836c18
new file mode 100644 (file)
index 0000000..037bf67
--- /dev/null
@@ -0,0 +1,2 @@
+go test fuzz v1
+string("fr..")
index 4f617df6044cf2a13f27f2e4b9b6a0fd1ebdb2b5..174dd6ea942289382629c2bf923d2a2003e7034a 100644 (file)
@@ -812,6 +812,32 @@ func (s *IntSetsBuilder) WithLanguageIndices(idxs ...int) *IntSetsBuilder {
        return s
 }
 
+func (s *IntSetsBuilder) WithVersionIndices(idxs ...int) *IntSetsBuilder {
+       if len(idxs) == 0 {
+               return s
+       }
+       if s.s.versions == nil {
+               s.s.versions = hmaps.NewOrderedIntSet()
+       }
+       for _, i := range idxs {
+               s.s.versions.Set(i)
+       }
+       return s
+}
+
+func (s *IntSetsBuilder) WithRoleIndices(idxs ...int) *IntSetsBuilder {
+       if len(idxs) == 0 {
+               return s
+       }
+       if s.s.roles == nil {
+               s.s.roles = hmaps.NewOrderedIntSet()
+       }
+       for _, i := range idxs {
+               s.s.roles.Set(i)
+       }
+       return s
+}
+
 func (s *IntSetsBuilder) WithDefaultsAndAllLanguagesIfNotSet() *IntSetsBuilder {
        s.s.setDefaultsAndAllLAnguagesIfNotSet(s.cfg)
        return s