Allow cascade to be a slice with a _target discriminator
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Mon, 5 Oct 2020 18:01:52 +0000 (20:01 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Tue, 6 Oct 2020 12:06:10 +0000 (14:06 +0200)
Fixes #7782

docs/content/en/content-management/front-matter.md
hugofs/glob/glob.go
hugofs/glob/glob_test.go
hugolib/cascade_test.go
hugolib/content_map_page.go
hugolib/page__meta.go
hugolib/page_test.go
hugolib/pages_capture.go
resources/page/page_matcher.go [new file with mode: 0644]
resources/page/page_matcher_test.go [new file with mode: 0644]
resources/page/testhelpers_test.go

index a14cb3065b62f9ab79301d77d8095b928eb26255..097cf05987cf481866c258d404a10fe623aef8fd 100644 (file)
@@ -159,6 +159,39 @@ show_comments: false
 
 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`
@@ -174,6 +207,8 @@ With the above example the Blog section page and its descendents will return `im
 - 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
 
 You can assign content-specific `weight` in the front matter of your content. These values are especially useful for [ordering][ordering] in list views. You can use `weight` for ordering of content and the convention of [`<TAXONOMY>_weight`][taxweight] for ordering content within a taxonomy. See [Ordering and Grouping Hugo Lists][lists] to see how `weight` can be used to organize your content in list views.
index 124a3d50e2b0b4eb48cc982ef37a02f629daa878..88485e1f00cdf25c5af79191583f4ac069270178 100644 (file)
@@ -23,30 +23,36 @@ import (
        "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
 
 }
 
index cca8e4e0fadf09960180deabc50d7230f8189ab0..5a6ef5b7ec162f4a91286b27b4bb2d9a46183970 100644 (file)
@@ -73,5 +73,14 @@ func TestGetGlob(t *testing.T) {
        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)
+               }
+       }
 
 }
index 33fc7ceec275f3fe2590e49ad81c8903b70790b5..336acdcf354bd98a4cd1288fd961f368911f2a20 100644 (file)
@@ -229,7 +229,7 @@ Banner: post.jpg`,
 
                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)
 
@@ -392,3 +392,71 @@ defaultContentLanguageInSubDir = false
 
        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|
+`)
+
+       })
+
+}
index b32f808c971cc83c66a2b61dc8011e3dd58f2c07..b2a8fda7edd0cb8c42c778b8c6c74428e2e449a3 100644 (file)
@@ -789,7 +789,7 @@ func (m *pageMaps) withMaps(fn func(pm *pageMap) error) error {
 
 type pagesMapBucket struct {
        // Cascading front matter.
-       cascade maps.Params
+       cascade map[page.PageMatcher]maps.Params
 
        owner *pageState // The branch node
 
index 0e16292f0a4b026e9ef43446e666e5469f15b419..c7a8db3d5f3105a72aee82f7c6ab38f97d676e43 100644 (file)
@@ -308,12 +308,22 @@ func (p *pageMeta) Weight() int {
 
 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,14 +342,44 @@ func (pm *pageMeta) setMetadata(parentBucket *pagesMapBucket, p *pageState, fron
                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 {
                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,9 +391,14 @@ func (pm *pageMeta) setMetadata(parentBucket *pagesMapBucket, p *pageState, fron
                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
+                       }
                }
        }
 
@@ -466,7 +511,7 @@ func (pm *pageMeta) setMetadata(parentBucket *pagesMapBucket, p *pageState, fron
                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 {
index 2df679a74fef2be16c05903ebb9f6d964bb06ee4..3a30216ed607dad804427fef55ef691f9ea18654 100644 (file)
@@ -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"))
+}
index ddb4bc4951cf51d7cce11155d8741951c5650722..4ee33992dbe48792f3d13a86502fda2758ce2225 100644 (file)
@@ -137,6 +137,7 @@ func (c *pagesCollector) isCascadingEdit(dir contentDirKey) (bool, string) {
                hasCascade := n.p.bucket.cascade != nil && len(n.p.bucket.cascade) > 0
                if !ok {
                        isCascade = hasCascade
+
                        return true
                }
 
@@ -145,7 +146,12 @@ func (c *pagesCollector) isCascadingEdit(dir contentDirKey) (bool, string) {
                        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 @@ func (c *pagesCollector) Collect() (collectErr error) {
                                collectErr = c.collectDir(dir.dirname, true, nil)
                        case bundleBranch:
                                isCascading, section := c.isCascadingEdit(dir)
+
                                if isCascading {
                                        c.contentMap.deleteSection(section)
                                }
diff --git a/resources/page/page_matcher.go b/resources/page/page_matcher.go
new file mode 100644 (file)
index 0000000..881f76e
--- /dev/null
@@ -0,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
+
+}
diff --git a/resources/page/page_matcher_test.go b/resources/page/page_matcher_test.go
new file mode 100644 (file)
index 0000000..24b3047
--- /dev/null
@@ -0,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/**"})
+       })
+
+}
index 17a795a208cbee53b9d23483772e74603efc0480..54a908d3bf2565530a375971af612005e35ef861 100644 (file)
@@ -85,11 +85,12 @@ func newTestPathSpecFor(cfg config.Provider) *helpers.PathSpec {
 }
 
 type testPage struct {
+       kind        string
        description string
        title       string
        linkTitle   string
-
-       section string
+       lang        string
+       section     string
 
        content string
 
@@ -297,11 +298,11 @@ func (p *testPage) Keywords() []string {
 }
 
 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 {