From ce2a156a4e89849244862705395791590d12b50a Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Wed, 15 Apr 2026 16:44:19 +0200 Subject: [PATCH] Add a more flexible filename identifier scheme that also allows setting roles and versions (#14754) Fixes #14750 --- common/paths/pathparser.go | 328 ++++++++++++++++-- common/paths/pathparser_test.go | 287 ++++++++++++++- common/paths/paths_integration_test.go | 66 ++++ .../FuzzParsePathContent/62fadd5d4f836c18 | 2 + hugolib/sitesmatrix/vectorstores.go | 26 ++ 5 files changed, 676 insertions(+), 33 deletions(-) create mode 100644 common/paths/testdata/fuzz/FuzzParsePathContent/62fadd5d4f836c18 diff --git a/common/paths/pathparser.go b/common/paths/pathparser.go index 5d828df14..9904c596c 100644 --- a/common/paths/pathparser.go +++ b/common/paths/pathparser.go @@ -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) } diff --git a/common/paths/pathparser_test.go b/common/paths/pathparser_test.go index 843b286c2..bfff79d9b 100644 --- a/common/paths/pathparser_test.go +++ b/common/paths/pathparser_test.go @@ -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) { diff --git a/common/paths/paths_integration_test.go b/common/paths/paths_integration_test.go index 3f3f786f9..2cbd7343d 100644 --- a/common/paths/paths_integration_test.go +++ b/common/paths/paths_integration_test.go @@ -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 index 000000000..037bf67dc --- /dev/null +++ b/common/paths/testdata/fuzz/FuzzParsePathContent/62fadd5d4f836c18 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("fr..") diff --git a/hugolib/sitesmatrix/vectorstores.go b/hugolib/sitesmatrix/vectorstores.go index 4f617df60..174dd6ea9 100644 --- a/hugolib/sitesmatrix/vectorstores.go +++ b/hugolib/sitesmatrix/vectorstores.go @@ -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 -- 2.39.5