shithub: hugo

Download patch

ref: bd98182dbde893a8a809661c70633741bbf63911
parent: e88d7989907108b656eccd92bccc076be72a5c03
author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
date: Fri Aug 9 06:05:22 EDT 2019

Implement cascading front matter

Fixes #6041

--- /dev/null
+++ b/hugolib/cascade_test.go
@@ -1,0 +1,252 @@
+// 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 (
+	"bytes"
+	"fmt"
+	"path"
+	"testing"
+
+	"github.com/alecthomas/assert"
+	"github.com/gohugoio/hugo/parser"
+	"github.com/gohugoio/hugo/parser/metadecoders"
+	"github.com/stretchr/testify/require"
+)
+
+func BenchmarkCascade(b *testing.B) {
+	allLangs := []string{"en", "nn", "nb", "sv", "ab", "aa", "af", "sq", "kw", "da"}
+
+	for i := 1; i <= len(allLangs); i += 2 {
+		langs := allLangs[0:i]
+		b.Run(fmt.Sprintf("langs-%d", len(langs)), func(b *testing.B) {
+			assert := require.New(b)
+			b.StopTimer()
+			builders := make([]*sitesBuilder, b.N)
+			for i := 0; i < b.N; i++ {
+				builders[i] = newCascadeTestBuilder(b, langs)
+			}
+			b.StartTimer()
+
+			for i := 0; i < b.N; i++ {
+				builder := builders[i]
+				err := builder.BuildE(BuildCfg{})
+				assert.NoError(err)
+				first := builder.H.Sites[0]
+				assert.NotNil(first)
+			}
+		})
+	}
+}
+
+func TestCascade(t *testing.T) {
+	assert := assert.New(t)
+
+	allLangs := []string{"en", "nn", "nb", "sv"}
+
+	langs := allLangs[:3]
+
+	t.Run(fmt.Sprintf("langs-%d", len(langs)), func(t *testing.T) {
+		b := newCascadeTestBuilder(t, langs)
+		b.Build(BuildCfg{})
+
+		b.AssertFileContent("public/index.html", `
+        12|taxonomy|categories/cool/_index.md|Cascade Category|cat.png|categories|HTML-|
+        12|taxonomy|categories/catsect1|catsect1|cat.png|categories|HTML-|
+        12|taxonomy|categories/funny|funny|cat.png|categories|HTML-|
+        12|taxonomyTerm|categories/_index.md|My Categories|cat.png|categories|HTML-|
+        32|taxonomy|categories/sad/_index.md|Cascade Category|sad.png|categories|HTML-|
+        42|taxonomy|tags/blue|blue|home.png|tags|HTML-|
+        42|section|sect3|Cascade Home|home.png|sect3|HTML-|
+        42|taxonomyTerm|tags|Cascade Home|home.png|tags|HTML-|
+        42|page|p2.md|Cascade Home|home.png|page|HTML-|
+        42|page|sect2/p2.md|Cascade Home|home.png|sect2|HTML-|
+        42|page|sect3/p1.md|Cascade Home|home.png|sect3|HTML-|
+        42|taxonomy|tags/green|green|home.png|tags|HTML-|
+        42|home|_index.md|Home|home.png|page|HTML-|
+        42|page|p1.md|p1|home.png|page|HTML-|
+        42|section|sect1/_index.md|Sect1|sect1.png|stype|HTML-|
+        42|section|sect1/s1_2/_index.md|Sect1_2|sect1.png|stype|HTML-|
+        42|page|sect1/s1_2/p1.md|Sect1_2_p1|sect1.png|stype|HTML-|
+        42|page|sect1/s1_2/p2.md|Sect1_2_p2|sect1.png|stype|HTML-|
+        42|section|sect2/_index.md|Sect2|home.png|sect2|HTML-|
+        42|page|sect2/p1.md|Sect2_p1|home.png|sect2|HTML-|
+        52|page|sect4/p1.md|Cascade Home|home.png|sect4|RSS-|
+        52|section|sect4/_index.md|Sect4|home.png|sect4|RSS-|
+`)
+
+		// Check that type set in cascade gets the correct layout.
+		b.AssertFileContent("public/sect1/index.html", `stype list: Sect1`)
+		b.AssertFileContent("public/sect1/s1_2/p2/index.html", `stype single: Sect1_2_p2`)
+
+		// Check output formats set in cascade
+		b.AssertFileContent("public/sect4/index.xml", `<link>https://example.org/sect4/index.xml</link>`)
+		b.AssertFileContent("public/sect4/p1/index.xml", `<link>https://example.org/sect4/p1/index.xml</link>`)
+		assert.False(b.CheckExists("public/sect2/index.xml"))
+
+		// Check cascade into bundled page
+		b.AssertFileContent("public/bundle1/index.html", `Resources: bp1.md|home.png|`)
+
+	})
+
+}
+
+func newCascadeTestBuilder(t testing.TB, langs []string) *sitesBuilder {
+	p := func(m map[string]interface{}) string {
+		var yamlStr string
+
+		if len(m) > 0 {
+			var b bytes.Buffer
+
+			parser.InterfaceToConfig(m, metadecoders.YAML, &b)
+			yamlStr = b.String()
+		}
+
+		metaStr := "---\n" + yamlStr + "\n---"
+
+		return metaStr
+
+	}
+
+	createLangConfig := func(lang string) string {
+		const langEntry = `
+[languages.%s]
+`
+		return fmt.Sprintf(langEntry, lang)
+	}
+
+	createMount := func(lang string) string {
+		const mountsTempl = `
+[[module.mounts]]
+source="content/%s"
+target="content"
+lang="%s"
+`
+		return fmt.Sprintf(mountsTempl, lang, lang)
+	}
+
+	config := `
+baseURL = "https://example.org"
+defaultContentLanguage = "en"
+defaultContentLanguageInSubDir = false
+
+[languages]`
+	for _, lang := range langs {
+		config += createLangConfig(lang)
+	}
+
+	config += "\n\n[module]\n"
+	for _, lang := range langs {
+		config += createMount(lang)
+	}
+
+	b := newTestSitesBuilder(t).WithConfigFile("toml", config)
+
+	createContentFiles := func(lang string) {
+
+		withContent := func(filenameContent ...string) {
+			for i := 0; i < len(filenameContent); i += 2 {
+				b.WithContent(path.Join(lang, filenameContent[i]), filenameContent[i+1])
+			}
+		}
+
+		withContent(
+			"_index.md", p(map[string]interface{}{
+				"title": "Home",
+				"cascade": map[string]interface{}{
+					"title":   "Cascade Home",
+					"ICoN":    "home.png",
+					"outputs": []string{"HTML"},
+					"weight":  42,
+				},
+			}),
+			"p1.md", p(map[string]interface{}{
+				"title": "p1",
+			}),
+			"p2.md", p(map[string]interface{}{}),
+			"sect1/_index.md", p(map[string]interface{}{
+				"title": "Sect1",
+				"type":  "stype",
+				"cascade": map[string]interface{}{
+					"title":      "Cascade Sect1",
+					"icon":       "sect1.png",
+					"type":       "stype",
+					"categories": []string{"catsect1"},
+				},
+			}),
+			"sect1/s1_2/_index.md", p(map[string]interface{}{
+				"title": "Sect1_2",
+			}),
+			"sect1/s1_2/p1.md", p(map[string]interface{}{
+				"title": "Sect1_2_p1",
+			}),
+			"sect1/s1_2/p2.md", p(map[string]interface{}{
+				"title": "Sect1_2_p2",
+			}),
+			"sect2/_index.md", p(map[string]interface{}{
+				"title": "Sect2",
+			}),
+			"sect2/p1.md", p(map[string]interface{}{
+				"title":      "Sect2_p1",
+				"categories": []string{"cool", "funny", "sad"},
+				"tags":       []string{"blue", "green"},
+			}),
+			"sect2/p2.md", p(map[string]interface{}{}),
+			"sect3/p1.md", p(map[string]interface{}{}),
+			"sect4/_index.md", p(map[string]interface{}{
+				"title": "Sect4",
+				"cascade": map[string]interface{}{
+					"weight":  52,
+					"outputs": []string{"RSS"},
+				},
+			}),
+			"sect4/p1.md", p(map[string]interface{}{}),
+			"p2.md", p(map[string]interface{}{}),
+			"bundle1/index.md", p(map[string]interface{}{}),
+			"bundle1/bp1.md", p(map[string]interface{}{}),
+			"categories/_index.md", p(map[string]interface{}{
+				"title": "My Categories",
+				"cascade": map[string]interface{}{
+					"title":  "Cascade Category",
+					"icoN":   "cat.png",
+					"weight": 12,
+				},
+			}),
+			"categories/cool/_index.md", p(map[string]interface{}{}),
+			"categories/sad/_index.md", p(map[string]interface{}{
+				"cascade": map[string]interface{}{
+					"icon":   "sad.png",
+					"weight": 32,
+				},
+			}),
+		)
+	}
+
+	createContentFiles("en")
+
+	b.WithTemplates("index.html", `
+	
+{{ range .Site.Pages }}
+{{- .Weight }}|{{ .Kind }}|{{ path.Join .Path }}|{{ .Title }}|{{ .Params.icon }}|{{ .Type }}|{{ range .OutputFormats }}{{ .Name }}-{{ end }}|
+{{ end }}
+`,
+
+		"_default/single.html", "default single: {{ .Title }}|{{ .RelPermalink }}|{{ .Content }}|Resources: {{ range .Resources }}{{ .Name }}|{{ .Params.icon }}|{{ .Content }}{{ end }}",
+		"_default/list.html", "default list: {{ .Title }}",
+		"stype/single.html", "stype single: {{ .Title }}|{{ .RelPermalink }}|{{ .Content }}",
+		"stype/list.html", "stype list: {{ .Title }}",
+	)
+
+	return b
+}
--- a/hugolib/collections_test.go
+++ b/hugolib/collections_test.go
@@ -178,7 +178,6 @@
 	b.WithSimpleConfigFile().
 		WithContent("page1.md", fmt.Sprintf(pageContent, 10), "page2.md", fmt.Sprintf(pageContent, 20)).
 		WithTemplatesAdded("index.html", `
-
 {{ $p1 := index .Site.RegularPages 0 }}{{ $p2 := index .Site.RegularPages 1 }}
 
 {{ $pages := slice }}
@@ -205,7 +204,7 @@
 	b.CreateSites().Build(BuildCfg{})
 
 	assert.Equal(1, len(b.H.Sites))
-	require.Len(t, b.H.Sites[0].RegularPages(), 2)
+	assert.Len(b.H.Sites[0].RegularPages(), 2)
 
 	b.AssertFileContent("public/index.html",
 		"pages:2:page.Pages:Page(/page2.md)/Page(/page1.md)",
--- a/hugolib/hugo_sites_build.go
+++ b/hugolib/hugo_sites_build.go
@@ -19,7 +19,10 @@
 	"fmt"
 	"runtime/trace"
 
+	"github.com/gohugoio/hugo/config"
 	"github.com/gohugoio/hugo/output"
+	"golang.org/x/sync/errgroup"
+	"golang.org/x/sync/semaphore"
 
 	"github.com/pkg/errors"
 
@@ -226,7 +229,7 @@
 
 }
 
-func (h *HugoSites) assemble(config *BuildCfg) error {
+func (h *HugoSites) assemble(bcfg *BuildCfg) error {
 
 	if len(h.Sites) > 1 {
 		// The first is initialized during process; initialize the rest
@@ -237,23 +240,46 @@
 		}
 	}
 
-	if !config.whatChanged.source {
+	if !bcfg.whatChanged.source {
 		return nil
 	}
 
+	numWorkers := config.GetNumWorkerMultiplier()
+	sem := semaphore.NewWeighted(int64(numWorkers))
+	g, ctx := errgroup.WithContext(context.Background())
+
 	for _, s := range h.Sites {
-		if err := s.assemblePagesMap(s); err != nil {
-			return err
-		}
+		s := s
+		g.Go(func() error {
+			err := sem.Acquire(ctx, 1)
+			if err != nil {
+				return err
+			}
+			defer sem.Release(1)
 
-		if err := s.pagesMap.assembleTaxonomies(s); err != nil {
-			return err
-		}
+			if err := s.assemblePagesMap(s); err != nil {
+				return err
+			}
 
-		if err := s.createWorkAllPages(); err != nil {
-			return err
-		}
+			if err := s.pagesMap.assemblePageMeta(); err != nil {
+				return err
+			}
 
+			if err := s.pagesMap.assembleTaxonomies(s); err != nil {
+				return err
+			}
+
+			if err := s.createWorkAllPages(); err != nil {
+				return err
+			}
+
+			return nil
+
+		})
+	}
+
+	if err := g.Wait(); err != nil {
+		return err
 	}
 
 	if err := h.createPageCollections(); err != nil {
--- a/hugolib/page.go
+++ b/hugolib/page.go
@@ -520,7 +520,7 @@
 	p.resources = append(p.resources, r...)
 }
 
-func (p *pageState) mapContent(meta *pageMeta) error {
+func (p *pageState) mapContent(bucket *pagesMapBucket, meta *pageMeta) error {
 
 	s := p.shortcodeState
 
@@ -563,7 +563,7 @@
 				}
 			}
 
-			if err := meta.setMetadata(p, m); err != nil {
+			if err := meta.setMetadata(bucket, p, m); err != nil {
 				return err
 			}
 
--- a/hugolib/page__common.go
+++ b/hugolib/page__common.go
@@ -35,6 +35,9 @@
 	// Laziliy initialized dependencies.
 	init *lazy.Init
 
+	metaInit   sync.Once
+	metaInitFn func(bucket *pagesMapBucket) error
+
 	// All of these represents the common parts of a page.Page
 	maps.Scratcher
 	navigation.PageMenusProvider
--- a/hugolib/page__meta.go
+++ b/hugolib/page__meta.go
@@ -306,19 +306,51 @@
 	return p.weight
 }
 
-func (pm *pageMeta) setMetadata(p *pageState, frontmatter map[string]interface{}) error {
-	if frontmatter == nil {
+func (pm *pageMeta) setMetadata(bucket *pagesMapBucket, p *pageState, frontmatter map[string]interface{}) error {
+	if frontmatter == nil && bucket.cascade == nil {
 		return errors.New("missing frontmatter data")
 	}
 
 	pm.params = make(map[string]interface{})
 
-	// Needed for case insensitive fetching of params values
-	maps.ToLower(frontmatter)
+	if frontmatter != nil {
+		// Needed for case insensitive fetching of params values
+		maps.ToLower(frontmatter)
+		if p.IsNode() {
+			// Check for any cascade define on itself.
+			if cv, found := frontmatter["cascade"]; found {
+				cvm := cast.ToStringMap(cv)
+				if bucket.cascade == nil {
+					bucket.cascade = cvm
+				} else {
+					for k, v := range cvm {
+						bucket.cascade[k] = v
+					}
+				}
+			}
+		}
 
+		if bucket != nil && bucket.cascade != nil {
+			for k, v := range bucket.cascade {
+				if _, found := frontmatter[k]; !found {
+					frontmatter[k] = v
+				}
+			}
+		}
+	} else {
+		frontmatter = make(map[string]interface{})
+		for k, v := range bucket.cascade {
+			frontmatter[k] = v
+		}
+	}
+
 	var mtime time.Time
-	if p.File().FileInfo() != nil {
-		mtime = p.File().FileInfo().ModTime()
+	var contentBaseName string
+	if !p.File().IsZero() {
+		contentBaseName = p.File().ContentBaseName()
+		if p.File().FileInfo() != nil {
+			mtime = p.File().FileInfo().ModTime()
+		}
 	}
 
 	var gitAuthorDate time.Time
@@ -331,7 +363,7 @@
 		Params:        pm.params,
 		Dates:         &pm.Dates,
 		PageURLs:      &pm.urlPaths,
-		BaseFilename:  p.File().ContentBaseName(),
+		BaseFilename:  contentBaseName,
 		ModTime:       mtime,
 		GitAuthorDate: gitAuthorDate,
 	}
@@ -546,7 +578,7 @@
 
 	if isCJKLanguage != nil {
 		pm.isCJKLanguage = *isCJKLanguage
-	} else if p.s.siteCfg.hasCJKLanguage {
+	} else if p.s.siteCfg.hasCJKLanguage && p.source.parsed != nil {
 		if cjkRe.Match(p.source.parsed.Input()) {
 			pm.isCJKLanguage = true
 		} else {
--- a/hugolib/page__new.go
+++ b/hugolib/page__new.go
@@ -95,7 +95,7 @@
 
 }
 
-func newPageFromMeta(metaProvider *pageMeta) (*pageState, error) {
+func newPageFromMeta(meta map[string]interface{}, metaProvider *pageMeta) (*pageState, error) {
 	if metaProvider.f == nil {
 		metaProvider.f = page.NewZeroFile(metaProvider.s.DistinctWarningLog)
 	}
@@ -105,10 +105,28 @@
 		return nil, err
 	}
 
-	if err := metaProvider.applyDefaultValues(); err != nil {
-		return nil, err
+	initMeta := func(bucket *pagesMapBucket) error {
+		if meta != nil || bucket != nil {
+			if err := metaProvider.setMetadata(bucket, ps, meta); err != nil {
+				return ps.wrapError(err)
+			}
+		}
+
+		if err := metaProvider.applyDefaultValues(); err != nil {
+			return err
+		}
+
+		return nil
 	}
 
+	if metaProvider.standalone {
+		initMeta(nil)
+	} else {
+		// Because of possible cascade keywords, we need to delay this
+		// until we have the complete page graph.
+		ps.metaInitFn = initMeta
+	}
+
 	ps.init.Add(func() (interface{}, error) {
 		pp, err := newPagePaths(metaProvider.s, ps, metaProvider)
 		if err != nil {
@@ -152,7 +170,7 @@
 func newPageStandalone(m *pageMeta, f output.Format) (*pageState, error) {
 	m.configuredOutputFormats = output.Formats{f}
 	m.standalone = true
-	p, err := newPageFromMeta(m)
+	p, err := newPageFromMeta(nil, m)
 
 	if err != nil {
 		return nil, err
@@ -211,12 +229,16 @@
 
 	ps.shortcodeState = newShortcodeHandler(ps, ps.s, nil)
 
-	if err := ps.mapContent(metaProvider); err != nil {
-		return nil, ps.wrapError(err)
-	}
+	ps.metaInitFn = func(bucket *pagesMapBucket) error {
+		if err := ps.mapContent(bucket, metaProvider); err != nil {
+			return ps.wrapError(err)
+		}
 
-	if err := metaProvider.applyDefaultValues(); err != nil {
-		return nil, err
+		if err := metaProvider.applyDefaultValues(); err != nil {
+			return err
+		}
+
+		return nil
 	}
 
 	ps.init.Add(func() (interface{}, error) {
--- a/hugolib/pagecollections.go
+++ b/hugolib/pagecollections.go
@@ -387,6 +387,7 @@
 }
 
 func (c *PageCollections) assemblePagesMap(s *Site) error {
+
 	c.pagesMap = newPagesMap(s)
 
 	rootSections := make(map[string]bool)
@@ -437,18 +438,14 @@
 	var (
 		bucketsToRemove []string
 		rootBuckets     []*pagesMapBucket
+		walkErr         error
 	)
 
 	c.pagesMap.r.Walk(func(s string, v interface{}) bool {
 		bucket := v.(*pagesMapBucket)
-		var parentBucket *pagesMapBucket
+		parentBucket := c.pagesMap.parentBucket(s)
 
-		if s != "/" {
-			_, parentv, found := c.pagesMap.r.LongestPrefix(path.Dir(s))
-			if !found {
-				panic(fmt.Sprintf("[BUG] parent bucket not found for %q", s))
-			}
-			parentBucket = parentv.(*pagesMapBucket)
+		if parentBucket != nil {
 
 			if !mainSectionsFound && strings.Count(s, "/") == 1 {
 				// Root section
@@ -535,6 +532,10 @@
 
 		return false
 	})
+
+	if walkErr != nil {
+		return walkErr
+	}
 
 	c.pagesMap.s.lastmod = siteLastmod
 
--- a/hugolib/pages_map.go
+++ b/hugolib/pages_map.go
@@ -68,6 +68,43 @@
 	return home
 }
 
+func (m *pagesMap) initPageMeta(p *pageState, bucket *pagesMapBucket) error {
+	var err error
+	p.metaInit.Do(func() {
+		if p.metaInitFn != nil {
+			err = p.metaInitFn(bucket)
+		}
+	})
+	return err
+}
+
+func (m *pagesMap) initPageMetaFor(prefix string, bucket *pagesMapBucket) error {
+	parentBucket := m.parentBucket(prefix)
+
+	m.mergeCascades(bucket, parentBucket)
+
+	if err := m.initPageMeta(bucket.owner, bucket); err != nil {
+		return err
+	}
+
+	if !bucket.view {
+		for _, p := range bucket.pages {
+			ps := p.(*pageState)
+			if err := m.initPageMeta(ps, bucket); err != nil {
+				return err
+			}
+
+			for _, p := range ps.resources.ByType(pageResourceType) {
+				if err := m.initPageMeta(p.(*pageState), bucket); err != nil {
+					return err
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
 func (m *pagesMap) createSectionIfNotExists(section string) {
 	key := m.cleanKey(section)
 	_, found := m.r.Get(key)
@@ -126,18 +163,19 @@
 	bucket.pages = append(bucket.pages, p)
 }
 
-func (m *pagesMap) withEveryPage(f func(p *pageState)) {
-	m.r.Walk(func(k string, v interface{}) bool {
-		b := v.(*pagesMapBucket)
-		f(b.owner)
-		if !b.view {
-			for _, p := range b.pages {
-				f(p.(*pageState))
-			}
-		}
+func (m *pagesMap) assemblePageMeta() error {
+	var walkErr error
+	m.r.Walk(func(s string, v interface{}) bool {
+		bucket := v.(*pagesMapBucket)
 
+		if err := m.initPageMetaFor(s, bucket); err != nil {
+			walkErr = err
+			return true
+		}
 		return false
 	})
+
+	return walkErr
 }
 
 func (m *pagesMap) assembleTaxonomies(s *Site) error {
@@ -165,6 +203,9 @@
 
 			key := m.cleanKey(plural)
 			bucket = m.addBucketFor(key, n, nil)
+			if err := m.initPageMetaFor(key, bucket); err != nil {
+				return err
+			}
 		}
 
 		if bucket.meta == nil {
@@ -201,7 +242,7 @@
 
 	}
 
-	addTaxonomy := func(singular, plural, term string, weight int, p page.Page) {
+	addTaxonomy := func(singular, plural, term string, weight int, p page.Page) error {
 		bkey := bucketKey{
 			plural: plural,
 		}
@@ -228,6 +269,9 @@
 
 			key := m.cleanKey(path.Join(plural, termKey))
 			b2 = m.addBucketFor(key, n, meta)
+			if err := m.initPageMetaFor(key, b2); err != nil {
+				return err
+			}
 			b1.pages = append(b1.pages, b2.owner)
 			taxonomyBuckets[bkey] = b2
 
@@ -239,6 +283,8 @@
 
 		b1.owner.m.Dates.UpdateDateAndLastmodIfAfter(p)
 		b2.owner.m.Dates.UpdateDateAndLastmodIfAfter(p)
+
+		return nil
 	}
 
 	m.r.Walk(func(k string, v interface{}) bool {
@@ -262,10 +308,14 @@
 				if vals != nil {
 					if v, ok := vals.([]string); ok {
 						for _, idx := range v {
-							addTaxonomy(singular, plural, idx, weight, p)
+							if err := addTaxonomy(singular, plural, idx, weight, p); err != nil {
+								m.s.Log.ERROR.Printf("Failed to add taxonomy %q for %q: %s", plural, p.Path(), err)
+							}
 						}
 					} else if v, ok := vals.(string); ok {
-						addTaxonomy(singular, plural, v, weight, p)
+						if err := addTaxonomy(singular, plural, v, weight, p); err != nil {
+							m.s.Log.ERROR.Printf("Failed to add taxonomy %q for %q: %s", plural, p.Path(), err)
+						}
 					} else {
 						m.s.Log.ERROR.Printf("Invalid %s in %q\n", plural, p.Path())
 					}
@@ -291,16 +341,41 @@
 	return "/" + key
 }
 
-func (m *pagesMap) dump() {
-	m.r.Walk(func(s string, v interface{}) bool {
+func (m *pagesMap) mergeCascades(b1, b2 *pagesMapBucket) {
+	if b1.cascade == nil {
+		b1.cascade = make(map[string]interface{})
+	}
+	if b2 != nil && b2.cascade != nil {
+		for k, v := range b2.cascade {
+			if _, found := b1.cascade[k]; !found {
+				b1.cascade[k] = v
+			}
+		}
+	}
+}
+
+func (m *pagesMap) parentBucket(prefix string) *pagesMapBucket {
+	if prefix == "/" {
+		return nil
+	}
+	_, parentv, found := m.r.LongestPrefix(path.Dir(prefix))
+	if !found {
+		panic(fmt.Sprintf("[BUG] parent bucket not found for %q", prefix))
+	}
+	return parentv.(*pagesMapBucket)
+
+}
+
+func (m *pagesMap) withEveryPage(f func(p *pageState)) {
+	m.r.Walk(func(k string, v interface{}) bool {
 		b := v.(*pagesMapBucket)
-		fmt.Println("-------\n", s, ":", b.owner.Kind(), ":")
-		if b.owner != nil {
-			fmt.Println("Owner:", b.owner.Path())
+		f(b.owner)
+		if !b.view {
+			for _, p := range b.pages {
+				f(p.(*pageState))
+			}
 		}
-		for _, p := range b.pages {
-			fmt.Println(p.Path())
-		}
+
 		return false
 	})
 }
@@ -311,6 +386,9 @@
 
 	// Some additional metatadata attached to this node.
 	meta map[string]interface{}
+
+	// Cascading front matter.
+	cascade map[string]interface{}
 
 	owner *pageState // The branch node
 
--- a/hugolib/site.go
+++ b/hugolib/site.go
@@ -1650,12 +1650,13 @@
 }
 
 func (s *Site) newTaxonomyPage(title string, sections ...string) *pageState {
-	p, err := newPageFromMeta(&pageMeta{
-		title:    title,
-		s:        s,
-		kind:     page.KindTaxonomy,
-		sections: sections,
-	})
+	p, err := newPageFromMeta(
+		map[string]interface{}{"title": title},
+		&pageMeta{
+			s:        s,
+			kind:     page.KindTaxonomy,
+			sections: sections,
+		})
 
 	if err != nil {
 		panic(err)
@@ -1666,11 +1667,13 @@
 }
 
 func (s *Site) newPage(kind string, sections ...string) *pageState {
-	p, err := newPageFromMeta(&pageMeta{
-		s:        s,
-		kind:     kind,
-		sections: sections,
-	})
+	p, err := newPageFromMeta(
+		map[string]interface{}{},
+		&pageMeta{
+			s:        s,
+			kind:     kind,
+			sections: sections,
+		})
 
 	if err != nil {
 		panic(err)
--- a/hugolib/testhelpers_test.go
+++ b/hugolib/testhelpers_test.go
@@ -649,9 +649,16 @@
 func (s *sitesBuilder) AssertFileContent(filename string, matches ...string) {
 	s.T.Helper()
 	content := s.FileContent(filename)
-	for _, match := range matches {
-		if !strings.Contains(content, match) {
-			s.Fatalf("No match for %q in content for %s\n%s\n%q", match, filename, content, content)
+	for _, m := range matches {
+		lines := strings.Split(m, "\n")
+		for _, match := range lines {
+			match = strings.TrimSpace(match)
+			if match == "" {
+				continue
+			}
+			if !strings.Contains(content, match) {
+				s.Fatalf("No match for %q in content for %s\n%s\n%q", match, filename, content, content)
+			}
 		}
 	}
 }