Add timezone support for front matter dates without one
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Tue, 27 Jul 2021 11:45:05 +0000 (13:45 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Tue, 27 Jul 2021 17:02:48 +0000 (19:02 +0200)
Fixes #8810

docs/content/en/functions/time.md
docs/content/en/getting-started/configuration.md
hugolib/dates_test.go
hugolib/page__meta.go
langs/language.go
resources/page/pagemeta/page_frontmatter.go
resources/page/pagemeta/page_frontmatter_test.go
tpl/time/init.go
tpl/time/time.go
tpl/time/time_test.go

index c4f74215bd2abeb70652a6ca1ed2be1c05dea956..6c7f5aec6a31b9b777bb68245dacafc92fedc7ab 100644 (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
index 36c8c1b50fa4b9d4d11fac6d7a8635c922a4d113..3f236dc02813ecbdc4ad8d8eca6143070b559160 100644 (file)
@@ -299,6 +299,9 @@ themesDir ("themes")
 timeout (10000)
 : Timeout for generating page contents, in milliseconds (defaults to 10&nbsp;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.
 
index fa03bc16b90e6dda3041882655420f5a70043e2a..6fe3adfa73d99fc2441bd9b4e9e87588f854d10d 100644 (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)
+
+}
index b55f58fe205692d60b797f21e7089e9ba6ef9280..7bd9f6ac791814200f0a3921c2934fad04c05e0e 100644 (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
index 5f5e8ddef95dd8ef981c70f56d09cae274ce8cfe..6f39848cfc4e127e8620112f078064a3730cd7c8 100644 (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()
+}
index 3184b444d89dc1189c6e3f2b098827364f7f1267..8e03c5f88ab7cf99256d5db6ae24b35ed145862f 100644 (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
                }
index 6e3833b0f9747414b241b74df29fd9941891a57a..653ced59e0980e165c6b3d26c40376c0ab2e39c4 100644 (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,
        }
 }
 
index 775878f718e91e093de5951b4a327e892ec89494..6cbdccb8f1f72818ebe9ffa585b32cb5e4c08965 100644 (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,
index 6fb901cc3aa21df205b7fa7eae6865c4f6c75861..a59d85b060071a9163f3222a520ce135e2a6b81a 100644 (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)
+       loc := ns.location
+       if len(args) > 0 {
+               locStr, err := cast.ToStringE(args[0])
                if err != nil {
                        return nil, err
                }
-
-               return t, nil
-       }
-
-       timeStr, err := cast.ToStringE(v)
-       if err != nil {
-               return nil, err
-       }
-
-       locStr, err := cast.ToStringE(args[0])
-       if err != nil {
-               return nil, err
-       }
-
-       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
+               loc, err = _time.LoadLocation(locStr)
+               if err != nil {
+                       return nil, err
                }
        }
 
-       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
index ed689f9a8021b16c7f59e8030ccdaebdd4aa0603..71899cc6516eb36606b3ae18e6bf91842a21c0bc 100644 (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{}