shithub: hugo

Download patch

ref: 07978e4a4922bc21c230fee65052232b829bd1ab
parent: 4f335f0c7f83daa32906e8e40c7ac225efa113de
author: Phil Pennock <[email protected]>
date: Sun Nov 17 23:35:56 EST 2013

configurable permalinks support

A sample config.yaml for a site might contain:

```yaml
permalinks:
  post: /:year/:month/:title/
```

Then, any article in the `post` section, will have the canonical URL
formed via the permalink specification given.

Signed-off-by: Noah Campbell <[email protected]>

--- a/hugolib/config.go
+++ b/hugolib/config.go
@@ -34,6 +34,7 @@
 	Indexes                                    map[string]string // singular, plural
 	ProcessFilters                             map[string][]string
 	Params                                     map[string]interface{}
+	Permalinks                                 PermalinkOverrides
 	BuildDrafts, UglyUrls, Verbose             bool
 }
 
@@ -68,6 +69,11 @@
 		c.Indexes = make(map[string]string)
 		c.Indexes["tag"] = "tags"
 		c.Indexes["category"] = "categories"
+	}
+
+	// ensure map exists, albeit empty
+	if c.Permalinks == nil {
+		c.Permalinks = make(PermalinkOverrides, 0)
 	}
 
 	if !strings.HasSuffix(c.BaseUrl, "/") {
--- a/hugolib/page.go
+++ b/hugolib/page.go
@@ -251,23 +251,35 @@
 	pSlug := strings.TrimSpace(p.Slug)
 	pUrl := strings.TrimSpace(p.Url)
 	var permalink string
-	if len(pSlug) > 0 {
-		if p.Site.Config != nil && p.Site.Config.UglyUrls {
-			permalink = path.Join(dir, p.Slug, p.Extension)
-		} else {
-			permalink = dir + "/" + p.Slug + "/"
+	var err error
+
+	if override, ok := p.Site.Permalinks[p.Section]; ok {
+		permalink, err = override.Expand(p)
+		if err != nil {
+			return nil, err
 		}
-	} else if len(pUrl) > 2 {
-		permalink = pUrl
+		//fmt.Printf("have an override for %q in section %s → %s\n", p.Title, p.Section, permalink)
 	} else {
-		_, t := path.Split(p.FileName)
-		if p.Site.Config != nil && p.Site.Config.UglyUrls {
-			x := replaceExtension(strings.TrimSpace(t), p.Extension)
-			permalink = path.Join(dir, x)
+
+		if len(pSlug) > 0 {
+			if p.Site.Config != nil && p.Site.Config.UglyUrls {
+				permalink = path.Join(dir, p.Slug, p.Extension)
+			} else {
+				permalink = dir + "/" + p.Slug + "/"
+			}
+		} else if len(pUrl) > 2 {
+			permalink = pUrl
 		} else {
-			file, _ := fileExt(strings.TrimSpace(t))
-			permalink = path.Join(dir, file)
+			_, t := path.Split(p.FileName)
+			if p.Site.Config != nil && p.Site.Config.UglyUrls {
+				x := replaceExtension(strings.TrimSpace(t), p.Extension)
+				permalink = path.Join(dir, x)
+			} else {
+				file, _ := fileExt(strings.TrimSpace(t))
+				permalink = path.Join(dir, file)
+			}
 		}
+
 	}
 
 	base, err := url.Parse(baseUrl)
@@ -553,6 +565,18 @@
 			outfile = outfile + "index.html"
 		}
 		return
+	}
+
+	// If there's a Permalink specification, we use that
+	if override, ok := p.Site.Permalinks[p.Section]; ok {
+		var err error
+		outfile, err = override.Expand(p)
+		if err == nil {
+			if strings.HasSuffix(outfile, "/") {
+				outfile += "index.html"
+			}
+			return
+		}
 	}
 
 	if len(strings.TrimSpace(p.Slug)) > 0 {
--- /dev/null
+++ b/hugolib/permalinks.go
@@ -1,0 +1,149 @@
+package hugolib
+
+import (
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+
+	helper "github.com/spf13/hugo/template"
+)
+
+// PathPattern represents a string which builds up a URL from attributes
+type PathPattern string
+
+// 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)
+
+// PermalinkOverrides maps a section name to a PathPattern
+type PermalinkOverrides map[string]PathPattern
+
+// 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.
+var knownPermalinkAttributes map[string]PageToPermaAttribute
+
+// validate determines if a PathPattern is well-formed
+func (pp PathPattern) validate() bool {
+	if pp[0] != '/' {
+		return false
+	}
+	fragments := strings.Split(string(pp[1:]), "/")
+	var bail = false
+	for i := range fragments {
+		if bail {
+			return false
+		}
+		if len(fragments[i]) == 0 {
+			bail = true
+			continue
+		}
+		if !strings.HasPrefix(fragments[i], ":") {
+			continue
+		}
+		k := strings.ToLower(fragments[i][1:])
+		if _, ok := knownPermalinkAttributes[k]; !ok {
+			return false
+		}
+	}
+	return true
+}
+
+type permalinkExpandError struct {
+	pattern PathPattern
+	section string
+	err     error
+}
+
+func (pee *permalinkExpandError) Error() string {
+	return fmt.Sprintf("error expanding %q section %q: %s", string(pee.pattern), pee.section, pee.err)
+}
+
+var (
+	errPermalinkIllFormed        = errors.New("permalink ill-formed")
+	errPermalinkAttributeUnknown = errors.New("permalink attribute not recognised")
+)
+
+// Expand on a PathPattern takes a Page and returns the fully expanded Permalink
+// or an error explaining the failure.
+func (pp PathPattern) Expand(p *Page) (string, error) {
+	if !pp.validate() {
+		return "", &permalinkExpandError{pattern: pp, section: "<all>", err: errPermalinkIllFormed}
+	}
+	sections := strings.Split(string(pp), "/")
+	for i, field := range sections {
+		if len(field) == 0 || field[0] != ':' {
+			continue
+		}
+		attr := field[1:]
+		callback, ok := knownPermalinkAttributes[attr]
+		if !ok {
+			return "", &permalinkExpandError{pattern: pp, section: strconv.Itoa(i), err: errPermalinkAttributeUnknown}
+		}
+		newField, err := callback(p, attr)
+		if err != nil {
+			return "", &permalinkExpandError{pattern: pp, section: strconv.Itoa(i), err: err}
+		}
+		sections[i] = newField
+	}
+	return strings.Join(sections, "/"), nil
+}
+
+func pageToPermalinkDate(p *Page, dateField string) (string, error) {
+	// a Page contains a Node which provides a field Date, time.Time
+	switch dateField {
+	case "year":
+		return strconv.Itoa(p.Date.Year()), nil
+	case "month":
+		return fmt.Sprintf("%02d", int(p.Date.Month())), nil
+	case "monthname":
+		return p.Date.Month().String(), nil
+	case "day":
+		return fmt.Sprintf("%02d", int(p.Date.Day())), nil
+	case "weekday":
+		return strconv.Itoa(int(p.Date.Weekday())), nil
+	case "weekdayname":
+		return p.Date.Weekday().String(), nil
+	case "yearday":
+		return strconv.Itoa(p.Date.YearDay()), nil
+	}
+	//TODO: support classic strftime escapes too
+	// (and pass those through despite not being in the map)
+	panic("coding error: should not be here")
+}
+
+// pageToPermalinkTitle returns the URL-safe form of the title
+func pageToPermalinkTitle(p *Page, _ string) (string, error) {
+	// Page contains Node which has Title
+	// (also contains UrlPath which has Slug, sometimes)
+	return helper.Urlize(p.Title), nil
+}
+
+// if the page has a slug, return the slug, else return the title
+func pageToPermalinkSlugElseTitle(p *Page, a string) (string, error) {
+	if p.Slug != "" {
+		return p.Slug, nil
+	}
+	return pageToPermalinkTitle(p, a)
+}
+
+func pageToPermalinkSection(p *Page, _ string) (string, error) {
+	// Page contains Node contains UrlPath which has Section
+	return p.Section, nil
+}
+
+func init() {
+	knownPermalinkAttributes = map[string]PageToPermaAttribute{
+		"year":        pageToPermalinkDate,
+		"month":       pageToPermalinkDate,
+		"monthname":   pageToPermalinkDate,
+		"day":         pageToPermalinkDate,
+		"weekday":     pageToPermalinkDate,
+		"weekdayname": pageToPermalinkDate,
+		"yearday":     pageToPermalinkDate,
+		"section":     pageToPermalinkSection,
+		"title":       pageToPermalinkTitle,
+		"slug":        pageToPermalinkSlugElseTitle,
+	}
+}
--- /dev/null
+++ b/hugolib/permalinks_test.go
@@ -1,0 +1,75 @@
+package hugolib
+
+import (
+	"strings"
+	"testing"
+)
+
+// testdataPermalinks is used by a couple of tests; the expandsTo content is
+// subject to the data in SIMPLE_PAGE_JSON.
+var testdataPermalinks = []struct {
+	spec      string
+	valid     bool
+	expandsTo string
+}{
+	{"/:year/:month/:title/", true, "/2012/04/spf13-vim-3.0-release-and-new-website/"},
+	{"/:title", true, "/spf13-vim-3.0-release-and-new-website"},
+	{":title", false, ""},
+	{"/blog/:year/:yearday/:title", true, "/blog/2012/97/spf13-vim-3.0-release-and-new-website"},
+	{":fred", false, ""},
+	{"/blog/:fred", false, ""},
+	{"/:year//:title", false, ""},
+	{
+		"/:section/:year/:month/:day/:weekdayname/:yearday/:title",
+		true,
+		"/blue/2012/04/06/Friday/97/spf13-vim-3.0-release-and-new-website",
+	},
+	{
+		"/:weekday/:weekdayname/:month/:monthname",
+		true,
+		"/5/Friday/04/April",
+	},
+	{
+		"/:slug/:title",
+		true,
+		"/spf13-vim-3-0-release-and-new-website/spf13-vim-3.0-release-and-new-website",
+	},
+}
+
+func TestPermalinkValidation(t *testing.T) {
+	for _, item := range testdataPermalinks {
+		pp := PathPattern(item.spec)
+		have := pp.validate()
+		if have == item.valid {
+			continue
+		}
+		var howBad string
+		if have {
+			howBad = "validates but should not have"
+		} else {
+			howBad = "should have validated but did not"
+		}
+		t.Errorf("permlink spec %q %s", item.spec, howBad)
+	}
+}
+
+func TestPermalinkExpansion(t *testing.T) {
+	page, err := ReadFrom(strings.NewReader(SIMPLE_PAGE_JSON), "blue/test-page.md")
+	if err != nil {
+		t.Fatalf("failed before we began, could not parse SIMPLE_PAGE_JSON: %s", err)
+	}
+	for _, item := range testdataPermalinks {
+		if !item.valid {
+			continue
+		}
+		pp := PathPattern(item.spec)
+		result, err := pp.Expand(page)
+		if err != nil {
+			t.Errorf("failed to expand page: %s", err)
+			continue
+		}
+		if result != item.expandsTo {
+			t.Errorf("expansion mismatch!\n\tExpected: %q\n\tReceived: %q", item.expandsTo, result)
+		}
+	}
+}
--- a/hugolib/site.go
+++ b/hugolib/site.go
@@ -46,14 +46,14 @@
 //
 // 2. Pages contain sections (based on the file they were generated from),
 //    aliases and slugs (included in a pages frontmatter) which are the
-//		various targets that will get generated.  There will be canonical
-//		listing.
+//    various targets that will get generated.  There will be canonical
+//    listing.  The canonical path can be overruled based on a pattern.
 //
 // 3. Indexes are created via configuration and will present some aspect of
 //    the final page and typically a perm url.
 //
 // 4. All Pages are passed through a template based on their desired
-// 		layout based on numerous different elements.
+//    layout based on numerous different elements.
 //
 // 5. The entire collection of files is written to disk.
 type Site struct {
@@ -80,6 +80,7 @@
 	LastChange time.Time
 	Title      string
 	Config     *Config
+	Permalinks PermalinkOverrides
 	Params     map[string]interface{}
 }
 
@@ -220,11 +221,12 @@
 
 func (s *Site) initializeSiteInfo() {
 	s.Info = SiteInfo{
-		BaseUrl: template.URL(s.Config.BaseUrl),
-		Title:   s.Config.Title,
-		Recent:  &s.Pages,
-		Config:  &s.Config,
-		Params:  s.Config.Params,
+		BaseUrl:    template.URL(s.Config.BaseUrl),
+		Title:      s.Config.Title,
+		Recent:     &s.Pages,
+		Config:     &s.Config,
+		Params:     s.Config.Params,
+		Permalinks: s.Config.Permalinks,
 	}
 }