diff options
Diffstat (limited to 'resources/page/pagemeta')
-rw-r--r-- | resources/page/pagemeta/page_frontmatter.go | 427 | ||||
-rw-r--r-- | resources/page/pagemeta/page_frontmatter_test.go | 262 | ||||
-rw-r--r-- | resources/page/pagemeta/pagemeta.go | 21 |
3 files changed, 710 insertions, 0 deletions
diff --git a/resources/page/pagemeta/page_frontmatter.go b/resources/page/pagemeta/page_frontmatter.go new file mode 100644 index 000000000..1ce3fbee4 --- /dev/null +++ b/resources/page/pagemeta/page_frontmatter.go @@ -0,0 +1,427 @@ +// Copyright 2019 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pagemeta + +import ( + "strings" + "time" + + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/resources/resource" + + "github.com/gohugoio/hugo/config" + "github.com/spf13/cast" +) + +// FrontMatterHandler maps front matter into Page fields and .Params. +// Note that we currently have only extracted the date logic. +type FrontMatterHandler struct { + fmConfig frontmatterConfig + + dateHandler frontMatterFieldHandler + lastModHandler frontMatterFieldHandler + publishDateHandler frontMatterFieldHandler + expiryDateHandler frontMatterFieldHandler + + // A map of all date keys configured, including any custom. + allDateKeys map[string]bool + + logger *loggers.Logger +} + +// FrontMatterDescriptor describes how to handle front matter for a given Page. +// It has pointers to values in the receiving page which gets updated. +type FrontMatterDescriptor struct { + + // This the Page's front matter. + Frontmatter map[string]interface{} + + // This is the Page's base filename (BaseFilename), e.g. page.md., or + // if page is a leaf bundle, the bundle folder name (ContentBaseName). + BaseFilename string + + // The content file's mod time. + ModTime time.Time + + // May be set from the author date in Git. + GitAuthorDate time.Time + + // The below are pointers to values on Page and will be modified. + + // This is the Page's params. + Params map[string]interface{} + + // This is the Page's dates. + Dates *resource.Dates + + // This is the Page's Slug etc. + PageURLs *URLPath +} + +var ( + dateFieldAliases = map[string][]string{ + fmDate: {}, + fmLastmod: {"modified"}, + fmPubDate: {"pubdate", "published"}, + fmExpiryDate: {"unpublishdate"}, + } +) + +// HandleDates updates all the dates given the current configuration and the +// supplied front matter params. Note that this requires all lower-case keys +// in the params map. +func (f FrontMatterHandler) HandleDates(d *FrontMatterDescriptor) error { + if d.Dates == nil { + panic("missing dates") + } + + if f.dateHandler == nil { + panic("missing date handler") + } + + if _, err := f.dateHandler(d); err != nil { + return err + } + + if _, err := f.lastModHandler(d); err != nil { + return err + } + + if _, err := f.publishDateHandler(d); err != nil { + return err + } + + if _, err := f.expiryDateHandler(d); err != nil { + return err + } + + return nil +} + +// IsDateKey returns whether the given front matter key is considered a date by the current +// configuration. +func (f FrontMatterHandler) IsDateKey(key string) bool { + return f.allDateKeys[key] +} + +// A Zero date is a signal that the name can not be parsed. +// This follows the format as outlined in Jekyll, https://jekyllrb.com/docs/posts/: +// "Where YEAR is a four-digit number, MONTH and DAY are both two-digit numbers" +func dateAndSlugFromBaseFilename(name string) (time.Time, string) { + withoutExt, _ := helpers.FileAndExt(name) + + if len(withoutExt) < 10 { + // This can not be a date. + return time.Time{}, "" + } + + // Note: Hugo currently have no custom timezone support. + // We will have to revisit this when that is in place. + d, err := time.Parse("2006-01-02", withoutExt[:10]) + if err != nil { + return time.Time{}, "" + } + + // Be a little lenient with the format here. + slug := strings.Trim(withoutExt[10:], " -_") + + return d, slug +} + +type frontMatterFieldHandler func(d *FrontMatterDescriptor) (bool, error) + +func (f FrontMatterHandler) newChainedFrontMatterFieldHandler(handlers ...frontMatterFieldHandler) frontMatterFieldHandler { + return func(d *FrontMatterDescriptor) (bool, error) { + for _, h := range handlers { + // First successful handler wins. + success, err := h(d) + if err != nil { + f.logger.ERROR.Println(err) + } else if success { + return true, nil + } + } + return false, nil + } +} + +type frontmatterConfig struct { + date []string + lastmod []string + publishDate []string + expiryDate []string +} + +const ( + // These are all the date handler identifiers + // All identifiers not starting with a ":" maps to a front matter parameter. + fmDate = "date" + fmPubDate = "publishdate" + fmLastmod = "lastmod" + fmExpiryDate = "expirydate" + + // Gets date from filename, e.g 218-02-22-mypage.md + fmFilename = ":filename" + + // Gets date from file OS mod time. + fmModTime = ":filemodtime" + + // Gets date from Git + fmGitAuthorDate = ":git" +) + +// This is the config you get when doing nothing. +func newDefaultFrontmatterConfig() frontmatterConfig { + return frontmatterConfig{ + date: []string{fmDate, fmPubDate, fmLastmod}, + lastmod: []string{fmGitAuthorDate, fmLastmod, fmDate, fmPubDate}, + publishDate: []string{fmPubDate, fmDate}, + expiryDate: []string{fmExpiryDate}, + } +} + +func newFrontmatterConfig(cfg config.Provider) (frontmatterConfig, error) { + c := newDefaultFrontmatterConfig() + defaultConfig := c + + if cfg.IsSet("frontmatter") { + fm := cfg.GetStringMap("frontmatter") + for k, v := range fm { + loki := strings.ToLower(k) + switch loki { + case fmDate: + c.date = toLowerSlice(v) + case fmPubDate: + c.publishDate = toLowerSlice(v) + case fmLastmod: + c.lastmod = toLowerSlice(v) + case fmExpiryDate: + c.expiryDate = toLowerSlice(v) + } + } + } + + expander := func(c, d []string) []string { + out := expandDefaultValues(c, d) + out = addDateFieldAliases(out) + return out + } + + c.date = expander(c.date, defaultConfig.date) + c.publishDate = expander(c.publishDate, defaultConfig.publishDate) + c.lastmod = expander(c.lastmod, defaultConfig.lastmod) + c.expiryDate = expander(c.expiryDate, defaultConfig.expiryDate) + + return c, nil +} + +func addDateFieldAliases(values []string) []string { + var complete []string + + for _, v := range values { + complete = append(complete, v) + if aliases, found := dateFieldAliases[v]; found { + complete = append(complete, aliases...) + } + } + return helpers.UniqueStrings(complete) +} + +func expandDefaultValues(values []string, defaults []string) []string { + var out []string + for _, v := range values { + if v == ":default" { + out = append(out, defaults...) + } else { + out = append(out, v) + } + } + return out +} + +func toLowerSlice(in interface{}) []string { + out := cast.ToStringSlice(in) + for i := 0; i < len(out); i++ { + out[i] = strings.ToLower(out[i]) + } + + return out +} + +// NewFrontmatterHandler creates a new FrontMatterHandler with the given logger and configuration. +// If no logger is provided, one will be created. +func NewFrontmatterHandler(logger *loggers.Logger, cfg config.Provider) (FrontMatterHandler, error) { + + if logger == nil { + logger = loggers.NewErrorLogger() + } + + frontMatterConfig, err := newFrontmatterConfig(cfg) + if err != nil { + return FrontMatterHandler{}, err + } + + allDateKeys := make(map[string]bool) + addKeys := func(vals []string) { + for _, k := range vals { + if !strings.HasPrefix(k, ":") { + allDateKeys[k] = true + } + } + } + + addKeys(frontMatterConfig.date) + addKeys(frontMatterConfig.expiryDate) + addKeys(frontMatterConfig.lastmod) + addKeys(frontMatterConfig.publishDate) + + f := FrontMatterHandler{logger: logger, fmConfig: frontMatterConfig, allDateKeys: allDateKeys} + + if err := f.createHandlers(); err != nil { + return f, err + } + + return f, nil +} + +func (f *FrontMatterHandler) createHandlers() error { + var err error + + if f.dateHandler, err = f.createDateHandler(f.fmConfig.date, + func(d *FrontMatterDescriptor, t time.Time) { + d.Dates.FDate = t + setParamIfNotSet(fmDate, t, d) + }); err != nil { + return err + } + + if f.lastModHandler, err = f.createDateHandler(f.fmConfig.lastmod, + func(d *FrontMatterDescriptor, t time.Time) { + setParamIfNotSet(fmLastmod, t, d) + d.Dates.FLastmod = t + }); err != nil { + return err + } + + if f.publishDateHandler, err = f.createDateHandler(f.fmConfig.publishDate, + func(d *FrontMatterDescriptor, t time.Time) { + setParamIfNotSet(fmPubDate, t, d) + d.Dates.FPublishDate = t + }); err != nil { + return err + } + + if f.expiryDateHandler, err = f.createDateHandler(f.fmConfig.expiryDate, + func(d *FrontMatterDescriptor, t time.Time) { + setParamIfNotSet(fmExpiryDate, t, d) + d.Dates.FExpiryDate = t + }); err != nil { + return err + } + + return nil +} + +func setParamIfNotSet(key string, value interface{}, d *FrontMatterDescriptor) { + if _, found := d.Params[key]; found { + return + } + d.Params[key] = value +} + +func (f FrontMatterHandler) createDateHandler(identifiers []string, setter func(d *FrontMatterDescriptor, t time.Time)) (frontMatterFieldHandler, error) { + var h *frontmatterFieldHandlers + var handlers []frontMatterFieldHandler + + for _, identifier := range identifiers { + switch identifier { + case fmFilename: + handlers = append(handlers, h.newDateFilenameHandler(setter)) + case fmModTime: + handlers = append(handlers, h.newDateModTimeHandler(setter)) + case fmGitAuthorDate: + handlers = append(handlers, h.newDateGitAuthorDateHandler(setter)) + default: + handlers = append(handlers, h.newDateFieldHandler(identifier, setter)) + } + } + + return f.newChainedFrontMatterFieldHandler(handlers...), nil + +} + +type frontmatterFieldHandlers int + +func (f *frontmatterFieldHandlers) newDateFieldHandler(key string, setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler { + return func(d *FrontMatterDescriptor) (bool, error) { + v, found := d.Frontmatter[key] + + if !found { + return false, nil + } + + date, err := cast.ToTimeE(v) + if err != nil { + return false, nil + } + + // We map several date keys to one, so, for example, + // "expirydate", "unpublishdate" will all set .ExpiryDate (first found). + setter(d, date) + + // This is the params key as set in front matter. + d.Params[key] = date + + return true, nil + } +} + +func (f *frontmatterFieldHandlers) newDateFilenameHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler { + return func(d *FrontMatterDescriptor) (bool, error) { + date, slug := dateAndSlugFromBaseFilename(d.BaseFilename) + if date.IsZero() { + return false, nil + } + + setter(d, date) + + if _, found := d.Frontmatter["slug"]; !found { + // Use slug from filename + d.PageURLs.Slug = slug + } + + return true, nil + } +} + +func (f *frontmatterFieldHandlers) newDateModTimeHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler { + return func(d *FrontMatterDescriptor) (bool, error) { + if d.ModTime.IsZero() { + return false, nil + } + setter(d, d.ModTime) + return true, nil + } +} + +func (f *frontmatterFieldHandlers) newDateGitAuthorDateHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler { + return func(d *FrontMatterDescriptor) (bool, error) { + if d.GitAuthorDate.IsZero() { + return false, nil + } + setter(d, d.GitAuthorDate) + return true, nil + } +} diff --git a/resources/page/pagemeta/page_frontmatter_test.go b/resources/page/pagemeta/page_frontmatter_test.go new file mode 100644 index 000000000..313f704d9 --- /dev/null +++ b/resources/page/pagemeta/page_frontmatter_test.go @@ -0,0 +1,262 @@ +// Copyright 2019 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pagemeta + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/gohugoio/hugo/resources/resource" + "github.com/spf13/viper" + + "github.com/stretchr/testify/require" +) + +func TestDateAndSlugFromBaseFilename(t *testing.T) { + + t.Parallel() + + assert := require.New(t) + + tests := []struct { + name string + date string + slug string + }{ + {"page.md", "0001-01-01", ""}, + {"2012-09-12-page.md", "2012-09-12", "page"}, + {"2018-02-28-page.md", "2018-02-28", "page"}, + {"2018-02-28_page.md", "2018-02-28", "page"}, + {"2018-02-28 page.md", "2018-02-28", "page"}, + {"2018-02-28page.md", "2018-02-28", "page"}, + {"2018-02-28-.md", "2018-02-28", ""}, + {"2018-02-28-.md", "2018-02-28", ""}, + {"2018-02-28.md", "2018-02-28", ""}, + {"2018-02-28-page", "2018-02-28", "page"}, + {"2012-9-12-page.md", "0001-01-01", ""}, + {"asdfasdf.md", "0001-01-01", ""}, + } + + for i, test := range tests { + expecteFDate, err := time.Parse("2006-01-02", test.date) + assert.NoError(err) + + errMsg := fmt.Sprintf("Test %d", i) + gotDate, gotSlug := dateAndSlugFromBaseFilename(test.name) + + assert.Equal(expecteFDate, gotDate, errMsg) + assert.Equal(test.slug, gotSlug, errMsg) + + } +} + +func newTestFd() *FrontMatterDescriptor { + return &FrontMatterDescriptor{ + Frontmatter: make(map[string]interface{}), + Params: make(map[string]interface{}), + Dates: &resource.Dates{}, + PageURLs: &URLPath{}, + } +} + +func TestFrontMatterNewConfig(t *testing.T) { + assert := require.New(t) + + cfg := viper.New() + + cfg.Set("frontmatter", map[string]interface{}{ + "date": []string{"publishDate", "LastMod"}, + "Lastmod": []string{"publishDate"}, + "expiryDate": []string{"lastMod"}, + "publishDate": []string{"date"}, + }) + + fc, err := newFrontmatterConfig(cfg) + assert.NoError(err) + assert.Equal([]string{"publishdate", "pubdate", "published", "lastmod", "modified"}, fc.date) + assert.Equal([]string{"publishdate", "pubdate", "published"}, fc.lastmod) + assert.Equal([]string{"lastmod", "modified"}, fc.expiryDate) + assert.Equal([]string{"date"}, fc.publishDate) + + // Default + cfg = viper.New() + fc, err = newFrontmatterConfig(cfg) + assert.NoError(err) + assert.Equal([]string{"date", "publishdate", "pubdate", "published", "lastmod", "modified"}, fc.date) + assert.Equal([]string{":git", "lastmod", "modified", "date", "publishdate", "pubdate", "published"}, fc.lastmod) + assert.Equal([]string{"expirydate", "unpublishdate"}, fc.expiryDate) + assert.Equal([]string{"publishdate", "pubdate", "published", "date"}, fc.publishDate) + + // :default keyword + cfg.Set("frontmatter", map[string]interface{}{ + "date": []string{"d1", ":default"}, + "lastmod": []string{"d2", ":default"}, + "expiryDate": []string{"d3", ":default"}, + "publishDate": []string{"d4", ":default"}, + }) + fc, err = newFrontmatterConfig(cfg) + assert.NoError(err) + assert.Equal([]string{"d1", "date", "publishdate", "pubdate", "published", "lastmod", "modified"}, fc.date) + assert.Equal([]string{"d2", ":git", "lastmod", "modified", "date", "publishdate", "pubdate", "published"}, fc.lastmod) + assert.Equal([]string{"d3", "expirydate", "unpublishdate"}, fc.expiryDate) + assert.Equal([]string{"d4", "publishdate", "pubdate", "published", "date"}, fc.publishDate) + +} + +func TestFrontMatterDatesHandlers(t *testing.T) { + assert := require.New(t) + + for _, handlerID := range []string{":filename", ":fileModTime", ":git"} { + + cfg := viper.New() + + cfg.Set("frontmatter", map[string]interface{}{ + "date": []string{handlerID, "date"}, + }) + + handler, err := NewFrontmatterHandler(nil, cfg) + assert.NoError(err) + + d1, _ := time.Parse("2006-01-02", "2018-02-01") + d2, _ := time.Parse("2006-01-02", "2018-02-02") + + d := newTestFd() + switch strings.ToLower(handlerID) { + case ":filename": + d.BaseFilename = "2018-02-01-page.md" + case ":filemodtime": + d.ModTime = d1 + case ":git": + d.GitAuthorDate = d1 + } + d.Frontmatter["date"] = d2 + assert.NoError(handler.HandleDates(d)) + assert.Equal(d1, d.Dates.FDate) + assert.Equal(d2, d.Params["date"]) + + d = newTestFd() + d.Frontmatter["date"] = d2 + assert.NoError(handler.HandleDates(d)) + assert.Equal(d2, d.Dates.FDate) + assert.Equal(d2, d.Params["date"]) + + } +} + +func TestFrontMatterDatesCustomConfig(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + cfg := viper.New() + cfg.Set("frontmatter", map[string]interface{}{ + "date": []string{"mydate"}, + "lastmod": []string{"publishdate"}, + "publishdate": []string{"publishdate"}, + }) + + handler, err := NewFrontmatterHandler(nil, cfg) + assert.NoError(err) + + testDate, err := time.Parse("2006-01-02", "2018-02-01") + assert.NoError(err) + + d := newTestFd() + d.Frontmatter["mydate"] = testDate + testDate = testDate.Add(24 * time.Hour) + d.Frontmatter["date"] = testDate + testDate = testDate.Add(24 * time.Hour) + d.Frontmatter["lastmod"] = testDate + testDate = testDate.Add(24 * time.Hour) + d.Frontmatter["publishdate"] = testDate + testDate = testDate.Add(24 * time.Hour) + d.Frontmatter["expirydate"] = testDate + + assert.NoError(handler.HandleDates(d)) + + assert.Equal(1, d.Dates.FDate.Day()) + assert.Equal(4, d.Dates.FLastmod.Day()) + assert.Equal(4, d.Dates.FPublishDate.Day()) + assert.Equal(5, d.Dates.FExpiryDate.Day()) + + assert.Equal(d.Dates.FDate, d.Params["date"]) + assert.Equal(d.Dates.FDate, d.Params["mydate"]) + assert.Equal(d.Dates.FPublishDate, d.Params["publishdate"]) + assert.Equal(d.Dates.FExpiryDate, d.Params["expirydate"]) + + assert.False(handler.IsDateKey("date")) // This looks odd, but is configured like this. + assert.True(handler.IsDateKey("mydate")) + assert.True(handler.IsDateKey("publishdate")) + assert.True(handler.IsDateKey("pubdate")) + +} + +func TestFrontMatterDatesDefaultKeyword(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + cfg := viper.New() + + cfg.Set("frontmatter", map[string]interface{}{ + "date": []string{"mydate", ":default"}, + "publishdate": []string{":default", "mypubdate"}, + }) + + handler, err := NewFrontmatterHandler(nil, cfg) + assert.NoError(err) + + testDate, _ := time.Parse("2006-01-02", "2018-02-01") + d := newTestFd() + d.Frontmatter["mydate"] = testDate + d.Frontmatter["date"] = testDate.Add(1 * 24 * time.Hour) + d.Frontmatter["mypubdate"] = testDate.Add(2 * 24 * time.Hour) + d.Frontmatter["publishdate"] = testDate.Add(3 * 24 * time.Hour) + + assert.NoError(handler.HandleDates(d)) + + assert.Equal(1, d.Dates.FDate.Day()) + assert.Equal(2, d.Dates.FLastmod.Day()) + assert.Equal(4, d.Dates.FPublishDate.Day()) + assert.True(d.Dates.FExpiryDate.IsZero()) + +} + +func TestExpandDefaultValues(t *testing.T) { + assert := require.New(t) + assert.Equal([]string{"a", "b", "c", "d"}, expandDefaultValues([]string{"a", ":default", "d"}, []string{"b", "c"})) + assert.Equal([]string{"a", "b", "c"}, expandDefaultValues([]string{"a", "b", "c"}, []string{"a", "b", "c"})) + assert.Equal([]string{"b", "c", "a", "b", "c", "d"}, expandDefaultValues([]string{":default", "a", ":default", "d"}, []string{"b", "c"})) + +} + +func TestFrontMatterDateFieldHandler(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + handlers := new(frontmatterFieldHandlers) + + fd := newTestFd() + d, _ := time.Parse("2006-01-02", "2018-02-01") + fd.Frontmatter["date"] = d + h := handlers.newDateFieldHandler("date", func(d *FrontMatterDescriptor, t time.Time) { d.Dates.FDate = t }) + + handled, err := h(fd) + assert.True(handled) + assert.NoError(err) + assert.Equal(d, fd.Dates.FDate) +} diff --git a/resources/page/pagemeta/pagemeta.go b/resources/page/pagemeta/pagemeta.go new file mode 100644 index 000000000..07e5c5673 --- /dev/null +++ b/resources/page/pagemeta/pagemeta.go @@ -0,0 +1,21 @@ +// Copyright 2019 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pagemeta + +type URLPath struct { + URL string + Permalink string + Slug string + Section string +} |