diff --git a/docs/content/en/content-management/urls.md b/docs/content/en/content-management/urls.md index 4389b1dca..4cba09232 100644 --- a/docs/content/en/content-management/urls.md +++ b/docs/content/en/content-management/urls.md @@ -83,7 +83,7 @@ The following is a list of values that can be used in a `permalink` definition i : the content's section `:sections` -: the content's sections hierarchy +: the content's sections hierarchy. {{< new-in "0.83.0" >}} Since Hugo 0.83 you can use a selection of the sections using _slice syntax_: `:sections[1:]` includes all but the first, `:sections[:last]` includes all but the last, `:sections[last]` includes only the last, `:sections[1:2]` includes section 2 and 3. Note that this slice access will not throw any out-of-bounds errors, so you don't have to be exact. `:title` : the content's title diff --git a/resources/page/permalinks.go b/resources/page/permalinks.go index 19ac52172..aaffcdc21 100644 --- a/resources/page/permalinks.go +++ b/resources/page/permalinks.go @@ -16,6 +16,7 @@ package page import ( "fmt" "os" + "path" "path/filepath" "regexp" "strconv" @@ -54,6 +55,13 @@ func (p PermalinkExpander) callback(attr string) (pageToPermaAttribute, bool) { return p.pageToPermalinkDate, true } + if strings.HasPrefix(attr, "sections[") { + fn := p.toSliceFunc(strings.TrimPrefix(attr, "sections")) + return func(p Page, s string) (string, error) { + return path.Join(fn(p.CurrentSection().SectionsEntries())...), nil + }, true + } + return nil, false } @@ -112,6 +120,7 @@ func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Pa for k, pattern := range patterns { k = strings.Trim(k, sectionCutSet) + if !l.validate(pattern) { return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkIllFormed} } @@ -165,7 +174,7 @@ func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Pa // can return a string to go in that position in the page (or an error) type pageToPermaAttribute func(Page, string) (string, error) -var attributeRegexp = regexp.MustCompile(`:\w+`) +var attributeRegexp = regexp.MustCompile(`:\w+(\[.+\])?`) // validate determines if a PathPattern is well-formed func (l PermalinkExpander) validate(pp string) bool { @@ -263,3 +272,90 @@ func (l PermalinkExpander) pageToPermalinkSection(p Page, _ string) (string, err func (l PermalinkExpander) pageToPermalinkSections(p Page, _ string) (string, error) { return p.CurrentSection().SectionsPath(), nil } + +var ( + nilSliceFunc = func(s []string) []string { + return nil + } + allSliceFunc = func(s []string) []string { + return s + } +) + +// toSliceFunc returns a slice func that slices s according to the cut spec. +// The cut spec must be on form [low:high] (one or both can be omitted), +// also allowing single slice indices (e.g. [2]) and the special [last] keyword +// giving the last element of the slice. +// The returned function will be lenient and not panic in out of bounds situation. +// +// The current use case for this is to use parts of the sections path in permalinks. +func (l PermalinkExpander) toSliceFunc(cut string) func(s []string) []string { + cut = strings.ToLower(strings.TrimSpace(cut)) + if cut == "" { + return allSliceFunc + } + + if len(cut) < 3 || (cut[0] != '[' || cut[len(cut)-1] != ']') { + return nilSliceFunc + } + + toNFunc := func(s string, low bool) func(ss []string) int { + if s == "" { + if low { + return func(ss []string) int { + return 0 + } + } else { + return func(ss []string) int { + return len(ss) + } + } + } + + if s == "last" { + return func(ss []string) int { + return len(ss) - 1 + } + } + + n, _ := strconv.Atoi(s) + if n < 0 { + n = 0 + } + return func(ss []string) int { + // Prevent out of bound situations. It would not make + // much sense to panic here. + if n > len(ss) { + return len(ss) + } + return n + } + } + + opsStr := cut[1 : len(cut)-1] + opts := strings.Split(opsStr, ":") + + if !strings.Contains(opsStr, ":") { + toN := toNFunc(opts[0], true) + return func(s []string) []string { + if len(s) == 0 { + return nil + } + v := s[toN(s)] + if v == "" { + return nil + } + return []string{v} + } + } + + toN1, toN2 := toNFunc(opts[0], true), toNFunc(opts[1], false) + + return func(s []string) []string { + if len(s) == 0 { + return nil + } + return s[toN1(s):toN2(s)] + } + +} diff --git a/resources/page/permalinks_test.go b/resources/page/permalinks_test.go index e40e7e0ef..ca106c236 100644 --- a/resources/page/permalinks_test.go +++ b/resources/page/permalinks_test.go @@ -15,6 +15,7 @@ package page import ( "fmt" + "regexp" "sync" "testing" "time" @@ -38,8 +39,8 @@ var testdataPermalinks = []struct { {"/:filename/", true, "/test-page/"}, // Filename {"/:06-:1-:2-:Monday", true, "/12-4-6-Friday"}, // Dates with Go formatting {"/:2006_01_02_15_04_05.000", true, "/2012_04_06_03_01_59.000"}, // Complicated custom date format - // TODO(moorereason): need test scaffolding for this. - //{"/:sections/", false, "/blue/"}, // Sections + {"/:sections/", true, "/a/b/c/"}, // Sections + {"/:sections[last]/", true, "/c/"}, // Sections // Failures {"/blog/:fred", false, ""}, @@ -66,19 +67,25 @@ func TestPermalinkExpansion(t *testing.T) { continue } - permalinksConfig := map[string]string{ - "posts": item.spec, - } + specNameCleaner := regexp.MustCompile(`[\:\/\[\]]`) + name := specNameCleaner.ReplaceAllString(item.spec, "") - ps := newTestPathSpec() - ps.Cfg.Set("permalinks", permalinksConfig) + c.Run(name, func(c *qt.C) { - expander, err := NewPermalinkExpander(ps) - c.Assert(err, qt.IsNil) + permalinksConfig := map[string]string{ + "posts": item.spec, + } - expanded, err := expander.Expand("posts", page) - c.Assert(err, qt.IsNil) - c.Assert(expanded, qt.Equals, item.expandsTo) + ps := newTestPathSpec() + ps.Cfg.Set("permalinks", permalinksConfig) + + expander, err := NewPermalinkExpander(ps) + c.Assert(err, qt.IsNil) + + expanded, err := expander.Expand("posts", page) + c.Assert(err, qt.IsNil) + c.Assert(expanded, qt.Equals, item.expandsTo) + }) } } @@ -149,6 +156,46 @@ func TestPermalinkExpansionConcurrent(t *testing.T) { wg.Wait() } +func TestPermalinkExpansionSliceSyntax(t *testing.T) { + t.Parallel() + + c := qt.New(t) + exp, _ := NewPermalinkExpander(newTestPathSpec()) + slice := []string{"a", "b", "c", "d"} + fn := func(s string) []string { + return exp.toSliceFunc(s)(slice) + } + + c.Run("Basic", func(c *qt.C) { + c.Assert(fn("[1:3]"), qt.DeepEquals, []string{"b", "c"}) + c.Assert(fn("[1:]"), qt.DeepEquals, []string{"b", "c", "d"}) + c.Assert(fn("[:2]"), qt.DeepEquals, []string{"a", "b"}) + c.Assert(fn("[0:2]"), qt.DeepEquals, []string{"a", "b"}) + c.Assert(fn("[:]"), qt.DeepEquals, []string{"a", "b", "c", "d"}) + c.Assert(fn(""), qt.DeepEquals, []string{"a", "b", "c", "d"}) + c.Assert(fn("[last]"), qt.DeepEquals, []string{"d"}) + c.Assert(fn("[:last]"), qt.DeepEquals, []string{"a", "b", "c"}) + + }) + + c.Run("Out of bounds", func(c *qt.C) { + c.Assert(fn("[1:5]"), qt.DeepEquals, []string{"b", "c", "d"}) + c.Assert(fn("[-1:5]"), qt.DeepEquals, []string{"a", "b", "c", "d"}) + c.Assert(fn("[5:]"), qt.DeepEquals, []string{}) + c.Assert(fn("[5:]"), qt.DeepEquals, []string{}) + c.Assert(fn("[5:32]"), qt.DeepEquals, []string{}) + c.Assert(exp.toSliceFunc("[:1]")(nil), qt.DeepEquals, []string(nil)) + c.Assert(exp.toSliceFunc("[:1]")([]string{}), qt.DeepEquals, []string(nil)) + + // These all return nil + c.Assert(fn("[]"), qt.IsNil) + c.Assert(fn("[1:}"), qt.IsNil) + c.Assert(fn("foo"), qt.IsNil) + + }) + +} + func BenchmarkPermalinkExpand(b *testing.B) { page := newTestPage() page.title = "Hugo Rocks" diff --git a/resources/page/testhelpers_test.go b/resources/page/testhelpers_test.go index 963848439..187930461 100644 --- a/resources/page/testhelpers_test.go +++ b/resources/page/testhelpers_test.go @@ -16,6 +16,7 @@ package page import ( "fmt" "html/template" + "path" "path/filepath" "time" @@ -61,6 +62,9 @@ func newTestPageWithFile(filename string) *testPage { params: make(map[string]interface{}), data: make(map[string]interface{}), file: file, + currentSection: &testPage{ + sectionEntries: []string{"a", "b", "c"}, + }, } } @@ -112,6 +116,9 @@ type testPage struct { data map[string]interface{} file source.File + + currentSection *testPage + sectionEntries []string } func (p *testPage) Aliases() []string { @@ -151,7 +158,7 @@ func (p *testPage) ContentBaseName() string { } func (p *testPage) CurrentSection() Page { - panic("not implemented") + return p.currentSection } func (p *testPage) Data() interface{} { @@ -502,11 +509,11 @@ func (p *testPage) Sections() Pages { } func (p *testPage) SectionsEntries() []string { - panic("not implemented") + return p.sectionEntries } func (p *testPage) SectionsPath() string { - panic("not implemented") + return path.Join(p.sectionEntries...) } func (p *testPage) Site() Site {