configurable permalinks support
authorPhil Pennock <pdp@spodhuis.org>
Mon, 18 Nov 2013 09:35:56 +0000 (04:35 -0500)
committerNoah Campbell <noahcampbell@gmail.com>
Mon, 18 Nov 2013 21:32:56 +0000 (13:32 -0800)
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 <noahcampbell@gmail.com>
hugolib/config.go
hugolib/page.go
hugolib/permalinks.go [new file with mode: 0644]
hugolib/permalinks_test.go [new file with mode: 0644]
hugolib/site.go

index b9b5d54bdaef88c7fb3ca270ac8a23e24db240b3..9a737d7b7c348af4bd2dfd9eab1f19d8f0cbe911 100644 (file)
@@ -34,6 +34,7 @@ type Config struct {
        Indexes                                    map[string]string // singular, plural
        ProcessFilters                             map[string][]string
        Params                                     map[string]interface{}
+       Permalinks                                 PermalinkOverrides
        BuildDrafts, UglyUrls, Verbose             bool
 }
 
@@ -70,6 +71,11 @@ func SetupConfig(cfgfile *string, path *string) *Config {
                c.Indexes["category"] = "categories"
        }
 
+       // ensure map exists, albeit empty
+       if c.Permalinks == nil {
+               c.Permalinks = make(PermalinkOverrides, 0)
+       }
+
        if !strings.HasSuffix(c.BaseUrl, "/") {
                c.BaseUrl = c.BaseUrl + "/"
        }
index f0ec4063eb94ccde20832f2a33db1be429ababa0..f9af8244877f6004b729d354c1e0856519751a2c 100644 (file)
@@ -251,23 +251,35 @@ func (p *Page) permalink() (*url.URL, error) {
        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)
@@ -555,6 +567,18 @@ func (p *Page) TargetPath() (outfile string) {
                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 {
                outfile = strings.TrimSpace(p.Slug) + "." + p.Extension
        } else {
diff --git a/hugolib/permalinks.go b/hugolib/permalinks.go
new file mode 100644 (file)
index 0000000..41e797e
--- /dev/null
@@ -0,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,
+       }
+}
diff --git a/hugolib/permalinks_test.go b/hugolib/permalinks_test.go
new file mode 100644 (file)
index 0000000..019b23c
--- /dev/null
@@ -0,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)
+               }
+       }
+}
index b1e1113dd25547a7ac9405a98b5ad7276f5c72f6..128b239620b12bb48330b62f1088a5de3e3eb48d 100644 (file)
@@ -46,14 +46,14 @@ func MakePermalink(base *url.URL, path *url.URL) *url.URL {
 //
 // 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 @@ type SiteInfo struct {
        LastChange time.Time
        Title      string
        Config     *Config
+       Permalinks PermalinkOverrides
        Params     map[string]interface{}
 }
 
@@ -220,11 +221,12 @@ func (s *Site) initialize() (err error) {
 
 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,
        }
 }