page_frontmatter.go (11348B)
1 // Copyright 2019 The Hugo Authors. All rights reserved.
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 // http://www.apache.org/licenses/LICENSE-2.0
7 //
8 // Unless required by applicable law or agreed to in writing, software
9 // distributed under the License is distributed on an "AS IS" BASIS,
10 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 // See the License for the specific language governing permissions and
12 // limitations under the License.
13
14 package pagemeta
15
16 import (
17 "strings"
18 "time"
19
20 "github.com/gohugoio/hugo/common/htime"
21 "github.com/gohugoio/hugo/common/paths"
22
23 "github.com/gohugoio/hugo/common/loggers"
24 "github.com/gohugoio/hugo/helpers"
25 "github.com/gohugoio/hugo/resources/resource"
26
27 "github.com/gohugoio/hugo/config"
28 "github.com/spf13/cast"
29 )
30
31 // FrontMatterHandler maps front matter into Page fields and .Params.
32 // Note that we currently have only extracted the date logic.
33 type FrontMatterHandler struct {
34 fmConfig frontmatterConfig
35
36 dateHandler frontMatterFieldHandler
37 lastModHandler frontMatterFieldHandler
38 publishDateHandler frontMatterFieldHandler
39 expiryDateHandler frontMatterFieldHandler
40
41 // A map of all date keys configured, including any custom.
42 allDateKeys map[string]bool
43
44 logger loggers.Logger
45 }
46
47 // FrontMatterDescriptor describes how to handle front matter for a given Page.
48 // It has pointers to values in the receiving page which gets updated.
49 type FrontMatterDescriptor struct {
50
51 // This the Page's front matter.
52 Frontmatter map[string]any
53
54 // This is the Page's base filename (BaseFilename), e.g. page.md., or
55 // if page is a leaf bundle, the bundle folder name (ContentBaseName).
56 BaseFilename string
57
58 // The content file's mod time.
59 ModTime time.Time
60
61 // May be set from the author date in Git.
62 GitAuthorDate time.Time
63
64 // The below are pointers to values on Page and will be modified.
65
66 // This is the Page's params.
67 Params map[string]any
68
69 // This is the Page's dates.
70 Dates *resource.Dates
71
72 // This is the Page's Slug etc.
73 PageURLs *URLPath
74
75 // The Location to use to parse dates without time zone info.
76 Location *time.Location
77 }
78
79 var dateFieldAliases = map[string][]string{
80 fmDate: {},
81 fmLastmod: {"modified"},
82 fmPubDate: {"pubdate", "published"},
83 fmExpiryDate: {"unpublishdate"},
84 }
85
86 // HandleDates updates all the dates given the current configuration and the
87 // supplied front matter params. Note that this requires all lower-case keys
88 // in the params map.
89 func (f FrontMatterHandler) HandleDates(d *FrontMatterDescriptor) error {
90 if d.Dates == nil {
91 panic("missing dates")
92 }
93
94 if f.dateHandler == nil {
95 panic("missing date handler")
96 }
97
98 if _, err := f.dateHandler(d); err != nil {
99 return err
100 }
101
102 if _, err := f.lastModHandler(d); err != nil {
103 return err
104 }
105
106 if _, err := f.publishDateHandler(d); err != nil {
107 return err
108 }
109
110 if _, err := f.expiryDateHandler(d); err != nil {
111 return err
112 }
113
114 return nil
115 }
116
117 // IsDateKey returns whether the given front matter key is considered a date by the current
118 // configuration.
119 func (f FrontMatterHandler) IsDateKey(key string) bool {
120 return f.allDateKeys[key]
121 }
122
123 // A Zero date is a signal that the name can not be parsed.
124 // This follows the format as outlined in Jekyll, https://jekyllrb.com/docs/posts/:
125 // "Where YEAR is a four-digit number, MONTH and DAY are both two-digit numbers"
126 func dateAndSlugFromBaseFilename(location *time.Location, name string) (time.Time, string) {
127 withoutExt, _ := paths.FileAndExt(name)
128
129 if len(withoutExt) < 10 {
130 // This can not be a date.
131 return time.Time{}, ""
132 }
133
134 d, err := htime.ToTimeInDefaultLocationE(withoutExt[:10], location)
135 if err != nil {
136 return time.Time{}, ""
137 }
138
139 // Be a little lenient with the format here.
140 slug := strings.Trim(withoutExt[10:], " -_")
141
142 return d, slug
143 }
144
145 type frontMatterFieldHandler func(d *FrontMatterDescriptor) (bool, error)
146
147 func (f FrontMatterHandler) newChainedFrontMatterFieldHandler(handlers ...frontMatterFieldHandler) frontMatterFieldHandler {
148 return func(d *FrontMatterDescriptor) (bool, error) {
149 for _, h := range handlers {
150 // First successful handler wins.
151 success, err := h(d)
152 if err != nil {
153 f.logger.Errorln(err)
154 } else if success {
155 return true, nil
156 }
157 }
158 return false, nil
159 }
160 }
161
162 type frontmatterConfig struct {
163 date []string
164 lastmod []string
165 publishDate []string
166 expiryDate []string
167 }
168
169 const (
170 // These are all the date handler identifiers
171 // All identifiers not starting with a ":" maps to a front matter parameter.
172 fmDate = "date"
173 fmPubDate = "publishdate"
174 fmLastmod = "lastmod"
175 fmExpiryDate = "expirydate"
176
177 // Gets date from filename, e.g 218-02-22-mypage.md
178 fmFilename = ":filename"
179
180 // Gets date from file OS mod time.
181 fmModTime = ":filemodtime"
182
183 // Gets date from Git
184 fmGitAuthorDate = ":git"
185 )
186
187 // This is the config you get when doing nothing.
188 func newDefaultFrontmatterConfig() frontmatterConfig {
189 return frontmatterConfig{
190 date: []string{fmDate, fmPubDate, fmLastmod},
191 lastmod: []string{fmGitAuthorDate, fmLastmod, fmDate, fmPubDate},
192 publishDate: []string{fmPubDate, fmDate},
193 expiryDate: []string{fmExpiryDate},
194 }
195 }
196
197 func newFrontmatterConfig(cfg config.Provider) (frontmatterConfig, error) {
198 c := newDefaultFrontmatterConfig()
199 defaultConfig := c
200
201 if cfg.IsSet("frontmatter") {
202 fm := cfg.GetStringMap("frontmatter")
203 for k, v := range fm {
204 loki := strings.ToLower(k)
205 switch loki {
206 case fmDate:
207 c.date = toLowerSlice(v)
208 case fmPubDate:
209 c.publishDate = toLowerSlice(v)
210 case fmLastmod:
211 c.lastmod = toLowerSlice(v)
212 case fmExpiryDate:
213 c.expiryDate = toLowerSlice(v)
214 }
215 }
216 }
217
218 expander := func(c, d []string) []string {
219 out := expandDefaultValues(c, d)
220 out = addDateFieldAliases(out)
221 return out
222 }
223
224 c.date = expander(c.date, defaultConfig.date)
225 c.publishDate = expander(c.publishDate, defaultConfig.publishDate)
226 c.lastmod = expander(c.lastmod, defaultConfig.lastmod)
227 c.expiryDate = expander(c.expiryDate, defaultConfig.expiryDate)
228
229 return c, nil
230 }
231
232 func addDateFieldAliases(values []string) []string {
233 var complete []string
234
235 for _, v := range values {
236 complete = append(complete, v)
237 if aliases, found := dateFieldAliases[v]; found {
238 complete = append(complete, aliases...)
239 }
240 }
241 return helpers.UniqueStringsReuse(complete)
242 }
243
244 func expandDefaultValues(values []string, defaults []string) []string {
245 var out []string
246 for _, v := range values {
247 if v == ":default" {
248 out = append(out, defaults...)
249 } else {
250 out = append(out, v)
251 }
252 }
253 return out
254 }
255
256 func toLowerSlice(in any) []string {
257 out := cast.ToStringSlice(in)
258 for i := 0; i < len(out); i++ {
259 out[i] = strings.ToLower(out[i])
260 }
261
262 return out
263 }
264
265 // NewFrontmatterHandler creates a new FrontMatterHandler with the given logger and configuration.
266 // If no logger is provided, one will be created.
267 func NewFrontmatterHandler(logger loggers.Logger, cfg config.Provider) (FrontMatterHandler, error) {
268 if logger == nil {
269 logger = loggers.NewErrorLogger()
270 }
271
272 frontMatterConfig, err := newFrontmatterConfig(cfg)
273 if err != nil {
274 return FrontMatterHandler{}, err
275 }
276
277 allDateKeys := make(map[string]bool)
278 addKeys := func(vals []string) {
279 for _, k := range vals {
280 if !strings.HasPrefix(k, ":") {
281 allDateKeys[k] = true
282 }
283 }
284 }
285
286 addKeys(frontMatterConfig.date)
287 addKeys(frontMatterConfig.expiryDate)
288 addKeys(frontMatterConfig.lastmod)
289 addKeys(frontMatterConfig.publishDate)
290
291 f := FrontMatterHandler{logger: logger, fmConfig: frontMatterConfig, allDateKeys: allDateKeys}
292
293 if err := f.createHandlers(); err != nil {
294 return f, err
295 }
296
297 return f, nil
298 }
299
300 func (f *FrontMatterHandler) createHandlers() error {
301 var err error
302
303 if f.dateHandler, err = f.createDateHandler(f.fmConfig.date,
304 func(d *FrontMatterDescriptor, t time.Time) {
305 d.Dates.FDate = t
306 setParamIfNotSet(fmDate, t, d)
307 }); err != nil {
308 return err
309 }
310
311 if f.lastModHandler, err = f.createDateHandler(f.fmConfig.lastmod,
312 func(d *FrontMatterDescriptor, t time.Time) {
313 setParamIfNotSet(fmLastmod, t, d)
314 d.Dates.FLastmod = t
315 }); err != nil {
316 return err
317 }
318
319 if f.publishDateHandler, err = f.createDateHandler(f.fmConfig.publishDate,
320 func(d *FrontMatterDescriptor, t time.Time) {
321 setParamIfNotSet(fmPubDate, t, d)
322 d.Dates.FPublishDate = t
323 }); err != nil {
324 return err
325 }
326
327 if f.expiryDateHandler, err = f.createDateHandler(f.fmConfig.expiryDate,
328 func(d *FrontMatterDescriptor, t time.Time) {
329 setParamIfNotSet(fmExpiryDate, t, d)
330 d.Dates.FExpiryDate = t
331 }); err != nil {
332 return err
333 }
334
335 return nil
336 }
337
338 func setParamIfNotSet(key string, value any, d *FrontMatterDescriptor) {
339 if _, found := d.Params[key]; found {
340 return
341 }
342 d.Params[key] = value
343 }
344
345 func (f FrontMatterHandler) createDateHandler(identifiers []string, setter func(d *FrontMatterDescriptor, t time.Time)) (frontMatterFieldHandler, error) {
346 var h *frontmatterFieldHandlers
347 var handlers []frontMatterFieldHandler
348
349 for _, identifier := range identifiers {
350 switch identifier {
351 case fmFilename:
352 handlers = append(handlers, h.newDateFilenameHandler(setter))
353 case fmModTime:
354 handlers = append(handlers, h.newDateModTimeHandler(setter))
355 case fmGitAuthorDate:
356 handlers = append(handlers, h.newDateGitAuthorDateHandler(setter))
357 default:
358 handlers = append(handlers, h.newDateFieldHandler(identifier, setter))
359 }
360 }
361
362 return f.newChainedFrontMatterFieldHandler(handlers...), nil
363 }
364
365 type frontmatterFieldHandlers int
366
367 func (f *frontmatterFieldHandlers) newDateFieldHandler(key string, setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
368 return func(d *FrontMatterDescriptor) (bool, error) {
369 v, found := d.Frontmatter[key]
370
371 if !found {
372 return false, nil
373 }
374
375 date, err := htime.ToTimeInDefaultLocationE(v, d.Location)
376 if err != nil {
377 return false, nil
378 }
379
380 // We map several date keys to one, so, for example,
381 // "expirydate", "unpublishdate" will all set .ExpiryDate (first found).
382 setter(d, date)
383
384 // This is the params key as set in front matter.
385 d.Params[key] = date
386
387 return true, nil
388 }
389 }
390
391 func (f *frontmatterFieldHandlers) newDateFilenameHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
392 return func(d *FrontMatterDescriptor) (bool, error) {
393 date, slug := dateAndSlugFromBaseFilename(d.Location, d.BaseFilename)
394 if date.IsZero() {
395 return false, nil
396 }
397
398 setter(d, date)
399
400 if _, found := d.Frontmatter["slug"]; !found {
401 // Use slug from filename
402 d.PageURLs.Slug = slug
403 }
404
405 return true, nil
406 }
407 }
408
409 func (f *frontmatterFieldHandlers) newDateModTimeHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
410 return func(d *FrontMatterDescriptor) (bool, error) {
411 if d.ModTime.IsZero() {
412 return false, nil
413 }
414 setter(d, d.ModTime)
415 return true, nil
416 }
417 }
418
419 func (f *frontmatterFieldHandlers) newDateGitAuthorDateHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
420 return func(d *FrontMatterDescriptor) (bool, error) {
421 if d.GitAuthorDate.IsZero() {
422 return false, nil
423 }
424 setter(d, d.GitAuthorDate)
425 return true, nil
426 }
427 }