From: Bjørn Erik Pedersen Date: Wed, 15 Apr 2026 21:38:25 +0000 (+0200) Subject: Add slice-based permalinks config with PageMatcher target X-Git-Url: http://git.maquefel.me/?a=commitdiff_plain;h=017a7cd63a2bd794430833cb46f9dbc367151cb5;p=brevno-suite%2Fhugo Add slice-based permalinks config with PageMatcher target Closes #14744 Clses #4641 Co-Authored-By: Claude Opus 4.6 (1M context) --- diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go index e7598236a..75613e0de 100644 --- a/config/allconfig/allconfig.go +++ b/config/allconfig/allconfig.go @@ -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. diff --git a/config/allconfig/alldecoders.go b/config/allconfig/alldecoders.go index 835ec19d1..dca8f14f9 100644 --- a/config/allconfig/alldecoders.go +++ b/config/allconfig/alldecoders.go @@ -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", diff --git a/hugolib/page__paths.go b/hugolib/page__paths.go index b669127fa..46a3a992a 100644 --- a/hugolib/page__paths.go +++ b/hugolib/page__paths.go @@ -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 } diff --git a/resources/page/page.go b/resources/page/page.go index cc7b96448..f764e7d13 100644 --- a/resources/page/page.go +++ b/resources/page/page.go @@ -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 diff --git a/resources/page/page_matcher.go b/resources/page/page_matcher.go index 0216a7412..bbed64482 100644 --- a/resources/page/page_matcher.go +++ b/resources/page/page_matcher.go @@ -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 diff --git a/resources/page/page_matcher_test.go b/resources/page/page_matcher_test.go index e6e522513..241202d45 100644 --- a/resources/page/page_matcher_test.go +++ b/resources/page/page_matcher_test.go @@ -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) { diff --git a/resources/page/permalinks.go b/resources/page/permalinks.go index adaa9faee..c3a0de35c 100644 --- a/resources/page/permalinks.go +++ b/resources/page/permalinks.go @@ -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 } diff --git a/resources/page/permalinks_integration_test.go b/resources/page/permalinks_integration_test.go index 7aabb8ee6..cdca2bc9c 100644 --- a/resources/page/permalinks_integration_test.go +++ b/resources/page/permalinks_integration_test.go @@ -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/|") +} diff --git a/resources/page/permalinks_test.go b/resources/page/permalinks_test.go index 9b3c315ed..a5ef79852 100644 --- a/resources/page/permalinks_test.go +++ b/resources/page/permalinks_test.go @@ -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) }