Fix/implement cascade for content adapters

Fixes #13692
This commit is contained in:
Bjørn Erik Pedersen
2025-05-07 10:40:39 +02:00
parent 9d1d8c8899
commit c745a3e108
11 changed files with 273 additions and 79 deletions

View File

@@ -16,6 +16,7 @@ package page
import (
"fmt"
"path/filepath"
"slices"
"strings"
"github.com/gohugoio/hugo/common/loggers"
@@ -24,7 +25,6 @@ import (
"github.com/gohugoio/hugo/hugofs/glob"
"github.com/gohugoio/hugo/resources/kinds"
"github.com/mitchellh/mapstructure"
"slices"
)
// A PageMatcher can be used to match a Page with Glob patterns.
@@ -105,7 +105,7 @@ func CheckCascadePattern(logger loggers.Logger, m PageMatcher) {
}
}
func DecodeCascadeConfig(logger loggers.Logger, in any) (*config.ConfigNamespace[[]PageMatcherParamsConfig, *maps.Ordered[PageMatcher, maps.Params]], error) {
func DecodeCascadeConfig(logger loggers.Logger, handleLegacyFormat bool, in any) (*config.ConfigNamespace[[]PageMatcherParamsConfig, *maps.Ordered[PageMatcher, maps.Params]], error) {
buildConfig := func(in any) (*maps.Ordered[PageMatcher, maps.Params], any, error) {
cascade := maps.NewOrdered[PageMatcher, maps.Params]()
if in == nil {
@@ -120,7 +120,15 @@ func DecodeCascadeConfig(logger loggers.Logger, in any) (*config.ConfigNamespace
for _, m := range ms {
m = maps.CleanConfigStringMap(m)
c, err := mapToPageMatcherParamsConfig(m)
var (
c PageMatcherParamsConfig
err error
)
if handleLegacyFormat {
c, err = mapToPageMatcherParamsConfigLegacy(m)
} else {
c, err = mapToPageMatcherParamsConfig(m)
}
if err != nil {
return nil, nil, err
}
@@ -155,8 +163,8 @@ func DecodeCascadeConfig(logger loggers.Logger, in any) (*config.ConfigNamespace
}
// DecodeCascade decodes in which could be either a map or a slice of maps.
func DecodeCascade(logger loggers.Logger, in any) (*maps.Ordered[PageMatcher, maps.Params], error) {
conf, err := DecodeCascadeConfig(logger, in)
func DecodeCascade(logger loggers.Logger, handleLegacyFormat bool, in any) (*maps.Ordered[PageMatcher, maps.Params], error) {
conf, err := DecodeCascadeConfig(logger, handleLegacyFormat, in)
if err != nil {
return nil, err
}
@@ -164,6 +172,26 @@ func DecodeCascade(logger loggers.Logger, in any) (*maps.Ordered[PageMatcher, ma
}
func mapToPageMatcherParamsConfig(m map[string]any) (PageMatcherParamsConfig, error) {
var pcfg PageMatcherParamsConfig
for k, v := range m {
switch strings.ToLower(k) {
case "_target", "target":
var target PageMatcher
if err := decodePageMatcher(v, &target); err != nil {
return pcfg, err
}
pcfg.Target = target
default:
if pcfg.Params == nil {
pcfg.Params = make(maps.Params)
}
pcfg.Params[k] = v
}
}
return pcfg, pcfg.init()
}
func mapToPageMatcherParamsConfigLegacy(m map[string]any) (PageMatcherParamsConfig, error) {
var pcfg PageMatcherParamsConfig
for k, v := range m {
switch strings.ToLower(k) {
@@ -190,7 +218,6 @@ func mapToPageMatcherParamsConfig(m map[string]any) (PageMatcherParamsConfig, er
}
pcfg.Target = target
default:
// Legacy config.
if pcfg.Params == nil {
pcfg.Params = make(maps.Params)
}

View File

@@ -84,7 +84,7 @@ func TestPageMatcher(t *testing.T) {
c.Run("mapToPageMatcherParamsConfig", func(c *qt.C) {
fn := func(m map[string]any) PageMatcherParamsConfig {
v, err := mapToPageMatcherParamsConfig(m)
v, err := mapToPageMatcherParamsConfigLegacy(m)
c.Assert(err, qt.IsNil)
return v
}
@@ -129,7 +129,7 @@ func TestDecodeCascadeConfig(t *testing.T) {
},
}
got, err := DecodeCascadeConfig(loggers.NewDefault(), in)
got, err := DecodeCascadeConfig(loggers.NewDefault(), true, in)
c.Assert(err, qt.IsNil)
c.Assert(got, qt.IsNotNil)
@@ -143,7 +143,7 @@ func TestDecodeCascadeConfig(t *testing.T) {
{Params: maps.Params{"b": string("bv")}, Target: PageMatcher{Kind: "page"}},
})
got, err = DecodeCascadeConfig(loggers.NewDefault(), nil)
got, err = DecodeCascadeConfig(loggers.NewDefault(), true, nil)
c.Assert(err, qt.IsNil)
c.Assert(got, qt.IsNotNil)
}

View File

@@ -33,6 +33,7 @@ import (
"github.com/gohugoio/hugo/resources/kinds"
"github.com/gohugoio/hugo/resources/page"
"github.com/gohugoio/hugo/resources/resource"
"github.com/mitchellh/mapstructure"
"github.com/gohugoio/hugo/helpers"
@@ -75,35 +76,43 @@ func (d Dates) IsAllDatesZero() bool {
return d.Date.IsZero() && d.Lastmod.IsZero() && d.PublishDate.IsZero() && d.ExpiryDate.IsZero()
}
// Page config that needs to be set early. These cannot be modified by cascade.
type PageConfigEarly struct {
Kind string // The kind of page, e.g. "page", "section", "home" etc. This is usually derived from the content path.
Path string // The canonical path to the page, e.g. /sect/mypage. Note: Leading slash, no trailing slash, no extensions or language identifiers.
Lang string // The language code for this page. This is usually derived from the module mount or filename.
Cascade []map[string]any
// Content holds the content for this page.
Content Source
}
// PageConfig configures a Page, typically from front matter.
// Note that all the top level fields are reserved Hugo keywords.
// Any custom configuration needs to be set in the Params map.
type PageConfig struct {
Dates Dates `json:"-"` // Dates holds the four core dates for this page.
DatesStrings
Title string // The title of the page.
LinkTitle string // The link title of the page.
Type string // The content type of the page.
Layout string // The layout to use for to render this page.
Weight int // The weight of the page, used in sorting if set to a non-zero value.
Kind string // The kind of page, e.g. "page", "section", "home" etc. This is usually derived from the content path.
Path string // The canonical path to the page, e.g. /sect/mypage. Note: Leading slash, no trailing slash, no extensions or language identifiers.
Lang string // The language code for this page. This is usually derived from the module mount or filename.
URL string // The URL to the rendered page, e.g. /sect/mypage.html.
Slug string // The slug for this page.
Description string // The description for this page.
Summary string // The summary for this page.
Draft bool // Whether or not the content is a draft.
Headless bool `json:"-"` // Whether or not the page should be rendered.
IsCJKLanguage bool // Whether or not the content is in a CJK language.
TranslationKey string // The translation key for this page.
Keywords []string // The keywords for this page.
Aliases []string // The aliases for this page.
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.
PageConfigEarly `mapstructure:",squash"`
Title string // The title of the page.
LinkTitle string // The link title of the page.
Type string // The content type of the page.
Layout string // The layout to use for to render this page.
Weight int // The weight of the page, used in sorting if set to a non-zero value.
URL string // The URL to the rendered page, e.g. /sect/mypage.html.
Slug string // The slug for this page.
Description string // The description for this page.
Summary string // The summary for this page.
Draft bool // Whether or not the content is a draft.
Headless bool `json:"-"` // Whether or not the page should be rendered.
IsCJKLanguage bool // Whether or not the content is in a CJK language.
TranslationKey string // The translation key for this page.
Keywords []string // The keywords for this page.
Aliases []string // The aliases for this page.
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.
FrontMatterOnlyValues `mapstructure:"-" json:"-"`
Cascade []map[string]any
Sitemap config.SitemapConfig
Build BuildConfig
Menus any // Can be a string, []string or map[string]any.
@@ -111,8 +120,9 @@ type PageConfig struct {
// User defined params.
Params maps.Params
// Content holds the content for this page.
Content Source
// The raw data from the content adapter.
// TODO(bep) clean up the ContentAdapterData vs Params.
ContentAdapterData map[string]any `mapstructure:"-" json:"-"`
// Compiled values.
CascadeCompiled *maps.Ordered[page.PageMatcher, maps.Params] `mapstructure:"-" json:"-"`
@@ -121,6 +131,20 @@ type PageConfig struct {
IsFromContentAdapter bool `mapstructure:"-" json:"-"`
}
func ClonePageConfigForRebuild(p *PageConfig, params map[string]any) *PageConfig {
pp := &PageConfig{
PageConfigEarly: p.PageConfigEarly,
IsFromContentAdapter: p.IsFromContentAdapter,
}
if pp.IsFromContentAdapter {
pp.ContentAdapterData = params
} else {
pp.Params = params
}
return pp
}
var DefaultPageConfig = PageConfig{
Build: DefaultBuildConfig,
}
@@ -151,8 +175,7 @@ func (p *PageConfig) Validate(pagesFromData bool) error {
return nil
}
// Compile sets up the page configuration after all fields have been set.
func (p *PageConfig) Compile(basePath string, pagesFromData bool, ext string, logger loggers.Logger, outputFormats output.Formats, mediaTypes media.Types) error {
func (p *PageConfig) CompileForPagesFromDataPre(basePath string, logger loggers.Logger, mediaTypes media.Types) error {
// In content adapters, we always get relative paths.
if basePath != "" {
p.Path = path.Join(basePath, p.Path)
@@ -160,12 +183,32 @@ func (p *PageConfig) Compile(basePath string, pagesFromData bool, ext string, lo
if p.Params == nil {
p.Params = make(maps.Params)
} else if pagesFromData {
p.Params = maps.PrepareParamsClone(p.Params)
} else {
maps.PrepareParams(p.Params)
p.Params = maps.PrepareParamsClone(p.Params)
}
if p.Kind == "" {
p.Kind = kinds.KindPage
}
if p.Cascade != nil {
cascade, err := page.DecodeCascade(logger, false, p.Cascade)
if err != nil {
return fmt.Errorf("failed to decode cascade: %w", err)
}
p.CascadeCompiled = cascade
}
// Note that NormalizePathStringBasic will make sure that we don't preserve the unnormalized path.
// We do that when we create pages from the file system; mostly for backward compatibility,
// but also because people tend to use use the filename to name their resources (with spaces and all),
// and this isn't relevant when creating resources from an API where it's easy to add textual meta data.
p.Path = paths.NormalizePathStringBasic(p.Path)
return p.compilePrePost("", mediaTypes)
}
func (p *PageConfig) compilePrePost(ext string, mediaTypes media.Types) error {
if p.Content.Markup == "" && p.Content.MediaType == "" {
if ext == "" {
ext = "md"
@@ -196,6 +239,29 @@ func (p *PageConfig) Compile(basePath string, pagesFromData bool, ext string, lo
if p.Content.Markup == "" {
p.Content.Markup = p.ContentMediaType.SubType
}
return nil
}
// Compile sets up the page configuration after all fields have been set.
func (p *PageConfig) Compile(ext string, logger loggers.Logger, outputFormats output.Formats, mediaTypes media.Types) error {
if p.IsFromContentAdapter {
if err := mapstructure.WeakDecode(p.ContentAdapterData, p); err != nil {
err = fmt.Errorf("failed to decode page map: %w", err)
return err
}
// Not needed anymore.
p.ContentAdapterData = nil
}
if p.Params == nil {
p.Params = make(maps.Params)
} else {
maps.PrepareParams(p.Params)
}
if err := p.compilePrePost(ext, mediaTypes); err != nil {
return err
}
if len(p.Outputs) > 0 {
outFormats, err := outputFormats.GetByNames(p.Outputs...)
@@ -206,27 +272,6 @@ func (p *PageConfig) Compile(basePath string, pagesFromData bool, ext string, lo
}
}
if pagesFromData {
if p.Kind == "" {
p.Kind = kinds.KindPage
}
// Note that NormalizePathStringBasic will make sure that we don't preserve the unnormalized path.
// We do that when we create pages from the file system; mostly for backward compatibility,
// but also because people tend to use use the filename to name their resources (with spaces and all),
// and this isn't relevant when creating resources from an API where it's easy to add textual meta data.
p.Path = paths.NormalizePathStringBasic(p.Path)
}
if p.Cascade != nil {
cascade, err := page.DecodeCascade(logger, p.Cascade)
if err != nil {
return fmt.Errorf("failed to decode cascade: %w", err)
}
p.CascadeCompiled = cascade
}
return nil
}

View File

@@ -176,7 +176,7 @@ func TestContentMediaTypeFromMarkup(t *testing.T) {
} {
var pc pagemeta.PageConfig
pc.Content.Markup = test.in
c.Assert(pc.Compile("", true, "", logger, output.DefaultFormats, media.DefaultTypes), qt.IsNil)
c.Assert(pc.Compile("", logger, output.DefaultFormats, media.DefaultTypes), qt.IsNil)
c.Assert(pc.ContentMediaType.Type, qt.Equals, test.expected)
}
}