github.com/neohugo/neohugo@v0.123.8/resources/page/pagemeta/page_frontmatter.go (about) 1 // Copyright 2024 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/neohugo/neohugo/common/htime" 21 "github.com/neohugo/neohugo/common/loggers" 22 "github.com/neohugo/neohugo/common/maps" 23 "github.com/neohugo/neohugo/common/paths" 24 "github.com/neohugo/neohugo/resources/page" 25 26 "github.com/neohugo/neohugo/helpers" 27 28 "github.com/neohugo/neohugo/config" 29 "github.com/spf13/cast" 30 ) 31 32 type Dates struct { 33 Date time.Time 34 Lastmod time.Time 35 PublishDate time.Time 36 ExpiryDate time.Time 37 } 38 39 func (d Dates) IsDateOrLastModAfter(in Dates) bool { 40 return d.Date.After(in.Date) || d.Lastmod.After(in.Lastmod) 41 } 42 43 func (d *Dates) UpdateDateAndLastmodIfAfter(in Dates) { 44 if in.Date.After(d.Date) { 45 d.Date = in.Date 46 } 47 if in.Lastmod.After(d.Lastmod) { 48 d.Lastmod = in.Lastmod 49 } 50 } 51 52 func (d Dates) IsAllDatesZero() bool { 53 return d.Date.IsZero() && d.Lastmod.IsZero() && d.PublishDate.IsZero() && d.ExpiryDate.IsZero() 54 } 55 56 // PageConfig configures a Page, typically from front matter. 57 // Note that all the top level fields are reserved Hugo keywords. 58 // Any custom configuration needs to be set in the Params map. 59 type PageConfig struct { 60 Dates // Dates holds the four core dates for this page. 61 Title string // The title of the page. 62 LinkTitle string // The link title of the page. 63 Type string // The content type of the page. 64 Layout string // The layout to use for to render this page. 65 Markup string // The markup used in the content file. 66 Weight int // The weight of the page, used in sorting if set to a non-zero value. 67 Kind string // The kind of page, e.g. "page", "section", "home" etc. This is usually derived from the content path. 68 Path string // The canonical path to the page, e.g. /sect/mypage. Note: Leading slash, no trailing slash, no extensions or language identifiers. 69 URL string // The URL to the rendered page, e.g. /sect/mypage.html. 70 Lang string // The language code for this page. This is usually derived from the module mount or filename. 71 Slug string // The slug for this page. 72 Description string // The description for this page. 73 Summary string // The summary for this page. 74 Draft bool // Whether or not the content is a draft. 75 Headless bool // Whether or not the page should be rendered. 76 IsCJKLanguage bool // Whether or not the content is in a CJK language. 77 TranslationKey string // The translation key for this page. 78 Keywords []string // The keywords for this page. 79 Aliases []string // The aliases for this page. 80 Outputs []string // The output formats to render this page in. If not set, the site's configured output formats for this page kind will be used. 81 82 // These build options are set in the front matter, 83 // but not passed on to .Params. 84 Resources []map[string]any 85 Cascade map[page.PageMatcher]maps.Params // Only relevant for branch nodes. 86 Sitemap config.SitemapConfig 87 Build BuildConfig 88 89 // User defined params. 90 Params maps.Params 91 } 92 93 // FrontMatterHandler maps front matter into Page fields and .Params. 94 // Note that we currently have only extracted the date logic. 95 type FrontMatterHandler struct { 96 fmConfig FrontmatterConfig 97 98 dateHandler frontMatterFieldHandler 99 lastModHandler frontMatterFieldHandler 100 publishDateHandler frontMatterFieldHandler 101 expiryDateHandler frontMatterFieldHandler 102 103 // A map of all date keys configured, including any custom. 104 allDateKeys map[string]bool 105 106 logger loggers.Logger 107 } 108 109 // FrontMatterDescriptor describes how to handle front matter for a given Page. 110 // It has pointers to values in the receiving page which gets updated. 111 type FrontMatterDescriptor struct { 112 // This is the Page's base filename (BaseFilename), e.g. page.md., or 113 // if page is a leaf bundle, the bundle folder name (ContentBaseName). 114 BaseFilename string 115 116 // The content file's mod time. 117 ModTime time.Time 118 119 // May be set from the author date in Git. 120 GitAuthorDate time.Time 121 122 // The below will be modified. 123 PageConfig *PageConfig 124 125 // The Location to use to parse dates without time zone info. 126 Location *time.Location 127 } 128 129 var dateFieldAliases = map[string][]string{ 130 fmDate: {}, 131 fmLastmod: {"modified"}, 132 fmPubDate: {"pubdate", "published"}, 133 fmExpiryDate: {"unpublishdate"}, 134 } 135 136 // HandleDates updates all the dates given the current configuration and the 137 // supplied front matter params. Note that this requires all lower-case keys 138 // in the params map. 139 func (f FrontMatterHandler) HandleDates(d *FrontMatterDescriptor) error { 140 if d.PageConfig == nil { 141 panic("missing pageConfig") 142 } 143 144 if f.dateHandler == nil { 145 panic("missing date handler") 146 } 147 148 if _, err := f.dateHandler(d); err != nil { 149 return err 150 } 151 152 if _, err := f.lastModHandler(d); err != nil { 153 return err 154 } 155 156 if _, err := f.publishDateHandler(d); err != nil { 157 return err 158 } 159 160 if _, err := f.expiryDateHandler(d); err != nil { 161 return err 162 } 163 164 return nil 165 } 166 167 // IsDateKey returns whether the given front matter key is considered a date by the current 168 // configuration. 169 func (f FrontMatterHandler) IsDateKey(key string) bool { 170 return f.allDateKeys[key] 171 } 172 173 // A Zero date is a signal that the name can not be parsed. 174 // This follows the format as outlined in Jekyll, https://jekyllrb.com/docs/posts/: 175 // "Where YEAR is a four-digit number, MONTH and DAY are both two-digit numbers" 176 func dateAndSlugFromBaseFilename(location *time.Location, name string) (time.Time, string) { 177 withoutExt, _ := paths.FileAndExt(name) 178 179 if len(withoutExt) < 10 { 180 // This can not be a date. 181 return time.Time{}, "" 182 } 183 184 d, err := htime.ToTimeInDefaultLocationE(withoutExt[:10], location) 185 if err != nil { 186 return time.Time{}, "" 187 } 188 189 // Be a little lenient with the format here. 190 slug := strings.Trim(withoutExt[10:], " -_") 191 192 return d, slug 193 } 194 195 type frontMatterFieldHandler func(d *FrontMatterDescriptor) (bool, error) 196 197 func (f FrontMatterHandler) newChainedFrontMatterFieldHandler(handlers ...frontMatterFieldHandler) frontMatterFieldHandler { 198 return func(d *FrontMatterDescriptor) (bool, error) { 199 for _, h := range handlers { 200 // First successful handler wins. 201 success, err := h(d) 202 if err != nil { 203 f.logger.Errorln(err) 204 } else if success { 205 return true, nil 206 } 207 } 208 return false, nil 209 } 210 } 211 212 type FrontmatterConfig struct { 213 // Controls how the Date is set from front matter. 214 Date []string 215 // Controls how the Lastmod is set from front matter. 216 Lastmod []string 217 // Controls how the PublishDate is set from front matter. 218 PublishDate []string 219 // Controls how the ExpiryDate is set from front matter. 220 ExpiryDate []string 221 } 222 223 const ( 224 // These are all the date handler identifiers 225 // All identifiers not starting with a ":" maps to a front matter parameter. 226 fmDate = "date" 227 fmPubDate = "publishdate" 228 fmLastmod = "lastmod" 229 fmExpiryDate = "expirydate" 230 231 // Gets date from filename, e.g 218-02-22-mypage.md 232 fmFilename = ":filename" 233 234 // Gets date from file OS mod time. 235 fmModTime = ":filemodtime" 236 237 // Gets date from Git 238 fmGitAuthorDate = ":git" 239 ) 240 241 // This is the config you get when doing nothing. 242 func newDefaultFrontmatterConfig() FrontmatterConfig { 243 return FrontmatterConfig{ 244 Date: []string{fmDate, fmPubDate, fmLastmod}, 245 Lastmod: []string{fmGitAuthorDate, fmLastmod, fmDate, fmPubDate}, 246 PublishDate: []string{fmPubDate, fmDate}, 247 ExpiryDate: []string{fmExpiryDate}, 248 } 249 } 250 251 func DecodeFrontMatterConfig(cfg config.Provider) (FrontmatterConfig, error) { 252 c := newDefaultFrontmatterConfig() 253 defaultConfig := c 254 255 if cfg.IsSet("frontmatter") { 256 fm := cfg.GetStringMap("frontmatter") 257 for k, v := range fm { 258 loki := strings.ToLower(k) 259 switch loki { 260 case fmDate: 261 c.Date = toLowerSlice(v) 262 case fmPubDate: 263 c.PublishDate = toLowerSlice(v) 264 case fmLastmod: 265 c.Lastmod = toLowerSlice(v) 266 case fmExpiryDate: 267 c.ExpiryDate = toLowerSlice(v) 268 } 269 } 270 } 271 272 expander := func(c, d []string) []string { 273 out := expandDefaultValues(c, d) 274 out = addDateFieldAliases(out) 275 return out 276 } 277 278 c.Date = expander(c.Date, defaultConfig.Date) 279 c.PublishDate = expander(c.PublishDate, defaultConfig.PublishDate) 280 c.Lastmod = expander(c.Lastmod, defaultConfig.Lastmod) 281 c.ExpiryDate = expander(c.ExpiryDate, defaultConfig.ExpiryDate) 282 283 return c, nil 284 } 285 286 func addDateFieldAliases(values []string) []string { 287 var complete []string 288 289 for _, v := range values { 290 complete = append(complete, v) 291 if aliases, found := dateFieldAliases[v]; found { 292 complete = append(complete, aliases...) 293 } 294 } 295 return helpers.UniqueStringsReuse(complete) 296 } 297 298 func expandDefaultValues(values []string, defaults []string) []string { 299 var out []string 300 for _, v := range values { 301 if v == ":default" { 302 out = append(out, defaults...) 303 } else { 304 out = append(out, v) 305 } 306 } 307 return out 308 } 309 310 func toLowerSlice(in any) []string { 311 out := cast.ToStringSlice(in) 312 for i := 0; i < len(out); i++ { 313 out[i] = strings.ToLower(out[i]) 314 } 315 316 return out 317 } 318 319 // NewFrontmatterHandler creates a new FrontMatterHandler with the given logger and configuration. 320 // If no logger is provided, one will be created. 321 func NewFrontmatterHandler(logger loggers.Logger, frontMatterConfig FrontmatterConfig) (FrontMatterHandler, error) { 322 if logger == nil { 323 logger = loggers.NewDefault() 324 } 325 326 allDateKeys := make(map[string]bool) 327 addKeys := func(vals []string) { 328 for _, k := range vals { 329 if !strings.HasPrefix(k, ":") { 330 allDateKeys[k] = true 331 } 332 } 333 } 334 335 addKeys(frontMatterConfig.Date) 336 addKeys(frontMatterConfig.ExpiryDate) 337 addKeys(frontMatterConfig.Lastmod) 338 addKeys(frontMatterConfig.PublishDate) 339 340 f := FrontMatterHandler{logger: logger, fmConfig: frontMatterConfig, allDateKeys: allDateKeys} 341 342 if err := f.createHandlers(); err != nil { 343 return f, err 344 } 345 346 return f, nil 347 } 348 349 func (f *FrontMatterHandler) createHandlers() error { 350 var err error 351 352 if f.dateHandler, err = f.createDateHandler(f.fmConfig.Date, 353 func(d *FrontMatterDescriptor, t time.Time) { 354 d.PageConfig.Date = t 355 setParamIfNotSet(fmDate, t, d) 356 }); err != nil { 357 return err 358 } 359 360 if f.lastModHandler, err = f.createDateHandler(f.fmConfig.Lastmod, 361 func(d *FrontMatterDescriptor, t time.Time) { 362 setParamIfNotSet(fmLastmod, t, d) 363 d.PageConfig.Lastmod = t 364 }); err != nil { 365 return err 366 } 367 368 if f.publishDateHandler, err = f.createDateHandler(f.fmConfig.PublishDate, 369 func(d *FrontMatterDescriptor, t time.Time) { 370 setParamIfNotSet(fmPubDate, t, d) 371 d.PageConfig.PublishDate = t 372 }); err != nil { 373 return err 374 } 375 376 if f.expiryDateHandler, err = f.createDateHandler(f.fmConfig.ExpiryDate, 377 func(d *FrontMatterDescriptor, t time.Time) { 378 setParamIfNotSet(fmExpiryDate, t, d) 379 d.PageConfig.ExpiryDate = t 380 }); err != nil { 381 return err 382 } 383 384 return nil 385 } 386 387 func setParamIfNotSet(key string, value any, d *FrontMatterDescriptor) { 388 if _, found := d.PageConfig.Params[key]; found { 389 return 390 } 391 d.PageConfig.Params[key] = value 392 } 393 394 func (f FrontMatterHandler) createDateHandler(identifiers []string, setter func(d *FrontMatterDescriptor, t time.Time)) (frontMatterFieldHandler, error) { 395 var h *frontmatterFieldHandlers 396 var handlers []frontMatterFieldHandler 397 398 for _, identifier := range identifiers { 399 switch identifier { 400 case fmFilename: 401 handlers = append(handlers, h.newDateFilenameHandler(setter)) 402 case fmModTime: 403 handlers = append(handlers, h.newDateModTimeHandler(setter)) 404 case fmGitAuthorDate: 405 handlers = append(handlers, h.newDateGitAuthorDateHandler(setter)) 406 default: 407 handlers = append(handlers, h.newDateFieldHandler(identifier, setter)) 408 } 409 } 410 411 return f.newChainedFrontMatterFieldHandler(handlers...), nil 412 } 413 414 type frontmatterFieldHandlers int 415 416 func (f *frontmatterFieldHandlers) newDateFieldHandler(key string, setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler { 417 return func(d *FrontMatterDescriptor) (bool, error) { 418 v, found := d.PageConfig.Params[key] 419 420 if !found { 421 return false, nil 422 } 423 424 date, err := htime.ToTimeInDefaultLocationE(v, d.Location) 425 if err != nil { 426 return false, nil 427 } 428 429 // We map several date keys to one, so, for example, 430 // "expirydate", "unpublishdate" will all set .ExpiryDate (first found). 431 setter(d, date) 432 433 // This is the params key as set in front matter. 434 d.PageConfig.Params[key] = date 435 436 return true, nil 437 } 438 } 439 440 func (f *frontmatterFieldHandlers) newDateFilenameHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler { 441 return func(d *FrontMatterDescriptor) (bool, error) { 442 date, slug := dateAndSlugFromBaseFilename(d.Location, d.BaseFilename) 443 if date.IsZero() { 444 return false, nil 445 } 446 447 setter(d, date) 448 449 if _, found := d.PageConfig.Params["slug"]; !found { 450 // Use slug from filename 451 d.PageConfig.Slug = slug 452 } 453 454 return true, nil 455 } 456 } 457 458 func (f *frontmatterFieldHandlers) newDateModTimeHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler { 459 return func(d *FrontMatterDescriptor) (bool, error) { 460 if d.ModTime.IsZero() { 461 return false, nil 462 } 463 setter(d, d.ModTime) 464 return true, nil 465 } 466 } 467 468 func (f *frontmatterFieldHandlers) newDateGitAuthorDateHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler { 469 return func(d *FrontMatterDescriptor) (bool, error) { 470 if d.GitAuthorDate.IsZero() { 471 return false, nil 472 } 473 setter(d, d.GitAuthorDate) 474 return true, nil 475 } 476 }