diff --git a/hugolib/config.go b/hugolib/config.go index b9b5d54bd..9a737d7b7 100644 --- a/hugolib/config.go +++ b/hugolib/config.go @@ -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 + "/" } diff --git a/hugolib/page.go b/hugolib/page.go index f0ec4063e..f9af82448 100644 --- a/hugolib/page.go +++ b/hugolib/page.go @@ -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 index 000000000..41e797ea3 --- /dev/null +++ b/hugolib/permalinks.go @@ -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: "", 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 index 000000000..019b23c2f --- /dev/null +++ b/hugolib/permalinks_test.go @@ -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) + } + } +} diff --git a/hugolib/site.go b/hugolib/site.go index b1e1113dd..128b23962 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -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, } }