Skip to content

Commit efa5760

Browse files
committed
Add timezone support for front matter dates without one
Fixes #8810
1 parent a57dda8 commit efa5760

File tree

10 files changed

+196
-73
lines changed

10 files changed

+196
-73
lines changed

docs/content/en/functions/time.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ menu:
1111
docs:
1212
parent: "functions"
1313
keywords: [dates,time,location]
14-
signature: ["time INPUT [LOCATION]"]
14+
signature: ["time INPUT [TIMEZONE]"]
1515
workson: []
1616
hugoversion: "v0.77.0"
1717
relatedfuncs: []
@@ -29,10 +29,12 @@ aliases: []
2929

3030
## Using Locations
3131

32-
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.
32+
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.
3333

3434
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).
3535

36+
If no `TIMEZONE` is set, the `timeZone` from site configuration will be used.
37+
3638
```
3739
{{ time "2020-10-20" }} → 2020-10-20 00:00:00 +0000 UTC
3840
{{ time "2020-10-20" "America/Los_Angeles" }} → 2020-10-20 00:00:00 -0700 PDT

docs/content/en/getting-started/configuration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ themesDir ("themes")
299299
timeout (10000)
300300
: 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.
301301

302+
timeZone {{< new-in "0.86.0" >}}
303+
: 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/).
304+
302305
title ("")
303306
: Site title.
304307

hugolib/dates_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
package hugolib
1515

1616
import (
17+
"fmt"
18+
"strings"
1719
"testing"
1820
)
1921

@@ -54,3 +56,133 @@ Date: {{ .Date | time.Format ":date_long" }}
5456
b.AssertFileContent("public/nn/index.html", `Date: 18. juli 2021`)
5557

5658
}
59+
60+
func TestTimeZones(t *testing.T) {
61+
b := newTestSitesBuilder(t)
62+
b.WithConfigFile("toml", `
63+
baseURL = "https://example.org"
64+
65+
defaultContentLanguage = "en"
66+
defaultContentLanguageInSubDir = true
67+
68+
[languages]
69+
[languages.en]
70+
timeZone="UTC"
71+
weight=10
72+
[languages.nn]
73+
timeZone="America/Antigua"
74+
weight=20
75+
76+
`)
77+
78+
const (
79+
pageTemplYaml = `---
80+
title: Page
81+
date: %s
82+
lastMod: %s
83+
publishDate: %s
84+
expiryDate: %s
85+
---
86+
`
87+
88+
pageTemplTOML = `+++
89+
title="Page"
90+
date=%s
91+
lastMod=%s
92+
publishDate=%s
93+
expiryDate=%s
94+
+++
95+
`
96+
97+
shortDateTempl = `%d-07-%d`
98+
longDateTempl = `%d-07-%d 15:28:01`
99+
)
100+
101+
createPageContent := func(pageTempl, dateTempl string, quoted bool) string {
102+
createDate := func(year, i int) string {
103+
d := fmt.Sprintf(dateTempl, year, i)
104+
if quoted {
105+
return fmt.Sprintf("%q", d)
106+
}
107+
return d
108+
}
109+
110+
return fmt.Sprintf(
111+
pageTempl,
112+
createDate(2021, 10),
113+
createDate(2021, 11),
114+
createDate(2021, 12),
115+
createDate(2099, 13), // This test will fail in 2099 :-)
116+
)
117+
}
118+
119+
b.WithContent(
120+
// YAML
121+
"short-date-yaml-unqouted.en.md", createPageContent(pageTemplYaml, shortDateTempl, false),
122+
"short-date-yaml-unqouted.nn.md", createPageContent(pageTemplYaml, shortDateTempl, false),
123+
"short-date-yaml-qouted.en.md", createPageContent(pageTemplYaml, shortDateTempl, true),
124+
"short-date-yaml-qouted.nn.md", createPageContent(pageTemplYaml, shortDateTempl, true),
125+
"long-date-yaml-unqouted.en.md", createPageContent(pageTemplYaml, longDateTempl, false),
126+
"long-date-yaml-unqouted.nn.md", createPageContent(pageTemplYaml, longDateTempl, false),
127+
128+
// TOML
129+
"short-date-toml-unqouted.en.md", createPageContent(pageTemplTOML, shortDateTempl, false),
130+
"short-date-toml-unqouted.nn.md", createPageContent(pageTemplTOML, shortDateTempl, false),
131+
"short-date-toml-qouted.en.md", createPageContent(pageTemplTOML, shortDateTempl, true),
132+
"short-date-toml-qouted.nn.md", createPageContent(pageTemplTOML, shortDateTempl, true),
133+
)
134+
135+
const datesTempl = `
136+
Date: {{ .Date | safeHTML }}
137+
Lastmod: {{ .Lastmod | safeHTML }}
138+
PublishDate: {{ .PublishDate | safeHTML }}
139+
ExpiryDate: {{ .ExpiryDate | safeHTML }}
140+
141+
`
142+
143+
b.WithTemplatesAdded(
144+
"_default/single.html", datesTempl,
145+
)
146+
147+
b.Build(BuildCfg{})
148+
149+
expectShortDateEn := `
150+
Date: 2021-07-10 00:00:00 +0000 UTC
151+
Lastmod: 2021-07-11 00:00:00 +0000 UTC
152+
PublishDate: 2021-07-12 00:00:00 +0000 UTC
153+
ExpiryDate: 2099-07-13 00:00:00 +0000 UTC`
154+
155+
expectShortDateNn := strings.ReplaceAll(expectShortDateEn, "+0000 UTC", "-0400 AST")
156+
157+
expectLongDateEn := `
158+
Date: 2021-07-10 15:28:01 +0000 UTC
159+
Lastmod: 2021-07-11 15:28:01 +0000 UTC
160+
PublishDate: 2021-07-12 15:28:01 +0000 UTC
161+
ExpiryDate: 2099-07-13 15:28:01 +0000 UTC`
162+
163+
expectLongDateNn := strings.ReplaceAll(expectLongDateEn, "+0000 UTC", "-0400 AST")
164+
165+
// TODO(bep) create a common proposal for go-yaml, go-toml
166+
// for a custom date parser hook to handle these time zones.
167+
// JSON is omitted from this test as JSON does no (to my knowledge)
168+
// have date literals.
169+
170+
// YAML
171+
// Note: This is with go-yaml v2, I suspect v3 will fail with the unquouted values.
172+
b.AssertFileContent("public/en/short-date-yaml-unqouted/index.html", expectShortDateEn)
173+
b.AssertFileContent("public/nn/short-date-yaml-unqouted/index.html", expectShortDateNn)
174+
b.AssertFileContent("public/en/short-date-yaml-qouted/index.html", expectShortDateEn)
175+
b.AssertFileContent("public/nn/short-date-yaml-qouted/index.html", expectShortDateNn)
176+
177+
b.AssertFileContent("public/en/long-date-yaml-unqouted/index.html", expectLongDateEn)
178+
b.AssertFileContent("public/nn/long-date-yaml-unqouted/index.html", expectLongDateNn)
179+
180+
// TOML
181+
// These fails: TOML (Burnt Sushi) defaults to local timezone.
182+
// TODO(bep) check go-toml
183+
// b.AssertFileContent("public/en/short-date-toml-unqouted/index.html", expectShortDateEn)
184+
// b.AssertFileContent("public/nn/short-date-toml-unqouted/index.html", expectShortDateNn)
185+
b.AssertFileContent("public/en/short-date-toml-qouted/index.html", expectShortDateEn)
186+
b.AssertFileContent("public/nn/short-date-toml-qouted/index.html", expectShortDateNn)
187+
188+
}

hugolib/page__meta.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222
"sync"
2323
"time"
2424

25+
"github.com/gohugoio/hugo/langs"
26+
2527
"github.com/gobuffalo/flect"
2628
"github.com/gohugoio/hugo/markup/converter"
2729

@@ -396,6 +398,7 @@ func (pm *pageMeta) setMetadata(parentBucket *pagesMapBucket, p *pageState, fron
396398
BaseFilename: contentBaseName,
397399
ModTime: mtime,
398400
GitAuthorDate: gitAuthorDate,
401+
Location: langs.GetLocation(pm.s.Language()),
399402
}
400403

401404
// Handle the date separately

langs/language.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"sort"
1818
"strings"
1919
"sync"
20+
"time"
2021

2122
translators "github.com/bep/gotranslators"
2223
"github.com/go-playground/locales"
@@ -71,10 +72,13 @@ type Language struct {
7172
paramsMu sync.Mutex
7273
paramsSet bool
7374

74-
// Used for date formatting etc. We don't want this exported to the
75+
// Used for date formatting etc. We don't want these exported to the
7576
// templates.
7677
// TODO(bep) do the same for some of the others.
7778
translator locales.Translator
79+
80+
locationInit sync.Once
81+
location *time.Location
7882
}
7983

8084
func (l *Language) String() string {
@@ -244,9 +248,25 @@ func (l *Language) IsSet(key string) bool {
244248
return l.Cfg.IsSet(key)
245249
}
246250

251+
func (l *Language) getLocation() *time.Location {
252+
l.locationInit.Do(func() {
253+
location, err := time.LoadLocation(l.GetString("timeZone"))
254+
if err != nil {
255+
location = time.UTC
256+
}
257+
l.location = location
258+
})
259+
260+
return l.location
261+
}
262+
247263
// Internal access to unexported Language fields.
248264
// This construct is to prevent them from leaking to the templates.
249265

250266
func GetTranslator(l *Language) locales.Translator {
251267
return l.translator
252268
}
269+
270+
func GetLocation(l *Language) *time.Location {
271+
return l.getLocation()
272+
}

resources/page/pagemeta/page_frontmatter.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ type FrontMatterDescriptor struct {
7070

7171
// This is the Page's Slug etc.
7272
PageURLs *URLPath
73+
74+
// The Location to use to parse dates without time zone info.
75+
Location *time.Location
7376
}
7477

7578
var dateFieldAliases = map[string][]string{
@@ -119,17 +122,15 @@ func (f FrontMatterHandler) IsDateKey(key string) bool {
119122
// A Zero date is a signal that the name can not be parsed.
120123
// This follows the format as outlined in Jekyll, https://jekyllrb.com/docs/posts/:
121124
// "Where YEAR is a four-digit number, MONTH and DAY are both two-digit numbers"
122-
func dateAndSlugFromBaseFilename(name string) (time.Time, string) {
125+
func dateAndSlugFromBaseFilename(location *time.Location, name string) (time.Time, string) {
123126
withoutExt, _ := paths.FileAndExt(name)
124127

125128
if len(withoutExt) < 10 {
126129
// This can not be a date.
127130
return time.Time{}, ""
128131
}
129132

130-
// Note: Hugo currently have no custom timezone support.
131-
// We will have to revisit this when that is in place.
132-
d, err := time.Parse("2006-01-02", withoutExt[:10])
133+
d, err := cast.ToTimeInDefaultLocationE(withoutExt[:10], location)
133134
if err != nil {
134135
return time.Time{}, ""
135136
}
@@ -370,7 +371,7 @@ func (f *frontmatterFieldHandlers) newDateFieldHandler(key string, setter func(d
370371
return false, nil
371372
}
372373

373-
date, err := cast.ToTimeE(v)
374+
date, err := cast.ToTimeInDefaultLocationE(v, d.Location)
374375
if err != nil {
375376
return false, nil
376377
}
@@ -388,7 +389,7 @@ func (f *frontmatterFieldHandlers) newDateFieldHandler(key string, setter func(d
388389

389390
func (f *frontmatterFieldHandlers) newDateFilenameHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
390391
return func(d *FrontMatterDescriptor) (bool, error) {
391-
date, slug := dateAndSlugFromBaseFilename(d.BaseFilename)
392+
date, slug := dateAndSlugFromBaseFilename(d.Location, d.BaseFilename)
392393
if date.IsZero() {
393394
return false, nil
394395
}

resources/page/pagemeta/page_frontmatter_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func TestDateAndSlugFromBaseFilename(t *testing.T) {
5353
expecteFDate, err := time.Parse("2006-01-02", test.date)
5454
c.Assert(err, qt.IsNil)
5555

56-
gotDate, gotSlug := dateAndSlugFromBaseFilename(test.name)
56+
gotDate, gotSlug := dateAndSlugFromBaseFilename(time.UTC, test.name)
5757

5858
c.Assert(gotDate, qt.Equals, expecteFDate)
5959
c.Assert(gotSlug, qt.Equals, test.slug)
@@ -67,6 +67,7 @@ func newTestFd() *FrontMatterDescriptor {
6767
Params: make(map[string]interface{}),
6868
Dates: &resource.Dates{},
6969
PageURLs: &URLPath{},
70+
Location: time.UTC,
7071
}
7172
}
7273

tpl/time/init.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func init() {
2626
if d.Language == nil {
2727
panic("Language must be set")
2828
}
29-
ctx := New(langs.GetTranslator(d.Language))
29+
ctx := New(langs.GetTranslator(d.Language), langs.GetLocation(d.Language))
3030

3131
ns := &internal.TemplateFuncsNamespace{
3232
Name: name,

0 commit comments

Comments
 (0)