]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Add slice-based permalinks config with PageMatcher target
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Wed, 15 Apr 2026 21:38:25 +0000 (23:38 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Sun, 19 Apr 2026 17:45:45 +0000 (19:45 +0200)
Closes #14744
Clses #4641

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
config/allconfig/allconfig.go
config/allconfig/alldecoders.go
hugolib/page__paths.go
resources/page/page.go
resources/page/page_matcher.go
resources/page/page_matcher_test.go
resources/page/permalinks.go
resources/page/permalinks_integration_test.go
resources/page/permalinks_test.go

index e7598236ab99a9ebc1bbe82fabd9e6b6e16014e5..75613e0de691fa718988a5c5ce53ef773346b359 100644 (file)
@@ -181,7 +181,7 @@ type Config struct {
        Minify minifiers.MinifyConfig `mapstructure:"-"`
 
        // Permalink configuration.
-       Permalinks map[string]map[string]string `mapstructure:"-"`
+       Permalinks page.PermalinksConfig `mapstructure:"-"`
 
        // Taxonomy configuration.
        Taxonomies map[string]string `mapstructure:"-"`
@@ -479,12 +479,13 @@ func (c *Config) CompileConfig(logger loggers.Logger) error {
        }
 
        // Legacy permalink tokens
-       vs := fmt.Sprintf("%v", c.Permalinks)
-       if strings.Contains(vs, ":filename") {
-               hugo.DeprecateWithLogger("the \":filename\" permalink token", "Use \":contentbasename\" instead.", "0.144.0", logger.Logger())
-       }
-       if strings.Contains(vs, ":slugorfilename") {
-               hugo.DeprecateWithLogger("the \":slugorfilename\" permalink token", "Use \":slugorcontentbasename\" instead.", "0.144.0", logger.Logger())
+       for _, pc := range c.Permalinks {
+               if strings.Contains(pc.Pattern, ":filename") {
+                       hugo.DeprecateWithLogger("the \":filename\" permalink token", "Use \":contentbasename\" instead.", "0.144.0", logger.Logger())
+               }
+               if strings.Contains(pc.Pattern, ":slugorfilename") {
+                       hugo.DeprecateWithLogger("the \":slugorfilename\" permalink token", "Use \":slugorcontentbasename\" instead.", "0.144.0", logger.Logger())
+               }
        }
 
        // Legacy render hook values.
index 835ec19d1955ae9dccf20cf8cdebee67f6e02595..dca8f14f9cfdc14438e070e2b91deb2e29d76b1f 100644 (file)
@@ -307,9 +307,10 @@ var allDecoderSetups = map[string]decodeWeight{
                key: "permalinks",
                decode: func(d decodeWeight, p decodeConfig) error {
                        var err error
-                       p.c.Permalinks, err = page.DecodePermalinksConfig(p.p.GetStringMap(d.key))
+                       p.c.Permalinks, err = page.DecodePermalinksConfig(p.p.Get(d.key))
                        return err
                },
+               getInitializer: func(c *Config) configInitializer { return c.Permalinks },
        },
        "sitemap": {
                key: "sitemap",
index b669127fa9a4ee4f09774ee233c43ef5986b86f0..46a3a992a074b7cf002bd2a7a17579a379e229aa 100644 (file)
@@ -187,7 +187,7 @@ func createTargetPathDescriptor(p *pageState) (page.TargetPathDescriptor, error)
                }
        }
 
-       opath, err := d.ResourceSpec.Permalinks.Expand(p.Section(), p)
+       opath, err := d.ResourceSpec.Permalinks.Expand(p)
        if err != nil {
                return desc, err
        }
index cc7b96448ef8e06397040a51f27d4692da8e34fe..f764e7d137d11568b1b1379690fde4b3fe431541 100644 (file)
@@ -553,7 +553,8 @@ type SiteVectorProvider interface {
        SiteVector() sitesmatrix.Vector
 }
 
-// GetSiteVector returns the site vector for a Page.
+// GetSiteVector returns the site vector for a Page,
+// or the zero value if the Page does not implement SiteVectorProvider.
 func GetSiteVector(p Page) sitesmatrix.Vector {
        if sp, ok := p.(SiteVectorProvider); ok {
                return sp.SiteVector()
@@ -561,6 +562,14 @@ func GetSiteVector(p Page) sitesmatrix.Vector {
        return sitesmatrix.Vector{}
 }
 
+// LookupSiteVector returns the site vector for a Page and whether it was found.
+func LookupSiteVector(p Page) (sitesmatrix.Vector, bool) {
+       if sp, ok := p.(SiteVectorProvider); ok {
+               return sp.SiteVector(), true
+       }
+       return sitesmatrix.Vector{}, false
+}
+
 // PageWithContext is a Page with a context.Context.
 type PageWithContext struct {
        Page
index 0216a741282e199f895e3779fa23d62a79655454..bbed6448235f659030f6f4e76f26f55c5a2abbca 100644 (file)
@@ -20,6 +20,7 @@ import (
        "slices"
        "strings"
 
+       "github.com/gobwas/glob"
        "github.com/gohugoio/hugo/common/hashing"
        "github.com/gohugoio/hugo/common/hmaps"
        "github.com/gohugoio/hugo/common/hstrings"
@@ -58,6 +59,18 @@ type PageMatcher struct {
        // Compiled values.
        // The site vectors to apply this to.
        SitesMatrixCompiled sitesmatrix.VectorProvider `mapstructure:"-"`
+       kindGlob            glob.Glob
+       pathGlob            glob.Glob
+       environmentGlob     glob.Glob
+}
+
+// Equal compares the configured fields; compiled state is ignored.
+func (m PageMatcher) Equal(other PageMatcher) bool {
+       return m.Path == other.Path &&
+               m.Kind == other.Kind &&
+               m.Lang == other.Lang &&
+               m.Environment == other.Environment &&
+               m.Sites.Equal(other.Sites)
 }
 
 func (m PageMatcher) Matches(p Page) bool {
@@ -70,30 +83,23 @@ func (m PageMatcher) Match(kind, path, environment string, sitesMatrix sitesmatr
                        return false
                }
        }
-       if m.Kind != "" {
-               g, err := hglob.GetGlob(m.Kind)
-               if err == nil && !g.Match(kind) {
-                       return false
-               }
+       if m.kindGlob != nil && !m.kindGlob.Match(kind) {
+               return false
        }
 
-       if m.Path != "" {
-               g, err := hglob.GetGlob(m.Path)
+       if m.pathGlob != nil {
                // TODO(bep) Path() vs filepath vs leading slash.
                p := strings.ToLower(filepath.ToSlash(path))
-               if !(strings.HasPrefix(p, "/")) {
+               if !strings.HasPrefix(p, "/") {
                        p = "/" + p
                }
-               if err == nil && !g.Match(p) {
+               if !m.pathGlob.Match(p) {
                        return false
                }
        }
 
-       if m.Environment != "" {
-               g, err := hglob.GetGlob(m.Environment)
-               if err == nil && !g.Match(environment) {
-                       return false
-               }
+       if m.environmentGlob != nil && !m.environmentGlob.Match(environment) {
+               return false
        }
 
        return true
@@ -117,7 +123,7 @@ func isGlobWithExtension(s string) bool {
 
 func checkCascadePattern(logger loggers.Logger, m PageMatcher) {
        if m.Lang != "" {
-               hugo.Deprecate("cascade.target.language", "cascade.target.sites.matrix instead, see https://gohugo.io/content-management/front-matter/#target", "v0.150.0")
+               hugo.DeprecateWithLogger("cascade.target.language", "cascade.target.sites.matrix instead, see https://gohugo.io/content-management/front-matter/#target", "v0.150.0", logger.Logger())
        }
 }
 
@@ -249,8 +255,35 @@ func (d cascadeConfigDecoder) decodePageMatcher(m any, v *PageMatcher) error {
        return nil
 }
 
+func (v *PageMatcher) compileGlobs() error {
+       var err error
+       if v.Kind != "" {
+               v.kindGlob, err = hglob.GetGlob(v.Kind)
+               if err != nil {
+                       return err
+               }
+       }
+       if v.Path != "" {
+               v.pathGlob, err = hglob.GetGlob(v.Path)
+               if err != nil {
+                       return err
+               }
+       }
+       if v.Environment != "" {
+               v.environmentGlob, err = hglob.GetGlob(v.Environment)
+               if err != nil {
+                       return err
+               }
+       }
+       return nil
+}
+
 // DecodeCascadeConfigOptions
 func (v *PageMatcher) compileSitesMatrix(defaults sitesmatrix.VectorStore, configuredDimensions *sitesmatrix.ConfiguredDimensions) error {
+       if err := v.compileGlobs(); err != nil {
+               return err
+       }
+
        if v.Sites.Matrix.IsZero() && defaults == nil {
                // Nothing to do.
                v.SitesMatrixCompiled = nil
index e6e52251382be541bdd38d8761695c6a5a234bb6..241202d454d2cdfa3848e51ef0a14a45a4b37dbe 100644 (file)
@@ -44,35 +44,33 @@ func TestPageMatcher(t *testing.T) {
                &testPage{path: "p3", kind: "page", lang: "en", site: developmentTestSite}
 
        c.Run("Matches", func(c *qt.C) {
-               m := PageMatcher{Kind: "section"}
-
-               c.Assert(m.Matches(p1), qt.Equals, true)
-               c.Assert(m.Matches(p2), qt.Equals, false)
-
-               m = PageMatcher{Kind: "page"}
-               c.Assert(m.Matches(p1), qt.Equals, false)
-               c.Assert(m.Matches(p2), qt.Equals, true)
-               c.Assert(m.Matches(p3), qt.Equals, true)
-
-               m = PageMatcher{Kind: "page", Path: "/p2"}
-               c.Assert(m.Matches(p1), qt.Equals, false)
-               c.Assert(m.Matches(p2), qt.Equals, true)
-               c.Assert(m.Matches(p3), qt.Equals, false)
-
-               m = PageMatcher{Path: "/p*"}
-               c.Assert(m.Matches(p1), qt.Equals, true)
-               c.Assert(m.Matches(p2), qt.Equals, true)
-               c.Assert(m.Matches(p3), qt.Equals, true)
-
-               m = PageMatcher{Environment: "development"}
-               c.Assert(m.Matches(p1), qt.Equals, true)
-               c.Assert(m.Matches(p2), qt.Equals, false)
-               c.Assert(m.Matches(p3), qt.Equals, true)
-
-               m = PageMatcher{Environment: "production"}
-               c.Assert(m.Matches(p1), qt.Equals, false)
-               c.Assert(m.Matches(p2), qt.Equals, true)
-               c.Assert(m.Matches(p3), qt.Equals, false)
+               matches := func(m PageMatcher, p Page) bool {
+                       c.Assert(m.compileGlobs(), qt.IsNil)
+                       return m.Matches(p)
+               }
+
+               c.Assert(matches(PageMatcher{Kind: "section"}, p1), qt.Equals, true)
+               c.Assert(matches(PageMatcher{Kind: "section"}, p2), qt.Equals, false)
+
+               c.Assert(matches(PageMatcher{Kind: "page"}, p1), qt.Equals, false)
+               c.Assert(matches(PageMatcher{Kind: "page"}, p2), qt.Equals, true)
+               c.Assert(matches(PageMatcher{Kind: "page"}, p3), qt.Equals, true)
+
+               c.Assert(matches(PageMatcher{Kind: "page", Path: "/p2"}, p1), qt.Equals, false)
+               c.Assert(matches(PageMatcher{Kind: "page", Path: "/p2"}, p2), qt.Equals, true)
+               c.Assert(matches(PageMatcher{Kind: "page", Path: "/p2"}, p3), qt.Equals, false)
+
+               c.Assert(matches(PageMatcher{Path: "/p*"}, p1), qt.Equals, true)
+               c.Assert(matches(PageMatcher{Path: "/p*"}, p2), qt.Equals, true)
+               c.Assert(matches(PageMatcher{Path: "/p*"}, p3), qt.Equals, true)
+
+               c.Assert(matches(PageMatcher{Environment: "development"}, p1), qt.Equals, true)
+               c.Assert(matches(PageMatcher{Environment: "development"}, p2), qt.Equals, false)
+               c.Assert(matches(PageMatcher{Environment: "development"}, p3), qt.Equals, true)
+
+               c.Assert(matches(PageMatcher{Environment: "production"}, p1), qt.Equals, false)
+               c.Assert(matches(PageMatcher{Environment: "production"}, p2), qt.Equals, true)
+               c.Assert(matches(PageMatcher{Environment: "production"}, p3), qt.Equals, false)
        })
 
        c.Run("Decode", func(c *qt.C) {
index adaa9faeede99e91c77e9c345d7a0d5f9359a0b7..c3a0de35c3b0c668d47c2c43c251dda67d92479e 100644 (file)
@@ -26,18 +26,41 @@ import (
 
        "github.com/gohugoio/hugo/common/hmaps"
        "github.com/gohugoio/hugo/common/hstrings"
+       "github.com/gohugoio/hugo/common/loggers"
        "github.com/gohugoio/hugo/helpers"
+       "github.com/gohugoio/hugo/hugolib/sitesmatrix"
        "github.com/gohugoio/hugo/resources/kinds"
+       "github.com/mitchellh/mapstructure"
 )
 
-// PermalinkExpander holds permalink mappings per section.
+// PermalinkConfig holds a single permalink rule with a target matcher and a pattern.
+type PermalinkConfig struct {
+       Target  PageMatcher
+       Pattern string
+}
+
+// PermalinksConfig is an ordered slice of permalink rules.
+// For any given Page, the first matching rule wins.
+type PermalinksConfig []PermalinkConfig
+
+// InitConfig compiles the sites matrix for each permalink target.
+func (c PermalinksConfig) InitConfig(logger loggers.Logger, defaultSitesMatrix sitesmatrix.VectorStore, configuredDimensions *sitesmatrix.ConfiguredDimensions) error {
+       for i := range c {
+               if err := c[i].Target.compileSitesMatrix(nil, configuredDimensions); err != nil {
+                       return fmt.Errorf("failed to compile permalink target %d: %w", i, err)
+               }
+       }
+       return nil
+}
+
+// PermalinkExpander holds permalink mappings.
 type PermalinkExpander struct {
        // knownPermalinkAttributes maps :tags in a permalink specification to a
        // function which, given a page and the tag, returns the resulting string
        // to be used to replace that tag.
        knownPermalinkAttributes map[string]pageToPermaAttribute
 
-       expanders map[string]map[string]func(Page) (string, error)
+       configs PermalinksConfig
 
        urlize func(uri string) string
 
@@ -80,9 +103,10 @@ func (p PermalinkExpander) callback(attr string) (pageToPermaAttribute, bool) {
 
 // NewPermalinkExpander creates a new PermalinkExpander configured by the given
 // urlize func.
-func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]map[string]string) (PermalinkExpander, error) {
+func NewPermalinkExpander(urlize func(uri string) string, configs PermalinksConfig) (PermalinkExpander, error) {
        p := PermalinkExpander{
                urlize:       urlize,
+               configs:      configs,
                patternCache: hmaps.NewCache[string, func(Page) (string, error)](),
        }
 
@@ -106,14 +130,15 @@ func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]ma
                "slugorcontentbasename": p.pageToPermalinkSlugOrContentBaseName,
        }
 
-       p.expanders = make(map[string]map[string]func(Page) (string, error))
-
-       for kind, patterns := range patterns {
-               e, err := p.parse(patterns)
-               if err != nil {
+       // Validate all patterns and compile matcher globs at init time.
+       for i := range configs {
+               if _, err := p.getOrParsePattern(configs[i].Pattern); err != nil {
+                       return p, err
+               }
+               if err := configs[i].Target.compileGlobs(); err != nil {
                        return p, err
                }
-               p.expanders[kind] = e
+
        }
 
        return p, nil
@@ -141,20 +166,29 @@ func (l PermalinkExpander) ExpandPattern(pattern string, p Page) (string, error)
        return expand(p)
 }
 
-// Expand expands the path in p according to the rules defined for the given key.
-// If no rules are found for the given key, an empty string is returned.
-func (l PermalinkExpander) Expand(key string, p Page) (string, error) {
-       expanders, found := l.expanders[p.Kind()]
-       if !found {
+// Expand expands the path in p according to the first matching permalink rule.
+// If no rules match, an empty string is returned.
+func (l PermalinkExpander) Expand(p Page) (string, error) {
+       kind := p.Kind()
+       if !hstrings.InSlice(permalinksKindsSupport, kind) {
                return "", nil
        }
-
-       expand, found := expanders[key]
-       if !found {
-               return "", nil
+       var siteVector sitesmatrix.VectorProvider
+       if sv, ok := LookupSiteVector(p); ok {
+               siteVector = sv
        }
-
-       return expand(p)
+       var environment string
+       var environmentResolved bool
+       for _, cfg := range l.configs {
+               if cfg.Target.Environment != "" && !environmentResolved {
+                       environment = p.Site().Hugo().Environment()
+                       environmentResolved = true
+               }
+               if cfg.Target.Match(kind, p.Path(), environment, siteVector) {
+                       return l.ExpandPattern(cfg.Pattern, p)
+               }
+       }
+       return "", nil
 }
 
 // Allow " " and / to represent the root section.
@@ -220,23 +254,6 @@ func (l PermalinkExpander) getOrParsePattern(pattern string) (func(Page) (string
        })
 }
 
-func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Page) (string, error), error) {
-       expanders := make(map[string]func(Page) (string, error))
-
-       for k, pattern := range patterns {
-               k = strings.Trim(k, sectionCutSet)
-
-               expander, err := l.getOrParsePattern(pattern)
-               if err != nil {
-                       return nil, err
-               }
-
-               expanders[k] = expander
-       }
-
-       return expanders, nil
-}
-
 // pageToPermaAttribute is the type of a function which, given a page and a tag
 // can return a string to go in that position in the page (or an error)
 type pageToPermaAttribute func(Page, string) (string, error)
@@ -483,50 +500,102 @@ func (l PermalinkExpander) toSliceFunc(cut string) func(s []string) []string {
        }
 }
 
-var permalinksKindsSupport = []string{kinds.KindPage, kinds.KindSection, kinds.KindTaxonomy, kinds.KindTerm}
+var permalinksKindsSupport = []string{kinds.KindPage, kinds.KindHome, kinds.KindSection, kinds.KindTaxonomy, kinds.KindTerm}
 
-// DecodePermalinksConfig decodes the permalinks configuration in the given map
-func DecodePermalinksConfig(m map[string]any) (map[string]map[string]string, error) {
-       permalinksConfig := make(map[string]map[string]string)
+func sectionToPathGlob(section string) string {
+       section = strings.Trim(section, sectionCutSet)
+       if section == "" {
+               return "/*"
+       }
+       return "/{" + section + "," + section + "/**}"
+}
+
+// DecodePermalinksConfig decodes the permalinks configuration.
+// It supports both the new slice-based format and the legacy map-based formats.
+func DecodePermalinksConfig(in any) (PermalinksConfig, error) {
+       if in == nil {
+               return nil, nil
+       }
 
-       permalinksConfig[kinds.KindPage] = make(map[string]string)
-       permalinksConfig[kinds.KindSection] = make(map[string]string)
-       permalinksConfig[kinds.KindTaxonomy] = make(map[string]string)
-       permalinksConfig[kinds.KindTerm] = make(map[string]string)
+       switch v := in.(type) {
+       // Check legacy formats first.
+       case map[string]any:
+               return decodePermalinksMap(v)
+       case hmaps.Params:
+               return decodePermalinksMap(v)
+       default:
+               if ms, err := hmaps.ToSliceStringMap(in); err == nil {
+                       // New slice format.
+                       return decodePermalinksSlice(ms)
+               }
+               return nil, fmt.Errorf("permalinks: unsupported config type %T", in)
+       }
+}
 
+func decodePermalinksSlice(ms []map[string]any) (PermalinksConfig, error) {
+       var configs PermalinksConfig
+       for _, m := range ms {
+               m = hmaps.CleanConfigStringMap(m)
+               var cfg PermalinkConfig
+
+               if targetVal, ok := m["target"]; ok {
+                       if err := mapstructure.WeakDecode(targetVal, &cfg.Target); err != nil {
+                               return nil, fmt.Errorf("permalinks: failed to decode target: %w", err)
+                       }
+                       cfg.Target.Kind = strings.ToLower(cfg.Target.Kind)
+                       cfg.Target.Path = filepath.ToSlash(strings.ToLower(cfg.Target.Path))
+               }
+
+               if patternVal, ok := m["pattern"]; ok {
+                       cfg.Pattern, ok = patternVal.(string)
+                       if !ok {
+                               return nil, fmt.Errorf("permalinks: pattern must be a string, got %T", patternVal)
+                       }
+               } else {
+                       return nil, fmt.Errorf("permalinks: missing pattern")
+               }
+
+               configs = append(configs, cfg)
+       }
+
+       return configs, nil
+}
+
+func decodePermalinksMap(m map[string]any) (PermalinksConfig, error) {
+       var configs PermalinksConfig
        config := hmaps.CleanConfigStringMap(m)
+
        for k, v := range config {
                switch v := v.(type) {
                case string:
                        // [permalinks]
                        //   key = '...'
-
-                       // To successfully be backward compatible, "default" patterns need to be set for both page and term
-                       permalinksConfig[kinds.KindPage][k] = v
-                       permalinksConfig[kinds.KindTerm][k] = v
+                       // Backward compat: set for both page and term.
+                       configs = append(configs,
+                               PermalinkConfig{Target: PageMatcher{Kind: kinds.KindPage, Path: sectionToPathGlob(k)}, Pattern: v},
+                               PermalinkConfig{Target: PageMatcher{Kind: kinds.KindTerm, Path: sectionToPathGlob(k)}, Pattern: v},
+                       )
 
                case hmaps.Params:
-                       // [permalinks.key]
-                       //   xyz = ???
-
-                       if hstrings.InSlice(permalinksKindsSupport, k) {
-                               // TODO: warn if we overwrite an already set value
-                               for k2, v2 := range v {
-                                       switch v2 := v2.(type) {
-                                       case string:
-                                               permalinksConfig[k][k2] = v2
-
-                                       default:
-                                               return nil, fmt.Errorf("permalinks configuration invalid: unknown value %q for key %q for kind %q", v2, k2, k)
-                                       }
-                               }
-                       } else {
+                       // [permalinks.kind]
+                       //   section = '...'
+                       if !hstrings.InSlice(permalinksKindsSupport, k) {
                                return nil, fmt.Errorf("permalinks configuration not supported for kind %q, supported kinds are %v", k, permalinksKindsSupport)
                        }
+                       for k2, v2 := range v {
+                               switch v2 := v2.(type) {
+                               case string:
+                                       configs = append(configs,
+                                               PermalinkConfig{Target: PageMatcher{Kind: k, Path: sectionToPathGlob(k2)}, Pattern: v2},
+                                       )
+                               default:
+                                       return nil, fmt.Errorf("permalinks configuration invalid: unknown value %q for key %q for kind %q", v2, k2, k)
+                               }
+                       }
 
                default:
                        return nil, fmt.Errorf("permalinks configuration invalid: unknown value %q for key %q", v, k)
                }
        }
-       return permalinksConfig, nil
+       return configs, nil
 }
index 7aabb8ee6d596fb6a4014f109e07af92fde1b77e..cdca2bc9c7be637a42350f8bfd14d6478e556c90 100644 (file)
@@ -135,12 +135,8 @@ slug: "mytagslug"
        b.AssertFileContent("public/tagsslug/tagsslug/index.html", "List|taxonomy|/tagsslug/tagsslug/|")
 
        permalinksConf := b.H.Configs.Base.Permalinks
-       b.Assert(permalinksConf, qt.DeepEquals, map[string]map[string]string{
-               "page":     {"withallbutlastsection": "/:sections[:last]/:slug/", "withallbutlastsectionslug": "/:sectionslugs[:last]/:slug/", "withpageslug": "/pageslug/:slug/", "withsectionslug": "/sectionslug/:sectionslug/:slug/", "withsectionslugs": "/sectionslugs/:sectionslugs/:slug/"},
-               "section":  {"nofilefilename": "/sectionnofilefilename/:contentbasename/", "nofileslug": "/sectionnofileslug/:slug/", "nofiletitle1": "/sectionnofiletitle1/:title/", "nofiletitle2": "/sectionnofiletitle2/:sections[:last]/", "withfilefilename": "/sectionwithfilefilename/:contentbasename/", "withfilefiletitle": "/sectionwithfilefiletitle/:title/", "withfileslug": "/sectionwithfileslug/:slug/"},
-               "taxonomy": {"tags": "/tagsslug/:slug/"},
-               "term":     {"tags": "/tagsslug/tag/:slug/"},
-       })
+       // 5 page + 7 section + 1 taxonomy + 1 term = 14 rules.
+       b.Assert(len(permalinksConf), qt.Equals, 14)
 }
 
 func TestPermalinksOldSetup(t *testing.T) {
@@ -172,12 +168,8 @@ slug: "p1slugvalue"
        b.AssertFileContent("public/pageslug/p1slugvalue/index.html", "Single|page|/pageslug/p1slugvalue/|")
 
        permalinksConf := b.H.Configs.Base.Permalinks
-       b.Assert(permalinksConf, qt.DeepEquals, map[string]map[string]string{
-               "page":     {"withpageslug": "/pageslug/:slug/"},
-               "section":  {},
-               "taxonomy": {},
-               "term":     {"withpageslug": "/pageslug/:slug/"},
-       })
+       // Old flat format: 1 entry creates 2 rules (page + term).
+       b.Assert(len(permalinksConf), qt.Equals, 2)
 }
 
 func TestPermalinksNestedSections(t *testing.T) {
@@ -515,3 +507,266 @@ title: Tag A (set in front matter)
                })
        }
 }
+
+func TestPermalinksNewSliceFormat(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- layouts/list.html --
+List|{{ .Kind }}|{{ .RelPermalink }}|
+-- layouts/single.html --
+Single|{{ .Kind }}|{{ .RelPermalink }}|
+-- hugo.yaml --
+permalinks:
+  - target:
+      kind: page
+      path: "/books/**"
+    pattern: /books/:year/:slug/
+  - target:
+      kind: section
+      path: "/{books,books/**}"
+    pattern: /libros/:sections[1:]
+  - target:
+      kind: page
+    pattern: /other/:slug/
+-- content/books/_index.md --
+---
+title: Books
+---
+-- content/books/fiction/_index.md --
+---
+title: Fiction
+---
+-- content/books/fiction/book1.md --
+---
+title: Book One
+date: 2023-06-15
+slug: book-one
+---
+-- content/other/p1.md --
+---
+title: Other Page
+slug: other-page
+---
+-- content/unmatched/p2.md --
+---
+title: Unmatched Page
+slug: unmatched-page
+---
+`
+
+       b := hugolib.Test(t, files)
+
+       // Page in /books section gets the books-specific pattern.
+       b.AssertFileContent("public/books/2023/book-one/index.html", "Single|page|/books/2023/book-one/|")
+       // Section page for books/fiction gets the section pattern.
+       b.AssertFileContent("public/libros/fiction/index.html", "List|section|/libros/fiction/|")
+       // Page outside /books matches the default page pattern.
+       b.AssertFileContent("public/other/other-page/index.html", "Single|page|/other/other-page/|")
+       // Page in unmatched section also matches the default page pattern.
+       b.AssertFileContent("public/other/unmatched-page/index.html", "Single|page|/other/unmatched-page/|")
+}
+
+func TestPermalinksNewSliceFormatEnvironment(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- layouts/list.html --
+List|{{ .Kind }}|{{ .RelPermalink }}|
+-- layouts/single.html --
+Single|{{ .Kind }}|{{ .RelPermalink }}|
+-- hugo.toml --
+disableKinds = ['home','rss','sitemap','taxonomy','term']
+[[permalinks]]
+pattern = "/testing/:slug/"
+[permalinks.target]
+path = "/books/**"
+environment = "test"
+[[permalinks]]
+pattern = "/prod/:slug/"
+[permalinks.target]
+path = "/books/**"
+environment = "production"
+-- content/books/fiction/book1.md --
+---
+title: Book One
+date: 2023-06-15
+slug: book-one
+---
+
+`
+
+       b := hugolib.Test(t, files)
+
+       b.AssertPublishDir("prod", "! testing")
+}
+
+func TestPermalinksNewSliceFormatSitesMatrix(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- layouts/single.html --
+Single|{{ .Kind }}|{{ .RelPermalink }}|{{ .Language.Lang }}|
+-- hugo.yaml --
+defaultContentLanguage: en
+defaultContentLanguageInSubdir: true
+disableKinds: ['home','rss','section','sitemap','taxonomy','term']
+languages:
+  en:
+    weight: 1
+  de:
+    weight: 2
+permalinks:
+  - target:
+      kind: page
+      sites:
+        matrix:
+          languages: ["en"]
+    pattern: /en-posts/:slug/
+  - target:
+      kind: page
+      sites:
+        matrix:
+          languages: ["de"]
+    pattern: /de-posts/:slug/
+  - target:
+      kind: page
+    pattern: /other/:slug/
+-- content/p1.en.md --
+---
+title: Hello
+slug: hello
+---
+-- content/p1.de.md --
+---
+title: Hallo
+slug: hallo
+---
+`
+
+       b := hugolib.Test(t, files)
+
+       // English page matches the en-specific rule.
+       b.AssertFileContent("public/en/en-posts/hello/index.html", "Single|page|/en/en-posts/hello/|en|")
+       // German page matches the de-specific rule.
+       b.AssertFileContent("public/de/de-posts/hallo/index.html", "Single|page|/de/de-posts/hallo/|de|")
+}
+
+// We only apply permalink patterns to kinds of type hone, page, section, taxonomy and term.
+func TestPermalinksNewSliceFormatAsteriskKind(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+enableRobotsTXT = true
+[[permalinks]]
+pattern = "/mylink/:slug/"
+[permalinks.target]
+kind = "*"
+-- content/mysection/p1.md --
+---
+title: My Page
+slug: my-page
+---
+-- layouts/all.html --
+{{ .Title }}|{{ .RelPermalink }}|{{ .Kind }}|
+-- layouts/404.html --
+404.
+`
+
+       b := hugolib.Test(t, files)
+       b.AssertPublishDir(`
+404.html
+index.html
+index.xml
+mylink/categories/index.html 
+mylink/my-page/index.html
+mylink/mysections/index.html
+mylink/tags/index.html 
+sitemap.xml
+`)
+}
+
+func TestPermalinksHomeKind(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- layouts/home.html --
+Home|{{ .Kind }}|{{ .RelPermalink }}|
+-- layouts/list.html --
+List|{{ .Kind }}|{{ .RelPermalink }}|
+-- hugo.yaml --
+disableKinds: ['rss','sitemap','taxonomy','term']
+permalinks:
+  - target:
+      kind: home
+    pattern: /welcome/
+  - target:
+      kind: section
+    pattern: /s/:slug/
+-- content/mysection/_index.md --
+---
+title: My Section
+slug: my-section
+---
+`
+
+       b := hugolib.Test(t, files, hugolib.TestOptWarn())
+       t.Log(b.LogString())
+       b.Assert(b.H.Log.LoggCount(logg.LevelWarn), qt.Equals, 0)
+       b.AssertFileContent("public/welcome/index.html", "Home|home|/welcome/|")
+       b.AssertFileContent("public/s/my-section/index.html", "List|section|/s/my-section/|")
+}
+
+func TestPermalinksHomeKindLegacyMap(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- layouts/home.html --
+Home|{{ .Kind }}|{{ .RelPermalink }}|
+-- hugo.toml --
+disableKinds = ['rss','sitemap','taxonomy','term']
+[permalinks.home]
+"/" = '/welcome/'
+`
+
+       b := hugolib.Test(t, files, hugolib.TestOptWarn())
+       t.Log(b.LogString())
+       b.Assert(b.H.Log.LoggCount(logg.LevelWarn), qt.Equals, 0)
+       b.AssertFileContent("public/welcome/index.html", "Home|home|/welcome/|")
+}
+
+func TestPermalinksNewSliceFormatRootSection(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- layouts/single.html --
+Single|{{ .Kind }}|{{ .RelPermalink }}|
+-- hugo.yaml --
+permalinks:
+  - target:
+      kind: page
+      path: "/*"
+    pattern: /root/:slug/
+  - target:
+      kind: page
+    pattern: /deep/:slug/
+-- content/p1.md --
+---
+title: Root Page
+slug: root-page
+---
+-- content/sub/p2.md --
+---
+title: Sub Page
+slug: sub-page
+---
+`
+
+       b := hugolib.Test(t, files)
+
+       // Root section page matches /* (non-recursive).
+       b.AssertFileContent("public/root/root-page/index.html", "Single|page|/root/root-page/|")
+       // Nested page matches the default rule.
+       b.AssertFileContent("public/deep/sub-page/index.html", "Single|page|/deep/sub-page/|")
+}
index 9b3c315ed11fdc76e7c8eafb150eafae3130422e..a5ef79852b3c6c368a5d6cd51a5989201332e9c7 100644 (file)
@@ -129,7 +129,7 @@ func TestPermalinkExpansion(t *testing.T) {
                page.section = "blue"
                page.slug = "The Slug"
                page.kind = "page"
-               // page.pathInfo
+               page.path = "/posts/test-page"
                return page
        }
 
@@ -147,14 +147,12 @@ func TestPermalinkExpansion(t *testing.T) {
                name := fmt.Sprintf("[%d] %s", i, specNameCleaner.ReplaceAllString(item.spec, "_"))
 
                c.Run(name, func(c *qt.C) {
-                       patterns := map[string]map[string]string{
-                               "page": {
-                                       "posts": item.spec,
-                               },
+                       configs := PermalinksConfig{
+                               {Target: PageMatcher{Kind: "page", Path: "/posts/**"}, Pattern: item.spec},
                        }
-                       expander, err := NewPermalinkExpander(urlize, patterns)
+                       expander, err := NewPermalinkExpander(urlize, configs)
                        c.Assert(err, qt.IsNil)
-                       expanded, err := expander.Expand("posts", page)
+                       expanded, err := expander.Expand(page)
                        c.Assert(err, qt.IsNil)
                        c.Assert(expanded, qt.Equals, item.expandsTo)
 
@@ -178,39 +176,42 @@ func TestPermalinkExpansionMultiSection(t *testing.T) {
        page.section = "blue"
        page.slug = "The Slug"
        page.kind = "page"
+       page.path = "/posts/my-page"
 
-       page_slug_fallback := newTestPageWithFile("/page-filename/index.md")
-       page_slug_fallback.title = "Page Title"
-       page_slug_fallback.kind = "page"
-
-       permalinksConfig := map[string]map[string]string{
-               "page": {
-                       "posts":   "/:slug",
-                       "blog":    "/:section/:year",
-                       "recipes": "/:slugorfilename",
-                       "special": "/special\\::slug",
-               },
+       pageSlugFallback := newTestPageWithFile("/page-filename/index.md")
+       pageSlugFallback.title = "Page Title"
+       pageSlugFallback.kind = "page"
+
+       configs := PermalinksConfig{
+               {Target: PageMatcher{Kind: "page", Path: "/posts/**"}, Pattern: "/:slug"},
+               {Target: PageMatcher{Kind: "page", Path: "/blog/**"}, Pattern: "/:section/:year"},
+               {Target: PageMatcher{Kind: "page", Path: "/recipes/**"}, Pattern: "/:slugorfilename"},
+               {Target: PageMatcher{Kind: "page", Path: "/special/**"}, Pattern: "/special\\::slug"},
        }
-       expander, err := NewPermalinkExpander(urlize, permalinksConfig)
+       expander, err := NewPermalinkExpander(urlize, configs)
        c.Assert(err, qt.IsNil)
 
-       expanded, err := expander.Expand("posts", page)
+       expanded, err := expander.Expand(page)
        c.Assert(err, qt.IsNil)
        c.Assert(expanded, qt.Equals, "/the-slug")
 
-       expanded, err = expander.Expand("blog", page)
+       page.path = "/blog/my-page"
+       expanded, err = expander.Expand(page)
        c.Assert(err, qt.IsNil)
        c.Assert(expanded, qt.Equals, "/blue/2012")
 
-       expanded, err = expander.Expand("posts", page_slug_fallback)
+       pageSlugFallback.path = "/posts/my-page"
+       expanded, err = expander.Expand(pageSlugFallback)
        c.Assert(err, qt.IsNil)
        c.Assert(expanded, qt.Equals, "/page-title")
 
-       expanded, err = expander.Expand("recipes", page_slug_fallback)
+       pageSlugFallback.path = "/recipes/my-page"
+       expanded, err = expander.Expand(pageSlugFallback)
        c.Assert(err, qt.IsNil)
        c.Assert(expanded, qt.Equals, "/page-filename")
 
-       expanded, err = expander.Expand("special", page)
+       page.path = "/special/my-page"
+       expanded, err = expander.Expand(page)
        c.Assert(err, qt.IsNil)
        c.Assert(expanded, qt.Equals, "/special:the-slug")
 }
@@ -220,13 +221,11 @@ func TestPermalinkExpansionConcurrent(t *testing.T) {
 
        c := qt.New(t)
 
-       permalinksConfig := map[string]map[string]string{
-               "page": {
-                       "posts": "/:slug/",
-               },
+       configs := PermalinksConfig{
+               {Target: PageMatcher{Kind: "page", Path: "/posts/**"}, Pattern: "/:slug/"},
        }
 
-       expander, err := NewPermalinkExpander(urlize, permalinksConfig)
+       expander, err := NewPermalinkExpander(urlize, configs)
        c.Assert(err, qt.IsNil)
 
        var wg sync.WaitGroup
@@ -237,9 +236,10 @@ func TestPermalinkExpansionConcurrent(t *testing.T) {
                        defer wg.Done()
                        page := newTestPage()
                        page.kind = "page"
+                       page.path = "/posts/my-page"
                        for j := 1; j < 20; j++ {
                                page.slug = fmt.Sprintf("slug%d", i+j)
-                               expanded, err := expander.Expand("posts", page)
+                               expanded, err := expander.Expand(page)
                                c.Assert(err, qt.IsNil)
                                c.Assert(expanded, qt.Equals, fmt.Sprintf("/%s/", page.slug))
                        }
@@ -253,7 +253,7 @@ func TestPermalinkExpansionSliceSyntax(t *testing.T) {
        t.Parallel()
 
        c := qt.New(t)
-       exp, err := NewPermalinkExpander(urlize, nil)
+       exp, err := NewPermalinkExpander(urlize, PermalinksConfig{})
        c.Assert(err, qt.IsNil)
        slice4 := []string{"a", "b", "c", "d"}
        fn4 := func(s string) []string {
@@ -302,19 +302,18 @@ func BenchmarkPermalinkExpand(b *testing.B) {
        d, _ := time.Parse("2006-01-02", "2019-02-28")
        page.date = d
        page.kind = "page"
+       page.path = "/posts/my-page"
 
-       permalinksConfig := map[string]map[string]string{
-               "page": {
-                       "posts": "/:year-:month-:title",
-               },
+       configs := PermalinksConfig{
+               {Target: PageMatcher{Kind: "page", Path: "/posts/**"}, Pattern: "/:year-:month-:title"},
        }
-       expander, err := NewPermalinkExpander(urlize, permalinksConfig)
+       expander, err := NewPermalinkExpander(urlize, configs)
        if err != nil {
                b.Fatal(err)
        }
 
        for b.Loop() {
-               s, err := expander.Expand("posts", page)
+               s, err := expander.Expand(page)
                if err != nil {
                        b.Fatal(err)
                }