hugo/hugolib/page__meta.go
Bjørn Erik Pedersen 6dedb4efc7 Add the [params] concept to front matter
This is deliberately very simple, but should not break anything. We need to introduce this in baby steps, but this should allow us to introduce this in the documentation.

Note that the `params` section's key/values will be added to `.Params` last. This means that you can have different values for "Hugo's summary" and the custom ".Params.summary" if you want to.

Updates #11055
2024-01-28 21:38:40 +01:00

892 lines
21 KiB
Go

// Copyright 2019 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package hugolib
import (
"context"
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/gobuffalo/flect"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/langs"
"github.com/gohugoio/hugo/markup/converter"
xmaps "golang.org/x/exp/maps"
"github.com/gohugoio/hugo/related"
"github.com/gohugoio/hugo/source"
"github.com/gohugoio/hugo/common/hugo"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/config"
"github.com/gohugoio/hugo/helpers"
"github.com/gohugoio/hugo/output"
"github.com/gohugoio/hugo/resources/kinds"
"github.com/gohugoio/hugo/resources/page"
"github.com/gohugoio/hugo/resources/page/pagemeta"
"github.com/gohugoio/hugo/resources/resource"
"github.com/spf13/cast"
)
var cjkRe = regexp.MustCompile(`\p{Han}|\p{Hangul}|\p{Hiragana}|\p{Katakana}`)
type pageMeta struct {
kind string // Page kind.
term string // Set for kind == KindTerm.
singular string // Set for kind == KindTerm and kind == KindTaxonomy.
resource.Staler
pageMetaParams
pageMetaFrontMatter
// Set for standalone pages, e.g. robotsTXT.
standaloneOutputFormat output.Format
resourcePath string // Set for bundled pages; path relative to its bundle root.
bundled bool // Set if this page is bundled inside another.
pathInfo *paths.Path // Always set. This the canonical path to the Page.
f *source.File
s *Site // The site this page belongs to.
}
// Prepare for a rebuild of the data passed in from front matter.
func (m *pageMeta) setMetaPostPrepareRebuild() {
params := xmaps.Clone[map[string]any](m.paramsOriginal)
m.pageMetaParams.params = params
m.pageMetaFrontMatter = pageMetaFrontMatter{}
}
type pageMetaParams struct {
setMetaPostCount int
setMetaPostCascadeChanged bool
params map[string]any // Params contains configuration defined in the params section of page frontmatter.
cascade map[page.PageMatcher]maps.Params // cascade contains default configuration to be cascaded downwards.
// These are only set in watch mode.
datesOriginal pageMetaDates
paramsOriginal map[string]any // contains the original params as defined in the front matter.
cascadeOriginal map[page.PageMatcher]maps.Params // contains the original cascade as defined in the front matter.
}
// From page front matter.
type pageMetaFrontMatter struct {
draft bool // Only published when running with -D flag
title string
linkTitle string
summary string
weight int
markup string
contentType string // type in front matter.
isCJKLanguage bool // whether the content is in a CJK language.
layout string
aliases []string
description string
keywords []string
translationKey string // maps to translation(s) of this page.
buildConfig pagemeta.BuildConfig
configuredOutputFormats output.Formats // outputs defiend in front matter.
pageMetaDates // The 4 front matter dates that Hugo cares about.
resourcesMetadata []map[string]any // Raw front matter metadata that is going to be assigned to the page resources.
sitemap config.SitemapConfig // Sitemap overrides from front matter.
urlPaths pagemeta.URLPath
}
func (m *pageMetaParams) init(preserveOringal bool) {
if preserveOringal {
m.paramsOriginal = xmaps.Clone[maps.Params](m.params)
m.cascadeOriginal = xmaps.Clone[map[page.PageMatcher]maps.Params](m.cascade)
}
}
func (p *pageMeta) Aliases() []string {
return p.aliases
}
func (p *pageMeta) Author() page.Author {
hugo.Deprecate(".Author", "Use taxonomies.", "v0.98.0")
authors := p.Authors()
for _, author := range authors {
return author
}
return page.Author{}
}
func (p *pageMeta) Authors() page.AuthorList {
hugo.Deprecate(".Author", "Use taxonomies.", "v0.112.0")
return nil
}
func (p *pageMeta) BundleType() string {
switch p.pathInfo.BundleType() {
case paths.PathTypeLeaf:
return "leaf"
case paths.PathTypeBranch:
return "branch"
default:
return ""
}
}
func (p *pageMeta) Description() string {
return p.description
}
func (p *pageMeta) Lang() string {
return p.s.Lang()
}
func (p *pageMeta) Draft() bool {
return p.draft
}
func (p *pageMeta) File() *source.File {
return p.f
}
func (p *pageMeta) IsHome() bool {
return p.Kind() == kinds.KindHome
}
func (p *pageMeta) Keywords() []string {
return p.keywords
}
func (p *pageMeta) Kind() string {
return p.kind
}
func (p *pageMeta) Layout() string {
return p.layout
}
func (p *pageMeta) LinkTitle() string {
if p.linkTitle != "" {
return p.linkTitle
}
return p.Title()
}
func (p *pageMeta) Name() string {
if p.resourcePath != "" {
return p.resourcePath
}
if p.kind == kinds.KindTerm {
return p.pathInfo.Unmormalized().BaseNameNoIdentifier()
}
return p.Title()
}
func (p *pageMeta) IsNode() bool {
return !p.IsPage()
}
func (p *pageMeta) IsPage() bool {
return p.Kind() == kinds.KindPage
}
// Param is a convenience method to do lookups in Page's and Site's Params map,
// in that order.
//
// This method is also implemented on SiteInfo.
// TODO(bep) interface
func (p *pageMeta) Param(key any) (any, error) {
return resource.Param(p, p.s.Params(), key)
}
func (p *pageMeta) Params() maps.Params {
return p.params
}
func (p *pageMeta) Path() string {
return p.pathInfo.Base()
}
func (p *pageMeta) PathInfo() *paths.Path {
return p.pathInfo
}
// RelatedKeywords implements the related.Document interface needed for fast page searches.
func (p *pageMeta) RelatedKeywords(cfg related.IndexConfig) ([]related.Keyword, error) {
v, err := p.Param(cfg.Name)
if err != nil {
return nil, err
}
return cfg.ToKeywords(v)
}
func (p *pageMeta) IsSection() bool {
return p.Kind() == kinds.KindSection
}
func (p *pageMeta) Section() string {
return p.pathInfo.Section()
}
func (p *pageMeta) Sitemap() config.SitemapConfig {
return p.sitemap
}
func (p *pageMeta) Title() string {
return p.title
}
const defaultContentType = "page"
func (p *pageMeta) Type() string {
if p.contentType != "" {
return p.contentType
}
if sect := p.Section(); sect != "" {
return sect
}
return defaultContentType
}
func (p *pageMeta) Weight() int {
return p.weight
}
func (ps *pageState) setMetaPre() error {
pm := ps.m
p := ps
frontmatter := p.content.parseInfo.frontMatter
watching := p.s.watching()
if frontmatter != nil {
// Needed for case insensitive fetching of params values
maps.PrepareParams(frontmatter)
pm.pageMetaParams.params = frontmatter
if p.IsNode() {
// Check for any cascade define on itself.
if cv, found := frontmatter["cascade"]; found {
var err error
cascade, err := page.DecodeCascade(cv)
if err != nil {
return err
}
pm.pageMetaParams.cascade = cascade
}
}
} else if pm.pageMetaParams.params == nil {
pm.pageMetaParams.params = make(maps.Params)
}
pm.pageMetaParams.init(watching)
return nil
}
func (ps *pageState) setMetaPost(cascade map[page.PageMatcher]maps.Params) error {
ps.m.setMetaPostCount++
var cascadeHashPre uint64
if ps.m.setMetaPostCount > 1 {
cascadeHashPre = identity.HashUint64(ps.m.cascade)
ps.m.cascade = xmaps.Clone[map[page.PageMatcher]maps.Params](ps.m.cascadeOriginal)
}
// Apply cascades first so they can be overriden later.
if cascade != nil {
if ps.m.cascade != nil {
for k, v := range cascade {
vv, found := ps.m.cascade[k]
if !found {
ps.m.cascade[k] = v
} else {
// Merge
for ck, cv := range v {
if _, found := vv[ck]; !found {
vv[ck] = cv
}
}
}
}
cascade = ps.m.cascade
} else {
ps.m.cascade = cascade
}
}
if cascade == nil {
cascade = ps.m.cascade
}
if ps.m.setMetaPostCount > 1 {
ps.m.setMetaPostCascadeChanged = cascadeHashPre != identity.HashUint64(ps.m.cascade)
if !ps.m.setMetaPostCascadeChanged {
// No changes, restore any value that may be changed by aggregation.
ps.m.dates = ps.m.datesOriginal.dates
return nil
}
ps.m.setMetaPostPrepareRebuild()
}
// Cascade is also applied to itself.
for m, v := range cascade {
if !m.Matches(ps) {
continue
}
for kk, vv := range v {
if _, found := ps.m.params[kk]; !found {
ps.m.params[kk] = vv
}
}
}
if err := ps.setMetaPostParams(); err != nil {
return err
}
if err := ps.m.applyDefaultValues(); err != nil {
return err
}
// Store away any original values that may be changed from aggregation.
ps.m.datesOriginal = ps.m.pageMetaDates
return nil
}
func (p *pageState) setMetaPostParams() error {
pm := p.m
var mtime time.Time
var contentBaseName string
if p.File() != nil {
contentBaseName = p.File().ContentBaseName()
if p.File().FileInfo() != nil {
mtime = p.File().FileInfo().ModTime()
}
}
var gitAuthorDate time.Time
if !p.gitInfo.IsZero() {
gitAuthorDate = p.gitInfo.AuthorDate
}
pm.pageMetaDates = pageMetaDates{}
pm.urlPaths = pagemeta.URLPath{}
descriptor := &pagemeta.FrontMatterDescriptor{
Params: pm.params,
Dates: &pm.pageMetaDates.dates,
PageURLs: &pm.urlPaths,
BaseFilename: contentBaseName,
ModTime: mtime,
GitAuthorDate: gitAuthorDate,
Location: langs.GetLocation(pm.s.Language()),
}
// Handle the date separately
// TODO(bep) we need to "do more" in this area so this can be split up and
// more easily tested without the Page, but the coupling is strong.
err := pm.s.frontmatterHandler.HandleDates(descriptor)
if err != nil {
p.s.Log.Errorf("Failed to handle dates for page %q: %s", p.pathOrTitle(), err)
}
pm.buildConfig, err = pagemeta.DecodeBuildConfig(pm.params["_build"])
if err != nil {
return err
}
var sitemapSet bool
var draft, published, isCJKLanguage *bool
var userParams map[string]any
for k, v := range pm.params {
loki := strings.ToLower(k)
if loki == "params" {
vv, err := maps.ToStringMapE(v)
if err != nil {
return err
}
userParams = vv
delete(pm.params, k)
continue
}
if loki == "published" { // Intentionally undocumented
vv, err := cast.ToBoolE(v)
if err == nil {
published = &vv
}
// published may also be a date
continue
}
if pm.s.frontmatterHandler.IsDateKey(loki) {
continue
}
switch loki {
case "title":
pm.title = cast.ToString(v)
pm.params[loki] = pm.title
case "linktitle":
pm.linkTitle = cast.ToString(v)
pm.params[loki] = pm.linkTitle
case "summary":
pm.summary = cast.ToString(v)
pm.params[loki] = pm.summary
case "description":
pm.description = cast.ToString(v)
pm.params[loki] = pm.description
case "slug":
// Don't start or end with a -
pm.urlPaths.Slug = strings.Trim(cast.ToString(v), "-")
pm.params[loki] = pm.Slug()
case "url":
url := cast.ToString(v)
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
return fmt.Errorf("URLs with protocol (http*) not supported: %q. In page %q", url, p.pathOrTitle())
}
pm.urlPaths.URL = url
pm.params[loki] = url
case "type":
pm.contentType = cast.ToString(v)
pm.params[loki] = pm.contentType
case "keywords":
pm.keywords = cast.ToStringSlice(v)
pm.params[loki] = pm.keywords
case "headless":
// Legacy setting for leaf bundles.
// This is since Hugo 0.63 handled in a more general way for all
// pages.
isHeadless := cast.ToBool(v)
pm.params[loki] = isHeadless
if p.File().TranslationBaseName() == "index" && isHeadless {
pm.buildConfig.List = pagemeta.Never
pm.buildConfig.Render = pagemeta.Never
}
case "outputs":
o := cast.ToStringSlice(v)
// lower case names:
for i, s := range o {
o[i] = strings.ToLower(s)
}
if len(o) > 0 {
// Output formats are explicitly set in front matter, use those.
outFormats, err := p.s.conf.OutputFormats.Config.GetByNames(o...)
if err != nil {
p.s.Log.Errorf("Failed to resolve output formats: %s", err)
} else {
pm.configuredOutputFormats = outFormats
pm.params[loki] = outFormats
}
}
case "draft":
draft = new(bool)
*draft = cast.ToBool(v)
case "layout":
pm.layout = cast.ToString(v)
pm.params[loki] = pm.layout
case "markup":
pm.markup = cast.ToString(v)
pm.params[loki] = pm.markup
case "weight":
pm.weight = cast.ToInt(v)
pm.params[loki] = pm.weight
case "aliases":
pm.aliases = cast.ToStringSlice(v)
for i, alias := range pm.aliases {
if strings.HasPrefix(alias, "http://") || strings.HasPrefix(alias, "https://") {
return fmt.Errorf("http* aliases not supported: %q", alias)
}
pm.aliases[i] = filepath.ToSlash(alias)
}
pm.params[loki] = pm.aliases
case "sitemap":
p.m.sitemap, err = config.DecodeSitemap(p.s.conf.Sitemap, maps.ToStringMap(v))
if err != nil {
return fmt.Errorf("failed to decode sitemap config in front matter: %s", err)
}
pm.params[loki] = p.m.sitemap
sitemapSet = true
case "iscjklanguage":
isCJKLanguage = new(bool)
*isCJKLanguage = cast.ToBool(v)
case "translationkey":
pm.translationKey = cast.ToString(v)
pm.params[loki] = pm.translationKey
case "resources":
var resources []map[string]any
handled := true
switch vv := v.(type) {
case []map[any]any:
for _, vvv := range vv {
resources = append(resources, maps.ToStringMap(vvv))
}
case []map[string]any:
resources = append(resources, vv...)
case []any:
for _, vvv := range vv {
switch vvvv := vvv.(type) {
case map[any]any:
resources = append(resources, maps.ToStringMap(vvvv))
case map[string]any:
resources = append(resources, vvvv)
}
}
default:
handled = false
}
if handled {
pm.params[loki] = resources
pm.resourcesMetadata = resources
break
}
fallthrough
default:
// If not one of the explicit values, store in Params
switch vv := v.(type) {
case []any:
if len(vv) > 0 {
allStrings := true
for _, vvv := range vv {
if _, ok := vvv.(string); !ok {
allStrings = false
break
}
}
if allStrings {
// We need tags, keywords etc. to be []string, not []interface{}.
a := make([]string, len(vv))
for i, u := range vv {
a[i] = cast.ToString(u)
}
pm.params[loki] = a
} else {
pm.params[loki] = vv
}
} else {
pm.params[loki] = []string{}
}
default:
pm.params[loki] = vv
}
}
}
for k, v := range userParams {
pm.params[strings.ToLower(k)] = v
}
if !sitemapSet {
pm.sitemap = p.s.conf.Sitemap
}
pm.markup = p.s.ContentSpec.ResolveMarkup(pm.markup)
if draft != nil && published != nil {
pm.draft = *draft
p.m.s.Log.Warnf("page %q has both draft and published settings in its frontmatter. Using draft.", p.File().Filename())
} else if draft != nil {
pm.draft = *draft
} else if published != nil {
pm.draft = !*published
}
pm.params["draft"] = pm.draft
if isCJKLanguage != nil {
pm.isCJKLanguage = *isCJKLanguage
} else if p.s.conf.HasCJKLanguage && p.content.openSource != nil {
if cjkRe.Match(p.content.mustSource()) {
pm.isCJKLanguage = true
} else {
pm.isCJKLanguage = false
}
}
pm.params["iscjklanguage"] = p.m.isCJKLanguage
return nil
}
// shouldList returns whether this page should be included in the list of pages.
// glogal indicates site.Pages etc.
func (p *pageMeta) shouldList(global bool) bool {
if p.isStandalone() {
// Never list 404, sitemap and similar.
return false
}
switch p.buildConfig.List {
case pagemeta.Always:
return true
case pagemeta.Never:
return false
case pagemeta.ListLocally:
return !global
}
return false
}
func (p *pageMeta) shouldListAny() bool {
return p.shouldList(true) || p.shouldList(false)
}
func (p *pageMeta) isStandalone() bool {
return !p.standaloneOutputFormat.IsZero()
}
func (p *pageMeta) shouldBeCheckedForMenuDefinitions() bool {
if !p.shouldList(false) {
return false
}
return p.kind == kinds.KindHome || p.kind == kinds.KindSection || p.kind == kinds.KindPage
}
func (p *pageMeta) noRender() bool {
return p.buildConfig.Render != pagemeta.Always
}
func (p *pageMeta) noLink() bool {
return p.buildConfig.Render == pagemeta.Never
}
func (p *pageMeta) applyDefaultValues() error {
if p.buildConfig.IsZero() {
p.buildConfig, _ = pagemeta.DecodeBuildConfig(nil)
}
if !p.s.conf.IsKindEnabled(p.Kind()) {
(&p.buildConfig).Disable()
}
if p.markup == "" {
if p.File() != nil {
// Fall back to file extension
p.markup = p.s.ContentSpec.ResolveMarkup(p.File().Ext())
}
if p.markup == "" {
p.markup = "markdown"
}
}
if p.title == "" && p.f == nil {
switch p.Kind() {
case kinds.KindHome:
p.title = p.s.Title()
case kinds.KindSection:
sectionName := p.pathInfo.Unmormalized().BaseNameNoIdentifier()
if p.s.conf.PluralizeListTitles {
sectionName = flect.Pluralize(sectionName)
}
p.title = p.s.conf.C.CreateTitle(sectionName)
case kinds.KindTerm:
if p.term != "" {
p.title = p.s.conf.C.CreateTitle(p.term)
} else {
panic("term not set")
}
case kinds.KindTaxonomy:
p.title = strings.Replace(p.s.conf.C.CreateTitle(p.pathInfo.Unmormalized().BaseNameNoIdentifier()), "-", " ", -1)
case kinds.KindStatus404:
p.title = "404 Page not found"
}
}
return nil
}
func (p *pageMeta) newContentConverter(ps *pageState, markup string) (converter.Converter, error) {
if ps == nil {
panic("no Page provided")
}
cp := p.s.ContentSpec.Converters.Get(markup)
if cp == nil {
return converter.NopConverter, fmt.Errorf("no content renderer found for markup %q", markup)
}
var id string
var filename string
var path string
if p.f != nil {
id = p.f.UniqueID()
filename = p.f.Filename()
path = p.f.Path()
} else {
path = p.Path()
}
cpp, err := cp.New(
converter.DocumentContext{
Document: newPageForRenderHook(ps),
DocumentID: id,
DocumentName: path,
Filename: filename,
},
)
if err != nil {
return converter.NopConverter, err
}
return cpp, nil
}
// The output formats this page will be rendered to.
func (m *pageMeta) outputFormats() output.Formats {
if len(m.configuredOutputFormats) > 0 {
return m.configuredOutputFormats
}
return m.s.conf.C.KindOutputFormats[m.Kind()]
}
func (p *pageMeta) Slug() string {
return p.urlPaths.Slug
}
func getParam(m resource.ResourceParamsProvider, key string, stringToLower bool) any {
v := m.Params()[strings.ToLower(key)]
if v == nil {
return nil
}
switch val := v.(type) {
case bool:
return val
case string:
if stringToLower {
return strings.ToLower(val)
}
return val
case int64, int32, int16, int8, int:
return cast.ToInt(v)
case float64, float32:
return cast.ToFloat64(v)
case time.Time:
return val
case []string:
if stringToLower {
return helpers.SliceToLower(val)
}
return v
default:
return v
}
}
func getParamToLower(m resource.ResourceParamsProvider, key string) any {
return getParam(m, key, true)
}
type pageMetaDates struct {
dates resource.Dates
}
func (d *pageMetaDates) Date() time.Time {
return d.dates.Date()
}
func (d *pageMetaDates) Lastmod() time.Time {
return d.dates.Lastmod()
}
func (d *pageMetaDates) PublishDate() time.Time {
return d.dates.PublishDate()
}
func (d *pageMetaDates) ExpiryDate() time.Time {
return d.dates.ExpiryDate()
}
func (ps *pageState) initLazyProviders() error {
ps.init.Add(func(ctx context.Context) (any, error) {
pp, err := newPagePaths(ps)
if err != nil {
return nil, err
}
var outputFormatsForPage output.Formats
var renderFormats output.Formats
if ps.m.standaloneOutputFormat.IsZero() {
outputFormatsForPage = ps.m.outputFormats()
renderFormats = ps.s.h.renderFormats
} else {
// One of the fixed output format pages, e.g. 404.
outputFormatsForPage = output.Formats{ps.m.standaloneOutputFormat}
renderFormats = outputFormatsForPage
}
// Prepare output formats for all sites.
// We do this even if this page does not get rendered on
// its own. It may be referenced via one of the site collections etc.
// it will then need an output format.
ps.pageOutputs = make([]*pageOutput, len(renderFormats))
created := make(map[string]*pageOutput)
shouldRenderPage := !ps.m.noRender()
for i, f := range renderFormats {
if po, found := created[f.Name]; found {
ps.pageOutputs[i] = po
continue
}
render := shouldRenderPage
if render {
_, render = outputFormatsForPage.GetByName(f.Name)
}
po := newPageOutput(ps, pp, f, render)
// Create a content provider for the first,
// we may be able to reuse it.
if i == 0 {
contentProvider, err := newPageContentOutput(po)
if err != nil {
return nil, err
}
po.setContentProvider(contentProvider)
}
ps.pageOutputs[i] = po
created[f.Name] = po
}
if err := ps.initCommonProviders(pp); err != nil {
return nil, err
}
return nil, nil
})
return nil
}