Add timezone support for front matter dates without one

Fixes #8810
This commit is contained in:
Bjørn Erik Pedersen 2021-07-27 13:45:05 +02:00
parent a57dda854b
commit efa5760db5
10 changed files with 196 additions and 73 deletions

View file

@ -11,7 +11,7 @@ menu:
docs:
parent: "functions"
keywords: [dates,time,location]
signature: ["time INPUT [LOCATION]"]
signature: ["time INPUT [TIMEZONE]"]
workson: []
hugoversion: "v0.77.0"
relatedfuncs: []
@ -29,10 +29,12 @@ aliases: []
## Using Locations
The optional `LOCATION` parameter is a string that sets a default location that is associated with the specified time value. If the time value has an explicit timezone or offset specified, it will take precedence over the `LOCATION` parameter.
The optional `TIMEZONE` parameter is a string that sets a default time zone (or more specific, the location, which represents the collection of time offsets in a geographical area) that is associated with the specified time value. If the time value has an explicit timezone or offset specified, it will take precedence over the `TIMEZONE` parameter.
The list of valid locations may be system dependent, but should include `UTC`, `Local`, or any location in the [IANA Time Zone database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
If no `TIMEZONE` is set, the `timeZone` from site configuration will be used.
```
{{ time "2020-10-20" }} → 2020-10-20 00:00:00 +0000 UTC
{{ time "2020-10-20" "America/Los_Angeles" }} → 2020-10-20 00:00:00 -0700 PDT

View file

@ -299,6 +299,9 @@ themesDir ("themes")
timeout (10000)
: Timeout for generating page contents, in milliseconds (defaults to 10 seconds). *Note:* this is used to bail out of recursive content generation, if your pages are slow to generate (e.g., because they require large image processing or depend on remote contents) you might need to raise this limit.
timeZone {{< new-in "0.86.0" >}}
: The time zone (or location), e.g. `Europe/Oslo`, used to parse front matter dates without such information and in the [`time` function](/functions/time/).
title ("")
: Site title.

View file

@ -14,6 +14,8 @@
package hugolib
import (
"fmt"
"strings"
"testing"
)
@ -54,3 +56,133 @@ Date: {{ .Date | time.Format ":date_long" }}
b.AssertFileContent("public/nn/index.html", `Date: 18. juli 2021`)
}
func TestTimeZones(t *testing.T) {
b := newTestSitesBuilder(t)
b.WithConfigFile("toml", `
baseURL = "https://example.org"
defaultContentLanguage = "en"
defaultContentLanguageInSubDir = true
[languages]
[languages.en]
timeZone="UTC"
weight=10
[languages.nn]
timeZone="America/Antigua"
weight=20
`)
const (
pageTemplYaml = `---
title: Page
date: %s
lastMod: %s
publishDate: %s
expiryDate: %s
---
`
pageTemplTOML = `+++
title="Page"
date=%s
lastMod=%s
publishDate=%s
expiryDate=%s
+++
`
shortDateTempl = `%d-07-%d`
longDateTempl = `%d-07-%d 15:28:01`
)
createPageContent := func(pageTempl, dateTempl string, quoted bool) string {
createDate := func(year, i int) string {
d := fmt.Sprintf(dateTempl, year, i)
if quoted {
return fmt.Sprintf("%q", d)
}
return d
}
return fmt.Sprintf(
pageTempl,
createDate(2021, 10),
createDate(2021, 11),
createDate(2021, 12),
createDate(2099, 13), // This test will fail in 2099 :-)
)
}
b.WithContent(
// YAML
"short-date-yaml-unqouted.en.md", createPageContent(pageTemplYaml, shortDateTempl, false),
"short-date-yaml-unqouted.nn.md", createPageContent(pageTemplYaml, shortDateTempl, false),
"short-date-yaml-qouted.en.md", createPageContent(pageTemplYaml, shortDateTempl, true),
"short-date-yaml-qouted.nn.md", createPageContent(pageTemplYaml, shortDateTempl, true),
"long-date-yaml-unqouted.en.md", createPageContent(pageTemplYaml, longDateTempl, false),
"long-date-yaml-unqouted.nn.md", createPageContent(pageTemplYaml, longDateTempl, false),
// TOML
"short-date-toml-unqouted.en.md", createPageContent(pageTemplTOML, shortDateTempl, false),
"short-date-toml-unqouted.nn.md", createPageContent(pageTemplTOML, shortDateTempl, false),
"short-date-toml-qouted.en.md", createPageContent(pageTemplTOML, shortDateTempl, true),
"short-date-toml-qouted.nn.md", createPageContent(pageTemplTOML, shortDateTempl, true),
)
const datesTempl = `
Date: {{ .Date | safeHTML }}
Lastmod: {{ .Lastmod | safeHTML }}
PublishDate: {{ .PublishDate | safeHTML }}
ExpiryDate: {{ .ExpiryDate | safeHTML }}
`
b.WithTemplatesAdded(
"_default/single.html", datesTempl,
)
b.Build(BuildCfg{})
expectShortDateEn := `
Date: 2021-07-10 00:00:00 +0000 UTC
Lastmod: 2021-07-11 00:00:00 +0000 UTC
PublishDate: 2021-07-12 00:00:00 +0000 UTC
ExpiryDate: 2099-07-13 00:00:00 +0000 UTC`
expectShortDateNn := strings.ReplaceAll(expectShortDateEn, "+0000 UTC", "-0400 AST")
expectLongDateEn := `
Date: 2021-07-10 15:28:01 +0000 UTC
Lastmod: 2021-07-11 15:28:01 +0000 UTC
PublishDate: 2021-07-12 15:28:01 +0000 UTC
ExpiryDate: 2099-07-13 15:28:01 +0000 UTC`
expectLongDateNn := strings.ReplaceAll(expectLongDateEn, "+0000 UTC", "-0400 AST")
// TODO(bep) create a common proposal for go-yaml, go-toml
// for a custom date parser hook to handle these time zones.
// JSON is omitted from this test as JSON does no (to my knowledge)
// have date literals.
// YAML
// Note: This is with go-yaml v2, I suspect v3 will fail with the unquouted values.
b.AssertFileContent("public/en/short-date-yaml-unqouted/index.html", expectShortDateEn)
b.AssertFileContent("public/nn/short-date-yaml-unqouted/index.html", expectShortDateNn)
b.AssertFileContent("public/en/short-date-yaml-qouted/index.html", expectShortDateEn)
b.AssertFileContent("public/nn/short-date-yaml-qouted/index.html", expectShortDateNn)
b.AssertFileContent("public/en/long-date-yaml-unqouted/index.html", expectLongDateEn)
b.AssertFileContent("public/nn/long-date-yaml-unqouted/index.html", expectLongDateNn)
// TOML
// These fails: TOML (Burnt Sushi) defaults to local timezone.
// TODO(bep) check go-toml
// b.AssertFileContent("public/en/short-date-toml-unqouted/index.html", expectShortDateEn)
// b.AssertFileContent("public/nn/short-date-toml-unqouted/index.html", expectShortDateNn)
b.AssertFileContent("public/en/short-date-toml-qouted/index.html", expectShortDateEn)
b.AssertFileContent("public/nn/short-date-toml-qouted/index.html", expectShortDateNn)
}

View file

@ -22,6 +22,8 @@ import (
"sync"
"time"
"github.com/gohugoio/hugo/langs"
"github.com/gobuffalo/flect"
"github.com/gohugoio/hugo/markup/converter"
@ -396,6 +398,7 @@ func (pm *pageMeta) setMetadata(parentBucket *pagesMapBucket, p *pageState, fron
BaseFilename: contentBaseName,
ModTime: mtime,
GitAuthorDate: gitAuthorDate,
Location: langs.GetLocation(pm.s.Language()),
}
// Handle the date separately

View file

@ -17,6 +17,7 @@ import (
"sort"
"strings"
"sync"
"time"
translators "github.com/bep/gotranslators"
"github.com/go-playground/locales"
@ -71,10 +72,13 @@ type Language struct {
paramsMu sync.Mutex
paramsSet bool
// Used for date formatting etc. We don't want this exported to the
// Used for date formatting etc. We don't want these exported to the
// templates.
// TODO(bep) do the same for some of the others.
translator locales.Translator
locationInit sync.Once
location *time.Location
}
func (l *Language) String() string {
@ -244,9 +248,25 @@ func (l *Language) IsSet(key string) bool {
return l.Cfg.IsSet(key)
}
func (l *Language) getLocation() *time.Location {
l.locationInit.Do(func() {
location, err := time.LoadLocation(l.GetString("timeZone"))
if err != nil {
location = time.UTC
}
l.location = location
})
return l.location
}
// Internal access to unexported Language fields.
// This construct is to prevent them from leaking to the templates.
func GetTranslator(l *Language) locales.Translator {
return l.translator
}
func GetLocation(l *Language) *time.Location {
return l.getLocation()
}

View file

@ -70,6 +70,9 @@ type FrontMatterDescriptor struct {
// This is the Page's Slug etc.
PageURLs *URLPath
// The Location to use to parse dates without time zone info.
Location *time.Location
}
var dateFieldAliases = map[string][]string{
@ -119,7 +122,7 @@ func (f FrontMatterHandler) IsDateKey(key string) bool {
// 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) {
func dateAndSlugFromBaseFilename(location *time.Location, name string) (time.Time, string) {
withoutExt, _ := paths.FileAndExt(name)
if len(withoutExt) < 10 {
@ -127,9 +130,7 @@ func dateAndSlugFromBaseFilename(name string) (time.Time, string) {
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])
d, err := cast.ToTimeInDefaultLocationE(withoutExt[:10], location)
if err != nil {
return time.Time{}, ""
}
@ -370,7 +371,7 @@ func (f *frontmatterFieldHandlers) newDateFieldHandler(key string, setter func(d
return false, nil
}
date, err := cast.ToTimeE(v)
date, err := cast.ToTimeInDefaultLocationE(v, d.Location)
if err != nil {
return false, nil
}
@ -388,7 +389,7 @@ func (f *frontmatterFieldHandlers) newDateFieldHandler(key string, setter func(d
func (f *frontmatterFieldHandlers) newDateFilenameHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
return func(d *FrontMatterDescriptor) (bool, error) {
date, slug := dateAndSlugFromBaseFilename(d.BaseFilename)
date, slug := dateAndSlugFromBaseFilename(d.Location, d.BaseFilename)
if date.IsZero() {
return false, nil
}

View file

@ -53,7 +53,7 @@ func TestDateAndSlugFromBaseFilename(t *testing.T) {
expecteFDate, err := time.Parse("2006-01-02", test.date)
c.Assert(err, qt.IsNil)
gotDate, gotSlug := dateAndSlugFromBaseFilename(test.name)
gotDate, gotSlug := dateAndSlugFromBaseFilename(time.UTC, test.name)
c.Assert(gotDate, qt.Equals, expecteFDate)
c.Assert(gotSlug, qt.Equals, test.slug)
@ -67,6 +67,7 @@ func newTestFd() *FrontMatterDescriptor {
Params: make(map[string]interface{}),
Dates: &resource.Dates{},
PageURLs: &URLPath{},
Location: time.UTC,
}
}

View file

@ -26,7 +26,7 @@ func init() {
if d.Language == nil {
panic("Language must be set")
}
ctx := New(langs.GetTranslator(d.Language))
ctx := New(langs.GetTranslator(d.Language), langs.GetLocation(d.Language))
ns := &internal.TemplateFuncsNamespace{
Name: name,

View file

@ -16,6 +16,7 @@ package time
import (
"fmt"
"time"
_time "time"
"github.com/gohugoio/hugo/common/htime"
@ -25,83 +26,37 @@ import (
"github.com/spf13/cast"
)
var timeFormats = []string{
_time.RFC3339,
"2006-01-02T15:04:05", // iso8601 without timezone
_time.RFC1123Z,
_time.RFC1123,
_time.RFC822Z,
_time.RFC822,
_time.RFC850,
_time.ANSIC,
_time.UnixDate,
_time.RubyDate,
"2006-01-02 15:04:05.999999999 -0700 MST", // Time.String()
"2006-01-02",
"02 Jan 2006",
"2006-01-02T15:04:05-0700", // RFC3339 without timezone hh:mm colon
"2006-01-02 15:04:05 -07:00",
"2006-01-02 15:04:05 -0700",
"2006-01-02 15:04:05Z07:00", // RFC3339 without T
"2006-01-02 15:04:05Z0700", // RFC3339 without T or timezone hh:mm colon
"2006-01-02 15:04:05",
_time.Kitchen,
_time.Stamp,
_time.StampMilli,
_time.StampMicro,
_time.StampNano,
}
// New returns a new instance of the time-namespaced template functions.
func New(translator locales.Translator) *Namespace {
func New(translator locales.Translator, location *time.Location) *Namespace {
return &Namespace{
timeFormatter: htime.NewTimeFormatter(translator),
location: location,
}
}
// Namespace provides template functions for the "time" namespace.
type Namespace struct {
timeFormatter htime.TimeFormatter
location *time.Location
}
// AsTime converts the textual representation of the datetime string into
// a time.Time interface.
func (ns *Namespace) AsTime(v interface{}, args ...interface{}) (interface{}, error) {
if len(args) == 0 {
t, err := cast.ToTimeE(v)
if err != nil {
return nil, err
}
return t, nil
}
timeStr, err := cast.ToStringE(v)
if err != nil {
return nil, err
}
loc := ns.location
if len(args) > 0 {
locStr, err := cast.ToStringE(args[0])
if err != nil {
return nil, err
}
loc, err := _time.LoadLocation(locStr)
loc, err = _time.LoadLocation(locStr)
if err != nil {
return nil, err
}
// Note: Cast currently doesn't support time with non-default locations. For now, just inlining this.
// Reference: https://github.com/spf13/cast/pull/80
for _, dateType := range timeFormats {
t, err2 := _time.ParseInLocation(dateType, timeStr, loc)
if err2 == nil {
return t, nil
}
}
return nil, fmt.Errorf("Unable to ParseInLocation using date %q with timezone %q", v, loc)
return cast.ToTimeInDefaultLocationE(v, loc)
}
// Format converts the textual representation of the datetime string into

View file

@ -23,14 +23,16 @@ import (
func TestTimeLocation(t *testing.T) {
t.Parallel()
ns := New(translators.Get("en"))
loc, _ := time.LoadLocation("America/Antigua")
ns := New(translators.Get("en"), loc)
for i, test := range []struct {
value string
location string
location interface{}
expect interface{}
}{
{"2020-10-20", "", "2020-10-20 00:00:00 +0000 UTC"},
{"2020-10-20", nil, "2020-10-20 00:00:00 -0400 AST"},
{"2020-10-20", "America/New_York", "2020-10-20 00:00:00 -0400 EDT"},
{"2020-01-20", "America/New_York", "2020-01-20 00:00:00 -0500 EST"},
{"2020-10-20 20:33:59", "", "2020-10-20 20:33:59 +0000 UTC"},
@ -41,7 +43,11 @@ func TestTimeLocation(t *testing.T) {
{"2020-01-20", "invalid-timezone", false}, // unknown time zone invalid-timezone
{"invalid-value", "", false},
} {
result, err := ns.AsTime(test.value, test.location)
var args []interface{}
if test.location != nil {
args = append(args, test.location)
}
result, err := ns.AsTime(test.value, args...)
if b, ok := test.expect.(bool); ok && !b {
if err == nil {
t.Errorf("[%d] AsTime didn't return an expected error, got %v", i, result)
@ -61,7 +67,7 @@ func TestTimeLocation(t *testing.T) {
func TestFormat(t *testing.T) {
t.Parallel()
ns := New(translators.Get("en"))
ns := New(translators.Get("en"), time.UTC)
for i, test := range []struct {
layout string
@ -101,7 +107,7 @@ func TestFormat(t *testing.T) {
func TestDuration(t *testing.T) {
t.Parallel()
ns := New(translators.Get("en"))
ns := New(translators.Get("en"), time.UTC)
for i, test := range []struct {
unit interface{}