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 <noahcampbell@gmail.com>
This commit is contained in:
Phil Pennock 2013-11-18 04:35:56 -05:00 committed by Noah Campbell
parent 4f335f0c7f
commit 07978e4a49
5 changed files with 277 additions and 21 deletions

View file

@ -34,6 +34,7 @@ type Config struct {
Indexes map[string]string // singular, plural Indexes map[string]string // singular, plural
ProcessFilters map[string][]string ProcessFilters map[string][]string
Params map[string]interface{} Params map[string]interface{}
Permalinks PermalinkOverrides
BuildDrafts, UglyUrls, Verbose bool BuildDrafts, UglyUrls, Verbose bool
} }
@ -70,6 +71,11 @@ func SetupConfig(cfgfile *string, path *string) *Config {
c.Indexes["category"] = "categories" c.Indexes["category"] = "categories"
} }
// ensure map exists, albeit empty
if c.Permalinks == nil {
c.Permalinks = make(PermalinkOverrides, 0)
}
if !strings.HasSuffix(c.BaseUrl, "/") { if !strings.HasSuffix(c.BaseUrl, "/") {
c.BaseUrl = c.BaseUrl + "/" c.BaseUrl = c.BaseUrl + "/"
} }

View file

@ -251,23 +251,35 @@ func (p *Page) permalink() (*url.URL, error) {
pSlug := strings.TrimSpace(p.Slug) pSlug := strings.TrimSpace(p.Slug)
pUrl := strings.TrimSpace(p.Url) pUrl := strings.TrimSpace(p.Url)
var permalink string var permalink string
if len(pSlug) > 0 { var err error
if p.Site.Config != nil && p.Site.Config.UglyUrls {
permalink = path.Join(dir, p.Slug, p.Extension) if override, ok := p.Site.Permalinks[p.Section]; ok {
} else { permalink, err = override.Expand(p)
permalink = dir + "/" + p.Slug + "/" if err != nil {
return nil, err
} }
} else if len(pUrl) > 2 { //fmt.Printf("have an override for %q in section %s → %s\n", p.Title, p.Section, permalink)
permalink = pUrl
} else { } else {
_, t := path.Split(p.FileName)
if p.Site.Config != nil && p.Site.Config.UglyUrls { if len(pSlug) > 0 {
x := replaceExtension(strings.TrimSpace(t), p.Extension) if p.Site.Config != nil && p.Site.Config.UglyUrls {
permalink = path.Join(dir, x) permalink = path.Join(dir, p.Slug, p.Extension)
} else {
permalink = dir + "/" + p.Slug + "/"
}
} else if len(pUrl) > 2 {
permalink = pUrl
} else { } else {
file, _ := fileExt(strings.TrimSpace(t)) _, t := path.Split(p.FileName)
permalink = path.Join(dir, file) 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) base, err := url.Parse(baseUrl)
@ -555,6 +567,18 @@ func (p *Page) TargetPath() (outfile string) {
return 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 { if len(strings.TrimSpace(p.Slug)) > 0 {
outfile = strings.TrimSpace(p.Slug) + "." + p.Extension outfile = strings.TrimSpace(p.Slug) + "." + p.Extension
} else { } else {

149
hugolib/permalinks.go Normal file
View file

@ -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,
}
}

View file

@ -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)
}
}
}

View 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), // 2. Pages contain sections (based on the file they were generated from),
// aliases and slugs (included in a pages frontmatter) which are the // aliases and slugs (included in a pages frontmatter) which are the
// various targets that will get generated. There will be canonical // various targets that will get generated. There will be canonical
// listing. // listing. The canonical path can be overruled based on a pattern.
// //
// 3. Indexes are created via configuration and will present some aspect of // 3. Indexes are created via configuration and will present some aspect of
// the final page and typically a perm url. // the final page and typically a perm url.
// //
// 4. All Pages are passed through a template based on their desired // 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. // 5. The entire collection of files is written to disk.
type Site struct { type Site struct {
@ -80,6 +80,7 @@ type SiteInfo struct {
LastChange time.Time LastChange time.Time
Title string Title string
Config *Config Config *Config
Permalinks PermalinkOverrides
Params map[string]interface{} Params map[string]interface{}
} }
@ -220,11 +221,12 @@ func (s *Site) initialize() (err error) {
func (s *Site) initializeSiteInfo() { func (s *Site) initializeSiteInfo() {
s.Info = SiteInfo{ s.Info = SiteInfo{
BaseUrl: template.URL(s.Config.BaseUrl), BaseUrl: template.URL(s.Config.BaseUrl),
Title: s.Config.Title, Title: s.Config.Title,
Recent: &s.Pages, Recent: &s.Pages,
Config: &s.Config, Config: &s.Config,
Params: s.Config.Params, Params: s.Config.Params,
Permalinks: s.Config.Permalinks,
} }
} }