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  }