ref: c63db7f1f6774a2d661af1d8197c6fe377e3ad25
parent: 5e2a547cb594b31ecb0f089b08db2e15c6dc381a
author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
date: Mon Oct 5 16:01:52 EDT 2020
Allow cascade to be a slice with a _target discriminator Fixes #7782
--- a/docs/content/en/content-management/front-matter.md
+++ b/docs/content/en/content-management/front-matter.md
@@ -159,6 +159,39 @@
Any node or section can pass down to descendents a set of Front Matter values as long as defined underneath the reserved `cascade` Front Matter key.
+### Target Specific Pages
+
+{{< new-in "0.76.0" >}}
+
+Since Hugo 0.76 the `cascade` block can be a slice with a optional `_target` keyword, allowing for multiple `cascade` values targeting different page sets.
+
+{{< code-toggle copy="false" >}}
+title ="Blog"
+[[cascade]]
+background = "yosemite.jpg"
+[cascade._target]
+path="/blog/**"
+lang="en"
+kind="page"
+[[cascade]]
+background = "goldenbridge.jpg"
+[cascade._target]
+kind="section"
+{{</ code-toggle >}}
+
+Keywords available for `_target`:
+
+path
+: A [Glob](https://github.com/gobwas/glob) pattern matching the content path below /content. Expects Unix-styled slashes. Note that this is the virtual path, so it starts at the mount root.
+
+kind
+: A Glob pattern matching the Page's Kind(s), e.g. "{home,section}".
+
+lang
+: A Glob pattern matching the Page's language, e.g. "{en,sv}".
+
+Any of the above can be omitted.
+
### Example
In `content/blog/_index.md`
@@ -173,6 +206,8 @@
- Said descendent has its own `banner` value set
- Or a closer ancestor node has its own `cascade.banner` value set.
+
+
## Order Content Through Front Matter
--- a/hugofs/glob/glob.go
+++ b/hugofs/glob/glob.go
@@ -23,30 +23,36 @@
"github.com/gobwas/glob/syntax"
)
+type globErr struct {
+ glob glob.Glob
+ err error
+}
+
var (
- globCache = make(map[string]glob.Glob)
+ globCache = make(map[string]globErr)
globMu sync.RWMutex
)
func GetGlob(pattern string) (glob.Glob, error) {
- var g glob.Glob
+ var eg globErr
globMu.RLock()
- g, found := globCache[pattern]
+ var found bool
+ eg, found = globCache[pattern]
globMu.RUnlock()
- if !found {
- var err error
- g, err = glob.Compile(strings.ToLower(pattern), '/')
- if err != nil {
- return nil, err
- }
-
- globMu.Lock()
- globCache[pattern] = g
- globMu.Unlock()
+ if found {
+ return eg.glob, eg.err
}
- return g, nil
+ var err error
+ g, err := glob.Compile(strings.ToLower(pattern), '/')
+ eg = globErr{g, err}
+
+ globMu.Lock()
+ globCache[pattern] = eg
+ globMu.Unlock()
+
+ return eg.glob, eg.err
}
--- a/hugofs/glob/glob_test.go
+++ b/hugofs/glob/glob_test.go
@@ -73,5 +73,14 @@
g, err := GetGlob("**.JSON")
c.Assert(err, qt.IsNil)
c.Assert(g.Match("data/my.json"), qt.Equals, true)
+}
+
+func BenchmarkGetGlob(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _, err := GetGlob("**/foo")
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
}
--- a/hugolib/cascade_test.go
+++ b/hugolib/cascade_test.go
@@ -229,7 +229,7 @@
counters := &testCounters{}
b.Build(BuildCfg{testCounters: counters})
- // As we only changed the content, not the cascade front matter, make
+ // As we only changed the content, not the cascade front matter,
// only the home page is re-rendered.
b.Assert(int(counters.contentRenderCounter), qt.Equals, 1)
@@ -391,4 +391,72 @@
)
return b
+}
+
+func TestCascadeTarget(t *testing.T) {
+ t.Parallel()
+
+ c := qt.New(t)
+
+ newBuilder := func(c *qt.C) *sitesBuilder {
+ b := newTestSitesBuilder(c)
+
+ b.WithTemplates("index.html", `
+{{ $p1 := site.GetPage "s1/p1" }}
+{{ $s1 := site.GetPage "s1" }}
+
+P1|p1:{{ $p1.Params.p1 }}|p2:{{ $p1.Params.p2 }}|
+S1|p1:{{ $s1.Params.p1 }}|p2:{{ $s1.Params.p2 }}|
+`)
+ b.WithContent("s1/_index.md", "---\ntitle: s1 section\n---")
+ b.WithContent("s1/p1/index.md", "---\ntitle: p1\n---")
+ b.WithContent("s1/p2/index.md", "---\ntitle: p2\n---")
+ b.WithContent("s2/p1/index.md", "---\ntitle: p1_2\n---")
+
+ return b
+
+ }
+
+ c.Run("slice", func(c *qt.C) {
+ b := newBuilder(c)
+ b.WithContent("_index.md", `+++
+title = "Home"
+[[cascade]]
+p1 = "p1"
+[[cascade]]
+p2 = "p2"
++++
+`)
+
+ b.Build(BuildCfg{})
+
+ b.AssertFileContent("public/index.html", "P1|p1:p1|p2:p2")
+
+ })
+
+ c.Run("slice with _target", func(c *qt.C) {
+ b := newBuilder(c)
+
+ b.WithContent("_index.md", `+++
+title = "Home"
+[[cascade]]
+p1 = "p1"
+[cascade._target]
+path="**p1**"
+[[cascade]]
+p2 = "p2"
+[cascade._target]
+kind="section"
++++
+`)
+
+ b.Build(BuildCfg{})
+
+ b.AssertFileContent("public/index.html", `
+P1|p1:p1|p2:|
+S1|p1:|p2:p2|
+`)
+
+ })
+
}
--- a/hugolib/content_map_page.go
+++ b/hugolib/content_map_page.go
@@ -789,7 +789,7 @@
type pagesMapBucket struct {
// Cascading front matter.
- cascade maps.Params
+ cascade map[page.PageMatcher]maps.Params
owner *pageState // The branch node
--- a/hugolib/page__meta.go
+++ b/hugolib/page__meta.go
@@ -308,12 +308,22 @@
func (pm *pageMeta) mergeBucketCascades(b1, b2 *pagesMapBucket) {
if b1.cascade == nil {
- b1.cascade = make(map[string]interface{})
+ b1.cascade = make(map[page.PageMatcher]maps.Params)
}
+
if b2 != nil && b2.cascade != nil {
for k, v := range b2.cascade {
- if _, found := b1.cascade[k]; !found {
+
+ vv, found := b1.cascade[k]
+ if !found {
b1.cascade[k] = v
+ } else {
+ // Merge
+ for ck, cv := range v {
+ if _, found := vv[ck]; !found {
+ vv[ck] = cv
+ }
+ }
}
}
}
@@ -332,7 +342,37 @@
if p.bucket != nil {
// Check for any cascade define on itself.
if cv, found := frontmatter["cascade"]; found {
- p.bucket.cascade = maps.ToStringMap(cv)
+ switch v := cv.(type) {
+ case []map[string]interface{}:
+ p.bucket.cascade = make(map[page.PageMatcher]maps.Params)
+
+ for _, vv := range v {
+ var m page.PageMatcher
+ if mv, found := vv["_target"]; found {
+ err := page.DecodePageMatcher(mv, &m)
+ if err != nil {
+ return err
+ }
+ }
+ c, found := p.bucket.cascade[m]
+ if found {
+ // Merge
+ for k, v := range vv {
+ if _, found := c[k]; !found {
+ c[k] = v
+ }
+ }
+ } else {
+ p.bucket.cascade[m] = vv
+ }
+
+ }
+ default:
+ p.bucket.cascade = map[page.PageMatcher]maps.Params{
+ page.PageMatcher{}: maps.ToStringMap(cv),
+ }
+ }
+
}
}
} else {
@@ -339,7 +379,7 @@
frontmatter = make(map[string]interface{})
}
- var cascade map[string]interface{}
+ var cascade map[page.PageMatcher]maps.Params
if p.bucket != nil {
if parentBucket != nil {
@@ -351,10 +391,15 @@
cascade = parentBucket.cascade
}
- for k, v := range cascade {
- if _, found := frontmatter[k]; !found {
- frontmatter[k] = v
+ for m, v := range cascade {
+ if !m.Matches(p) {
+ continue
}
+ for kk, vv := range v {
+ if _, found := frontmatter[kk]; !found {
+ frontmatter[kk] = vv
+ }
+ }
}
var mtime time.Time
@@ -466,7 +511,7 @@
case "outputs":
o := cast.ToStringSlice(v)
if len(o) > 0 {
- // Output formats are exlicitly set in front matter, use those.
+ // Output formats are explicitly set in front matter, use those.
outFormats, err := p.s.outputFormatsConfig.GetByNames(o...)
if err != nil {
--- a/hugolib/page_test.go
+++ b/hugolib/page_test.go
@@ -1757,3 +1757,24 @@
`<pre><code class="language-bash {hl_lines=[1]}" data-lang="bash {hl_lines=[1]}">SHORT`,
)
}
+
+func TestPageCaseIssues(t *testing.T) {
+ t.Parallel()
+
+ b := newTestSitesBuilder(t)
+ b.WithConfigFile("toml", `defaultContentLanguage = "no"
+[languages]
+[languages.NO]
+title = "Norsk"
+`)
+ b.WithContent("a/B/C/Page1.md", "---\ntitle: Page1\n---")
+ b.WithTemplates("index.html", `
+{{ $p1 := site.GetPage "a/B/C/Page1" }}
+Lang: {{ .Lang }}
+Page1: {{ $p1.Path }}
+`)
+
+ b.Build(BuildCfg{})
+
+ b.AssertFileContent("public/index.html", "Lang: no", filepath.FromSlash("Page1: a/B/C/Page1.md"))
+}
--- a/hugolib/pages_capture.go
+++ b/hugolib/pages_capture.go
@@ -137,6 +137,7 @@
hasCascade := n.p.bucket.cascade != nil && len(n.p.bucket.cascade) > 0
if !ok {
isCascade = hasCascade
+
return true
}
@@ -145,7 +146,12 @@
return true
}
- isCascade = !reflect.DeepEqual(cascade1, n.p.bucket.cascade)
+ for _, v := range n.p.bucket.cascade {
+ isCascade = !reflect.DeepEqual(cascade1, v)
+ if isCascade {
+ break
+ }
+ }
return true
@@ -187,6 +193,7 @@
collectErr = c.collectDir(dir.dirname, true, nil)
case bundleBranch:
isCascading, section := c.isCascadingEdit(dir)
+
if isCascading {
c.contentMap.deleteSection(section)
}
--- /dev/null
+++ b/resources/page/page_matcher.go
@@ -1,0 +1,91 @@
+// Copyright 2020 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 page
+
+import (
+ "path/filepath"
+ "strings"
+
+ "github.com/pkg/errors"
+
+ "github.com/gohugoio/hugo/hugofs/glob"
+ "github.com/mitchellh/mapstructure"
+)
+
+// A PageMatcher can be used to match a Page with Glob patterns.
+// Note that the pattern matching is case insensitive.
+type PageMatcher struct {
+ // A Glob pattern matching the content path below /content.
+ // Expects Unix-styled slashes.
+ // Note that this is the virtual path, so it starts at the mount root
+ // with a leading "/".
+ Path string
+
+ // A Glob pattern matching the Page's Kind(s), e.g. "{home,section}"
+ Kind string
+
+ // A Glob pattern matching the Page's language, e.g. "{en,sv}".
+ Lang string
+}
+
+// Matches returns whether p matches this matcher.
+func (m PageMatcher) Matches(p Page) bool {
+
+ if m.Kind != "" {
+ g, err := glob.GetGlob(m.Kind)
+ if err == nil && !g.Match(p.Kind()) {
+ return false
+ }
+ }
+
+ if m.Lang != "" {
+ g, err := glob.GetGlob(m.Lang)
+ if err == nil && !g.Match(p.Lang()) {
+ return false
+ }
+ }
+
+ if m.Path != "" {
+ g, err := glob.GetGlob(m.Path)
+ // TODO(bep) Path() vs filepath vs leading slash.
+ p := strings.ToLower(filepath.ToSlash(p.Path()))
+ if !(strings.HasPrefix(p, "/")) {
+ p = "/" + p
+ }
+ if err == nil && !g.Match(p) {
+ return false
+ }
+ }
+
+ return true
+}
+
+// DecodePageMatcher decodes m into v.
+func DecodePageMatcher(m interface{}, v *PageMatcher) error {
+ if err := mapstructure.WeakDecode(m, v); err != nil {
+ return err
+ }
+
+ v.Kind = strings.ToLower(v.Kind)
+ if v.Kind != "" {
+ if _, found := kindMap[v.Kind]; !found {
+ return errors.Errorf("%q is not a valid Page Kind", v.Kind)
+ }
+ }
+
+ v.Path = filepath.ToSlash(strings.ToLower(v.Path))
+
+ return nil
+
+}
--- /dev/null
+++ b/resources/page/page_matcher_test.go
@@ -1,0 +1,63 @@
+// Copyright 2020 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 page
+
+import (
+ "path/filepath"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestPageMatcher(t *testing.T) {
+ c := qt.New(t)
+
+ p1, p2, p3 := &testPage{path: "/p1", kind: "section", lang: "en"}, &testPage{path: "p2", kind: "page", lang: "no"}, &testPage{path: "p3", kind: "page", lang: "en"}
+
+ c.Run("Matches", func(c *qt.C) {
+ m := PageMatcher{Kind: "section"}
+
+ c.Assert(m.Matches(p1), qt.Equals, true)
+ c.Assert(m.Matches(p2), qt.Equals, false)
+
+ m = PageMatcher{Kind: "page"}
+ c.Assert(m.Matches(p1), qt.Equals, false)
+ c.Assert(m.Matches(p2), qt.Equals, true)
+ c.Assert(m.Matches(p3), qt.Equals, true)
+
+ m = PageMatcher{Kind: "page", Path: "/p2"}
+ c.Assert(m.Matches(p1), qt.Equals, false)
+ c.Assert(m.Matches(p2), qt.Equals, true)
+ c.Assert(m.Matches(p3), qt.Equals, false)
+
+ m = PageMatcher{Path: "/p*"}
+ c.Assert(m.Matches(p1), qt.Equals, true)
+ c.Assert(m.Matches(p2), qt.Equals, true)
+ c.Assert(m.Matches(p3), qt.Equals, true)
+
+ m = PageMatcher{Lang: "en"}
+ c.Assert(m.Matches(p1), qt.Equals, true)
+ c.Assert(m.Matches(p2), qt.Equals, false)
+ c.Assert(m.Matches(p3), qt.Equals, true)
+
+ })
+
+ c.Run("Decode", func(c *qt.C) {
+ var v PageMatcher
+ c.Assert(DecodePageMatcher(map[string]interface{}{"kind": "foo"}, &v), qt.Not((qt.IsNil)))
+ c.Assert(DecodePageMatcher(map[string]interface{}{"kind": "home", "path": filepath.FromSlash("/a/b/**")}, &v), qt.IsNil)
+ c.Assert(v, qt.Equals, PageMatcher{Kind: "home", Path: "/a/b/**"})
+ })
+
+}
--- a/resources/page/testhelpers_test.go
+++ b/resources/page/testhelpers_test.go
@@ -85,12 +85,13 @@
}
type testPage struct {
+ kind string
description string
title string
linkTitle string
+ lang string
+ section string
- section string
-
content string
fuzzyWordCount int
@@ -297,11 +298,11 @@
}
func (p *testPage) Kind() string {
- panic("not implemented")
+ return p.kind
}
func (p *testPage) Lang() string {
- panic("not implemented")
+ return p.lang
}
func (p *testPage) Language() *langs.Language {