URI: 
       Make cascade front matter order deterministic - hugo - [fork] hugo port for 9front
  HTML git clone git@git.drkhsh.at/hugo.git
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
   DIR README
   DIR LICENSE
       ---
   DIR commit 7f0f50b133e768bd939bfc2be7e09ecebba270ae
   DIR parent 77a8e347bc2128b7fc7c82358512268e863d61f9
  HTML Author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
       Date:   Wed, 22 Jan 2025 17:47:54 +0100
       
       Make cascade front matter order deterministic
       
       Fixes #12594
       
       Diffstat:
         M common/hashing/hashing.go           |      16 ++++++++++++----
         A common/maps/ordered.go              |     133 +++++++++++++++++++++++++++++++
         A common/maps/ordered_test.go         |      99 +++++++++++++++++++++++++++++++
         M config/allconfig/allconfig.go       |       7 ++++---
         M hugolib/cascade_test.go             |      35 +++++++++++++++++++++++++++++++
         M hugolib/content_map_page.go         |      12 ++++++------
         M hugolib/page__meta.go               |      32 ++++++++++++++++---------------
         M resources/page/page_matcher.go      |      14 +++++++-------
         M resources/page/page_matcher_test.go |      12 ++----------
         M resources/page/pagemeta/page_front… |       6 +++---
       
       10 files changed, 318 insertions(+), 48 deletions(-)
       ---
   DIR diff --git a/common/hashing/hashing.go b/common/hashing/hashing.go
       @@ -123,16 +123,24 @@ func HashUint64(vs ...any) uint64 {
                        o = elements
                }
        
       -        hashOpts := getHashOpts()
       -        defer putHashOpts(hashOpts)
       -
       -        hash, err := hashstructure.Hash(o, hashOpts)
       +        hash, err := Hash(o)
                if err != nil {
                        panic(err)
                }
                return hash
        }
        
       +// Hash returns a hash from vs.
       +func Hash(vs ...any) (uint64, error) {
       +        hashOpts := getHashOpts()
       +        defer putHashOpts(hashOpts)
       +        var v any = vs
       +        if len(vs) == 1 {
       +                v = vs[0]
       +        }
       +        return hashstructure.Hash(v, hashOpts)
       +}
       +
        type keyer interface {
                Key() string
        }
   DIR diff --git a/common/maps/ordered.go b/common/maps/ordered.go
       @@ -0,0 +1,133 @@
       +// Copyright 2024 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 maps
       +
       +import (
       +        "github.com/gohugoio/hugo/common/hashing"
       +)
       +
       +// Ordered is a map that can be iterated in the order of insertion.
       +// Note that insertion order is not affected if a key is re-inserted into the map.
       +// In a nil map, all operations are no-ops.
       +// This is not thread safe.
       +type Ordered[K comparable, T any] struct {
       +        // The keys in the order they were added.
       +        keys []K
       +        // The values.
       +        values map[K]T
       +}
       +
       +// NewOrdered creates a new Ordered map.
       +func NewOrdered[K comparable, T any]() *Ordered[K, T] {
       +        return &Ordered[K, T]{values: make(map[K]T)}
       +}
       +
       +// Set sets the value for the given key.
       +// Note that insertion order is not affected if a key is re-inserted into the map.
       +func (m *Ordered[K, T]) Set(key K, value T) {
       +        if m == nil {
       +                return
       +        }
       +        // Check if key already exists.
       +        if _, found := m.values[key]; !found {
       +                m.keys = append(m.keys, key)
       +        }
       +        m.values[key] = value
       +}
       +
       +// Get gets the value for the given key.
       +func (m *Ordered[K, T]) Get(key K) (T, bool) {
       +        if m == nil {
       +                var v T
       +                return v, false
       +        }
       +        value, found := m.values[key]
       +        return value, found
       +}
       +
       +// Delete deletes the value for the given key.
       +func (m *Ordered[K, T]) Delete(key K) {
       +        if m == nil {
       +                return
       +        }
       +        delete(m.values, key)
       +        for i, k := range m.keys {
       +                if k == key {
       +                        m.keys = append(m.keys[:i], m.keys[i+1:]...)
       +                        break
       +                }
       +        }
       +}
       +
       +// Clone creates a shallow copy of the map.
       +func (m *Ordered[K, T]) Clone() *Ordered[K, T] {
       +        if m == nil {
       +                return nil
       +        }
       +        clone := NewOrdered[K, T]()
       +        for _, k := range m.keys {
       +                clone.Set(k, m.values[k])
       +        }
       +        return clone
       +}
       +
       +// Keys returns the keys in the order they were added.
       +func (m *Ordered[K, T]) Keys() []K {
       +        if m == nil {
       +                return nil
       +        }
       +        return m.keys
       +}
       +
       +// Values returns the values in the order they were added.
       +func (m *Ordered[K, T]) Values() []T {
       +        if m == nil {
       +                return nil
       +        }
       +        var values []T
       +        for _, k := range m.keys {
       +                values = append(values, m.values[k])
       +        }
       +        return values
       +}
       +
       +// Len returns the number of items in the map.
       +func (m *Ordered[K, T]) Len() int {
       +        if m == nil {
       +                return 0
       +        }
       +        return len(m.keys)
       +}
       +
       +// Range calls f sequentially for each key and value present in the map.
       +// If f returns false, range stops the iteration.
       +// TODO(bep) replace with iter.Seq2 when we bump go Go 1.24.
       +func (m *Ordered[K, T]) Range(f func(key K, value T) bool) {
       +        if m == nil {
       +                return
       +        }
       +        for _, k := range m.keys {
       +                if !f(k, m.values[k]) {
       +                        return
       +                }
       +        }
       +}
       +
       +// Hash calculates a hash from the values.
       +func (m *Ordered[K, T]) Hash() (uint64, error) {
       +        if m == nil {
       +                return 0, nil
       +        }
       +        return hashing.Hash(m.values)
       +}
   DIR diff --git a/common/maps/ordered_test.go b/common/maps/ordered_test.go
       @@ -0,0 +1,99 @@
       +// Copyright 2024 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 maps
       +
       +import (
       +        "testing"
       +
       +        qt "github.com/frankban/quicktest"
       +)
       +
       +func TestOrdered(t *testing.T) {
       +        c := qt.New(t)
       +
       +        m := NewOrdered[string, int]()
       +        m.Set("a", 1)
       +        m.Set("b", 2)
       +        m.Set("c", 3)
       +
       +        c.Assert(m.Keys(), qt.DeepEquals, []string{"a", "b", "c"})
       +        c.Assert(m.Values(), qt.DeepEquals, []int{1, 2, 3})
       +
       +        v, found := m.Get("b")
       +        c.Assert(found, qt.Equals, true)
       +        c.Assert(v, qt.Equals, 2)
       +
       +        m.Set("b", 22)
       +        c.Assert(m.Keys(), qt.DeepEquals, []string{"a", "b", "c"})
       +        c.Assert(m.Values(), qt.DeepEquals, []int{1, 22, 3})
       +
       +        m.Delete("b")
       +
       +        c.Assert(m.Keys(), qt.DeepEquals, []string{"a", "c"})
       +        c.Assert(m.Values(), qt.DeepEquals, []int{1, 3})
       +}
       +
       +func TestOrderedHash(t *testing.T) {
       +        c := qt.New(t)
       +
       +        m := NewOrdered[string, int]()
       +        m.Set("a", 1)
       +        m.Set("b", 2)
       +        m.Set("c", 3)
       +
       +        h1, err := m.Hash()
       +        c.Assert(err, qt.IsNil)
       +
       +        m.Set("d", 4)
       +
       +        h2, err := m.Hash()
       +        c.Assert(err, qt.IsNil)
       +
       +        c.Assert(h1, qt.Not(qt.Equals), h2)
       +
       +        m = NewOrdered[string, int]()
       +        m.Set("b", 2)
       +        m.Set("a", 1)
       +        m.Set("c", 3)
       +
       +        h3, err := m.Hash()
       +        c.Assert(err, qt.IsNil)
       +        // Order does not matter.
       +        c.Assert(h1, qt.Equals, h3)
       +}
       +
       +func TestOrderedNil(t *testing.T) {
       +        c := qt.New(t)
       +
       +        var m *Ordered[string, int]
       +
       +        m.Set("a", 1)
       +        c.Assert(m.Keys(), qt.IsNil)
       +        c.Assert(m.Values(), qt.IsNil)
       +        v, found := m.Get("a")
       +        c.Assert(found, qt.Equals, false)
       +        c.Assert(v, qt.Equals, 0)
       +        m.Delete("a")
       +        var b bool
       +        m.Range(func(k string, v int) bool {
       +                b = true
       +                return true
       +        })
       +        c.Assert(b, qt.Equals, false)
       +        c.Assert(m.Len(), qt.Equals, 0)
       +        c.Assert(m.Clone(), qt.IsNil)
       +        h, err := m.Hash()
       +        c.Assert(err, qt.IsNil)
       +        c.Assert(h, qt.Equals, uint64(0))
       +}
   DIR diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go
       @@ -143,7 +143,7 @@ type Config struct {
        
                // The cascade configuration section contains the top level front matter cascade configuration options,
                // a slice of page matcher and params to apply to those pages.
       -        Cascade *config.ConfigNamespace[[]page.PageMatcherParamsConfig, map[page.PageMatcher]maps.Params] `mapstructure:"-"`
       +        Cascade *config.ConfigNamespace[[]page.PageMatcherParamsConfig, *maps.Ordered[page.PageMatcher, maps.Params]] `mapstructure:"-"`
        
                // The segments defines segments for the site. Used for partial/segmented builds.
                Segments *config.ConfigNamespace[map[string]segments.SegmentConfig, segments.Segments] `mapstructure:"-"`
       @@ -766,9 +766,10 @@ type Configs struct {
        }
        
        func (c *Configs) Validate(logger loggers.Logger) error {
       -        for p := range c.Base.Cascade.Config {
       +        c.Base.Cascade.Config.Range(func(p page.PageMatcher, params maps.Params) bool {
                        page.CheckCascadePattern(logger, p)
       -        }
       +                return true
       +        })
                return nil
        }
        
   DIR diff --git a/hugolib/cascade_test.go b/hugolib/cascade_test.go
       @@ -841,3 +841,38 @@ title: p1
                b.AssertFileExists("public/s1/index.html", false)
                b.AssertFileExists("public/s1/p1/index.html", false)
        }
       +
       +// Issue 12594.
       +func TestCascadeOrder(t *testing.T) {
       +        t.Parallel()
       +
       +        files := `
       +-- hugo.toml --
       +disableKinds = ['rss','sitemap','taxonomy','term', 'home']
       +-- content/_index.md --
       +---
       +title: Home
       +cascade:
       +- _target:
       +    path: "**"
       +  params:
       +    background: yosemite.jpg
       +- _target:
       +  params:
       +    background: goldenbridge.jpg
       +---
       +-- content/p1.md --
       +---
       +title: p1
       +---
       +-- layouts/_default/single.html --
       +Background: {{ .Params.background }}|
       +-- layouts/_default/list.html --
       +{{ .Title }}|
       +  `
       +
       +        for i := 0; i < 10; i++ {
       +                b := Test(t, files)
       +                b.AssertFileContent("public/p1/index.html", "Background: yosemite.jpg")
       +        }
       +}
   DIR diff --git a/hugolib/content_map_page.go b/hugolib/content_map_page.go
       @@ -1387,7 +1387,7 @@ func (sa *sitePagesAssembler) applyAggregates() error {
                        }
        
                        // Handle cascades first to get any default dates set.
       -                var cascade map[page.PageMatcher]maps.Params
       +                var cascade *maps.Ordered[page.PageMatcher, maps.Params]
                        if keyPage == "" {
                                // Home page gets it's cascade from the site config.
                                cascade = sa.conf.Cascade.Config
       @@ -1399,7 +1399,7 @@ func (sa *sitePagesAssembler) applyAggregates() error {
                        } else {
                                _, data := pw.WalkContext.Data().LongestPrefix(keyPage)
                                if data != nil {
       -                                cascade = data.(map[page.PageMatcher]maps.Params)
       +                                cascade = data.(*maps.Ordered[page.PageMatcher, maps.Params])
                                }
                        }
        
       @@ -1481,11 +1481,11 @@ func (sa *sitePagesAssembler) applyAggregates() error {
                                        pageResource := rs.r.(*pageState)
                                        relPath := pageResource.m.pathInfo.BaseRel(pageBundle.m.pathInfo)
                                        pageResource.m.resourcePath = relPath
       -                                var cascade map[page.PageMatcher]maps.Params
       +                                var cascade *maps.Ordered[page.PageMatcher, maps.Params]
                                        // Apply cascade (if set) to the page.
                                        _, data := pw.WalkContext.Data().LongestPrefix(resourceKey)
                                        if data != nil {
       -                                        cascade = data.(map[page.PageMatcher]maps.Params)
       +                                        cascade = data.(*maps.Ordered[page.PageMatcher, maps.Params])
                                        }
                                        if err := pageResource.setMetaPost(cascade); err != nil {
                                                return false, err
       @@ -1549,10 +1549,10 @@ func (sa *sitePagesAssembler) applyAggregatesToTaxonomiesAndTerms() error {
                                        const eventName = "dates"
        
                                        if p.Kind() == kinds.KindTerm {
       -                                        var cascade map[page.PageMatcher]maps.Params
       +                                        var cascade *maps.Ordered[page.PageMatcher, maps.Params]
                                                _, data := pw.WalkContext.Data().LongestPrefix(s)
                                                if data != nil {
       -                                                cascade = data.(map[page.PageMatcher]maps.Params)
       +                                                cascade = data.(*maps.Ordered[page.PageMatcher, maps.Params])
                                                }
                                                if err := p.setMetaPost(cascade); err != nil {
                                                        return false, err
   DIR diff --git a/hugolib/page__meta.go b/hugolib/page__meta.go
       @@ -87,8 +87,8 @@ type pageMetaParams struct {
        
                // These are only set in watch mode.
                datesOriginal   pagemeta.Dates
       -        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.
       +        paramsOriginal  map[string]any                               // contains the original params as defined in the front matter.
       +        cascadeOriginal *maps.Ordered[page.PageMatcher, maps.Params] // contains the original cascade as defined in the front matter.
        }
        
        // From page front matter.
       @@ -96,10 +96,10 @@ type pageMetaFrontMatter struct {
                configuredOutputFormats output.Formats // outputs defined in front matter.
        }
        
       -func (m *pageMetaParams) init(preserveOringal bool) {
       -        if preserveOringal {
       +func (m *pageMetaParams) init(preserveOriginal bool) {
       +        if preserveOriginal {
                        m.paramsOriginal = xmaps.Clone[maps.Params](m.pageConfig.Params)
       -                m.cascadeOriginal = xmaps.Clone[map[page.PageMatcher]maps.Params](m.pageConfig.CascadeCompiled)
       +                m.cascadeOriginal = m.pageConfig.CascadeCompiled.Clone()
                }
        }
        
       @@ -306,22 +306,22 @@ func (p *pageMeta) setMetaPre(pi *contentParseInfo, logger loggers.Logger, conf 
                return nil
        }
        
       -func (ps *pageState) setMetaPost(cascade map[page.PageMatcher]maps.Params) error {
       +func (ps *pageState) setMetaPost(cascade *maps.Ordered[page.PageMatcher, maps.Params]) error {
                ps.m.setMetaPostCount++
                var cascadeHashPre uint64
                if ps.m.setMetaPostCount > 1 {
                        cascadeHashPre = hashing.HashUint64(ps.m.pageConfig.CascadeCompiled)
       -                ps.m.pageConfig.CascadeCompiled = xmaps.Clone[map[page.PageMatcher]maps.Params](ps.m.cascadeOriginal)
       +                ps.m.pageConfig.CascadeCompiled = ps.m.cascadeOriginal.Clone()
        
                }
        
                // Apply cascades first so they can be overridden later.
                if cascade != nil {
                        if ps.m.pageConfig.CascadeCompiled != nil {
       -                        for k, v := range cascade {
       -                                vv, found := ps.m.pageConfig.CascadeCompiled[k]
       +                        cascade.Range(func(k page.PageMatcher, v maps.Params) bool {
       +                                vv, found := ps.m.pageConfig.CascadeCompiled.Get(k)
                                        if !found {
       -                                        ps.m.pageConfig.CascadeCompiled[k] = v
       +                                        ps.m.pageConfig.CascadeCompiled.Set(k, v)
                                        } else {
                                                // Merge
                                                for ck, cv := range v {
       @@ -330,7 +330,8 @@ func (ps *pageState) setMetaPost(cascade map[page.PageMatcher]maps.Params) error
                                                        }
                                                }
                                        }
       -                        }
       +                                return true
       +                        })
                                cascade = ps.m.pageConfig.CascadeCompiled
                        } else {
                                ps.m.pageConfig.CascadeCompiled = cascade
       @@ -354,16 +355,17 @@ func (ps *pageState) setMetaPost(cascade map[page.PageMatcher]maps.Params) error
                }
        
                // Cascade is also applied to itself.
       -        for m, v := range cascade {
       -                if !m.Matches(ps) {
       -                        continue
       +        cascade.Range(func(k page.PageMatcher, v maps.Params) bool {
       +                if !k.Matches(ps) {
       +                        return true
                        }
                        for kk, vv := range v {
                                if _, found := ps.m.pageConfig.Params[kk]; !found {
                                        ps.m.pageConfig.Params[kk] = vv
                                }
                        }
       -        }
       +                return true
       +        })
        
                if err := ps.setMetaPostParams(); err != nil {
                        return err
   DIR diff --git a/resources/page/page_matcher.go b/resources/page/page_matcher.go
       @@ -104,9 +104,9 @@ func CheckCascadePattern(logger loggers.Logger, m PageMatcher) {
                }
        }
        
       -func DecodeCascadeConfig(logger loggers.Logger, in any) (*config.ConfigNamespace[[]PageMatcherParamsConfig, map[PageMatcher]maps.Params], error) {
       -        buildConfig := func(in any) (map[PageMatcher]maps.Params, any, error) {
       -                cascade := make(map[PageMatcher]maps.Params)
       +func DecodeCascadeConfig(logger loggers.Logger, 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 {
                                return cascade, []map[string]any{}, nil
                        }
       @@ -134,7 +134,7 @@ func DecodeCascadeConfig(logger loggers.Logger, in any) (*config.ConfigNamespace
                        for _, cfg := range cfgs {
                                m := cfg.Target
                                CheckCascadePattern(logger, m)
       -                        c, found := cascade[m]
       +                        c, found := cascade.Get(m)
                                if found {
                                        // Merge
                                        for k, v := range cfg.Params {
       @@ -143,18 +143,18 @@ func DecodeCascadeConfig(logger loggers.Logger, in any) (*config.ConfigNamespace
                                                }
                                        }
                                } else {
       -                                cascade[m] = cfg.Params
       +                                cascade.Set(m, cfg.Params)
                                }
                        }
        
                        return cascade, cfgs, nil
                }
        
       -        return config.DecodeNamespace[[]PageMatcherParamsConfig](in, buildConfig)
       +        return config.DecodeNamespace[[]PageMatcherParamsConfig, *maps.Ordered[PageMatcher, maps.Params]](in, buildConfig)
        }
        
        // DecodeCascade decodes in which could be either a map or a slice of maps.
       -func DecodeCascade(logger loggers.Logger, in any) (map[PageMatcher]maps.Params, error) {
       +func DecodeCascade(logger loggers.Logger, in any) (*maps.Ordered[PageMatcher, maps.Params], error) {
                conf, err := DecodeCascadeConfig(logger, in)
                if err != nil {
                        return nil, err
   DIR diff --git a/resources/page/page_matcher_test.go b/resources/page/page_matcher_test.go
       @@ -133,16 +133,8 @@ func TestDecodeCascadeConfig(t *testing.T) {
        
                c.Assert(err, qt.IsNil)
                c.Assert(got, qt.IsNotNil)
       -        c.Assert(got.Config, qt.DeepEquals,
       -                map[PageMatcher]maps.Params{
       -                        {Path: "", Kind: "page", Lang: "", Environment: ""}: {
       -                                "b": "bv",
       -                        },
       -                        {Path: "", Kind: "page", Lang: "", Environment: "production"}: {
       -                                "a": "av",
       -                        },
       -                },
       -        )
       +        c.Assert(got.Config.Keys(), qt.DeepEquals, []PageMatcher{{Kind: "page", Environment: "production"}, {Kind: "page"}})
       +        c.Assert(got.Config.Values(), qt.DeepEquals, []maps.Params{{"a": string("av")}, {"b": string("bv")}})
                c.Assert(got.SourceStructure, qt.DeepEquals, []PageMatcherParamsConfig{
                        {
                                Params: maps.Params{"a": string("av")},
   DIR diff --git a/resources/page/pagemeta/page_frontmatter.go b/resources/page/pagemeta/page_frontmatter.go
       @@ -114,9 +114,9 @@ type PageConfig struct {
                Content Source
        
                // Compiled values.
       -        CascadeCompiled      map[page.PageMatcher]maps.Params
       -        ContentMediaType     media.Type `mapstructure:"-" json:"-"`
       -        IsFromContentAdapter bool       `mapstructure:"-" json:"-"`
       +        CascadeCompiled      *maps.Ordered[page.PageMatcher, maps.Params] `mapstructure:"-" json:"-"`
       +        ContentMediaType     media.Type                                   `mapstructure:"-" json:"-"`
       +        IsFromContentAdapter bool                                         `mapstructure:"-" json:"-"`
        }
        
        var DefaultPageConfig = PageConfig{