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
|
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 + "/"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue