mirror of
https://github.com/gohugoio/hugo.git
synced 2024-11-21 20:46:30 -05:00
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:
parent
4f335f0c7f
commit
07978e4a49
5 changed files with 277 additions and 21 deletions
|
@ -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 + "/"
|
||||
}
|
||||
|
|
|
@ -251,6 +251,16 @@ func (p *Page) permalink() (*url.URL, error) {
|
|||
pSlug := strings.TrimSpace(p.Slug)
|
||||
pUrl := strings.TrimSpace(p.Url)
|
||||
var permalink string
|
||||
var err error
|
||||
|
||||
if override, ok := p.Site.Permalinks[p.Section]; ok {
|
||||
permalink, err = override.Expand(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//fmt.Printf("have an override for %q in section %s → %s\n", p.Title, p.Section, permalink)
|
||||
} else {
|
||||
|
||||
if len(pSlug) > 0 {
|
||||
if p.Site.Config != nil && p.Site.Config.UglyUrls {
|
||||
permalink = path.Join(dir, p.Slug, p.Extension)
|
||||
|
@ -270,6 +280,8 @@ func (p *Page) permalink() (*url.URL, error) {
|
|||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
base, err := url.Parse(baseUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -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 {
|
||||
|
|
149
hugolib/permalinks.go
Normal file
149
hugolib/permalinks.go
Normal 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,
|
||||
}
|
||||
}
|
75
hugolib/permalinks_test.go
Normal file
75
hugolib/permalinks_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -47,7 +47,7 @@ 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.
|
||||
// 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.
|
||||
|
@ -80,6 +80,7 @@ type SiteInfo struct {
|
|||
LastChange time.Time
|
||||
Title string
|
||||
Config *Config
|
||||
Permalinks PermalinkOverrides
|
||||
Params map[string]interface{}
|
||||
}
|
||||
|
||||
|
@ -225,6 +226,7 @@ func (s *Site) initializeSiteInfo() {
|
|||
Recent: &s.Pages,
|
||||
Config: &s.Config,
|
||||
Params: s.Config.Params,
|
||||
Permalinks: s.Config.Permalinks,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue