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 }