Skip to content

Commit

Permalink
hugolib: Extract date and slug from filename
Browse files Browse the repository at this point in the history
This commit adds a new config option  which, when enabled and no date is set in front matter, will make Hugo try to parse the date from the content filename.

Also, the filenames in these cases will make for very poor permalinks, so we will also use the remaining part as the page `slug` if that value is not set in front matter.

This should make it easier to move content from Jekyll to Hugo.

To enable, put this in your `config.toml`:

```toml
[frontmatter]
defaultDate  = ["filename"]
```

Fixes #285
Closes #3310
Closes #3762
  • Loading branch information
bep committed Feb 21, 2018
1 parent 55bd46a commit bcbf53d
Show file tree
Hide file tree
Showing 5 changed files with 250 additions and 7 deletions.
35 changes: 29 additions & 6 deletions hugolib/page.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2016 The Hugo Authors. All rights reserved.
// Copyright 2018 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.
Expand Down Expand Up @@ -1115,6 +1115,9 @@ func (p *Page) update(frontmatter map[string]interface{}) error {
// Needed for case insensitive fetching of params values
helpers.ToLowerMap(frontmatter)

// Handle the date separately
p.s.frontmatterConfig.handleDate(frontmatter, p)

var modified time.Time

var err error
Expand Down Expand Up @@ -1151,11 +1154,7 @@ func (p *Page) update(frontmatter map[string]interface{}) error {
p.Keywords = cast.ToStringSlice(v)
p.params[loki] = p.Keywords
case "date":
p.Date, err = cast.ToTimeE(v)
if err != nil {
p.s.Log.ERROR.Printf("Failed to parse date '%v' in page %s", v, p.File.Path())
}
p.params[loki] = p.Date
// Handled separately.
case "headless":
// For now, only the leaf bundles ("index.md") can be headless (i.e. produce no output).
// We may expand on this in the future, but that gets more complex pretty fast.
Expand Down Expand Up @@ -1373,6 +1372,30 @@ func (p *Page) update(frontmatter map[string]interface{}) error {
return nil
}

// 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
}

func (p *Page) GetParam(key string) interface{} {
return p.getParam(key, false)
}
Expand Down
115 changes: 115 additions & 0 deletions hugolib/page_frontmatter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright 2018 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 hugolib

import (
"fmt"
"io/ioutil"
"log"
"os"
"strings"

"github.com/gohugoio/hugo/config"
"github.com/spf13/cast"
jww "github.com/spf13/jwalterweatherman"
)

type frontmatterConfig struct {
// Ordered chain.
dateHandlers []frontmatterFieldHandler

logger *jww.Notepad
}

func (f frontmatterConfig) handleField(handlers []frontmatterFieldHandler, frontmatter map[string]interface{}, p *Page) {
for _, h := range handlers {
handled, err := h(frontmatter, p)
if err != nil {
f.logger.ERROR.Println(err)
}
if handled {
break
}
}
}

func (f frontmatterConfig) handleDate(frontmatter map[string]interface{}, p *Page) {
f.handleField(f.dateHandlers, frontmatter, p)
}

type frontmatterFieldHandler func(frontmatter map[string]interface{}, p *Page) (bool, error)

func newFrontmatterConfig(logger *jww.Notepad, cfg config.Provider) (frontmatterConfig, error) {

if logger == nil {
logger = jww.NewNotepad(jww.LevelWarn, jww.LevelWarn, os.Stdout, ioutil.Discard, "", log.Ldate|log.Ltime)
}

f := frontmatterConfig{logger: logger}

handlers := &frontmatterFieldHandlers{logger: logger}

f.dateHandlers = []frontmatterFieldHandler{handlers.defaultDateHandler}

if cfg.IsSet("frontmatter") {
fm := cfg.GetStringMap("frontmatter")
if fm != nil {
dateFallbacks, found := fm["defaultdate"]
if found {
slice, err := cast.ToStringSliceE(dateFallbacks)
if err != nil {
return f, fmt.Errorf("invalid value for dataCallbacks, expeced a string slice, got %T", dateFallbacks)
}

for _, v := range slice {
if strings.EqualFold(v, "filename") {
f.dateHandlers = append(f.dateHandlers, handlers.fileanameFallbackDateHandler)
// No more for now.
break
}
}
}
}
}

return f, nil
}

type frontmatterFieldHandlers struct {
logger *jww.Notepad
}

// TODO(bep) modtime

func (f *frontmatterFieldHandlers) defaultDateHandler(frontmatter map[string]interface{}, p *Page) (bool, error) {
loki := "date"
v, found := frontmatter[loki]
if !found {
return false, nil
}

var err error
p.Date, err = cast.ToTimeE(v)
if err != nil {
return false, fmt.Errorf("Failed to parse date %q in page %s", v, p.File.Path())
}

p.params[loki] = p.Date

return true, nil
}

func (f *frontmatterFieldHandlers) fileanameFallbackDateHandler(frontmatter map[string]interface{}, p *Page) (bool, error) {
return true, nil
}
39 changes: 39 additions & 0 deletions hugolib/page_frontmatter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright 2018 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 hugolib

import (
"testing"

"github.com/spf13/viper"
"github.com/stretchr/testify/require"
)

func TestNewFrontmatterConfig(t *testing.T) {
t.Parallel()

v := viper.New()

v.Set("frontmatter", map[string]interface{}{
"defaultDate": []string{"filename"},
})

assert := require.New(t)

fc, err := newFrontmatterConfig(newWarningLogger(), v)

assert.NoError(err)
assert.Equal(2, len(fc.dateHandlers))

}
58 changes: 57 additions & 1 deletion hugolib/page_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2015 The Hugo Authors. All rights reserved.
// Copyright 2018 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.
Expand Down Expand Up @@ -985,6 +985,25 @@ Page With empty front matter`
zero_FM = "Page With empty front matter"
)

/*func TestPageWithFilenameDateAsFallback(t *testing.T) {
t.Parallel()
cfg, fs := newTestCfg()
var tests = []struct {
}{}
writeSource(t, fs, filepath.Join("content", "simple.md"), simplePageRFC3339Date)
s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true})
require.Len(t, s.RegularPages, 1)
p := s.RegularPages[0]
d, _ := time.Parse(time.RFC3339, "2013-05-17T16:59:30Z")
checkPageDate(t, p, d)
}
*/
func TestMetadataDates(t *testing.T) {
t.Parallel()
var tests = []struct {
Expand Down Expand Up @@ -1873,6 +1892,43 @@ tags:
}
}

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 {
expectedDate, err := time.Parse("2006-01-02", test.date)
assert.NoError(err)

errMsg := fmt.Sprintf("Test %d", i)
gotDate, gotSlug := dateAndSlugFromBaseFilename(test.name)

assert.Equal(expectedDate, gotDate, errMsg)
assert.Equal(test.slug, gotSlug, errMsg)

}
}

func BenchmarkParsePage(b *testing.B) {
s := newTestSite(b)
f, _ := os.Open("testdata/redis.cn.md")
Expand Down
10 changes: 10 additions & 0 deletions hugolib/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ type Site struct {
outputFormatsConfig output.Formats
mediaTypesConfig media.Types

// How to handle page front matter.
frontmatterConfig frontmatterConfig

// We render each site for all the relevant output formats in serial with
// this rendering context pointing to the current one.
rc *siteRenderingContext
Expand Down Expand Up @@ -177,6 +180,7 @@ func (s *Site) reset() *Site {
relatedDocsHandler: newSearchIndexHandler(s.relatedDocsHandler.cfg),
outputFormats: s.outputFormats,
outputFormatsConfig: s.outputFormatsConfig,
frontmatterConfig: s.frontmatterConfig,
mediaTypesConfig: s.mediaTypesConfig,
resourceSpec: s.resourceSpec,
Language: s.Language,
Expand Down Expand Up @@ -248,6 +252,11 @@ func newSite(cfg deps.DepsCfg) (*Site, error) {

titleFunc := helpers.GetTitleFunc(cfg.Language.GetString("titleCaseStyle"))

fmConfig, err := newFrontmatterConfig(cfg.Logger, cfg.Cfg)
if err != nil {
return nil, err
}

s := &Site{
PageCollections: c,
layoutHandler: output.NewLayoutHandler(cfg.Cfg.GetString("themesDir") != ""),
Expand All @@ -258,6 +267,7 @@ func newSite(cfg deps.DepsCfg) (*Site, error) {
outputFormats: outputFormats,
outputFormatsConfig: siteOutputFormatsConfig,
mediaTypesConfig: siteMediaTypesConfig,
frontmatterConfig: fmConfig,
}

s.Info = newSiteInfo(siteBuilderCfg{s: s, pageCollections: c, language: s.Language})
Expand Down

0 comments on commit bcbf53d

Please sign in to comment.