hugo

Unnamed repository; edit this file 'description' to name the repository.

git clone git://git.shimmy1996.com/hugo.git
commit 2dc222cec4460595af8569165d1c498bb45aac84
parent 4d22ad580ec8c8e5e27cf4f5cce69b6828aa8501
Author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Date:   Tue, 30 Mar 2021 07:55:24 +0200

Add slice syntax to sections permalinks config

Fixes #8363

Diffstat:
Mdocs/content/en/content-management/urls.md | 2+-
Mresources/page/permalinks.go | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mresources/page/permalinks_test.go | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mresources/page/testhelpers_test.go | 13++++++++++---
4 files changed, 167 insertions(+), 17 deletions(-)
diff --git 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
@@ -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
@@ -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, "")
+
+		c.Run(name, func(c *qt.C) {
+
+			permalinksConfig := map[string]string{
+				"posts": item.spec,
+			}
 
-		ps := newTestPathSpec()
-		ps.Cfg.Set("permalinks", permalinksConfig)
+			ps := newTestPathSpec()
+			ps.Cfg.Set("permalinks", permalinksConfig)
 
-		expander, err := NewPermalinkExpander(ps)
-		c.Assert(err, qt.IsNil)
+			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)
+			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
@@ -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 {