// 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" "strings" "testing" "github.com/gohugoio/hugo/common/maps" qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/parser" "github.com/gohugoio/hugo/parser/metadecoders" ) 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) { c := qt.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{}) c.Assert(err, qt.IsNil) first := builder.H.Sites[0] c.Assert(first, qt.Not(qt.IsNil)) } }) } } func BenchmarkCascadeTarget(b *testing.B) { files := ` -- content/_index.md -- background = 'yosemite.jpg' [cascade._target] kind = '{section,term}' -- content/posts/_index.md -- -- content/posts/funny/_index.md -- ` for i := 1; i < 100; i++ { files += fmt.Sprintf("\n-- content/posts/p%d.md --\n", i+1) } for i := 1; i < 100; i++ { files += fmt.Sprintf("\n-- content/posts/funny/pf%d.md --\n", i+1) } b.Run("Kind", func(b *testing.B) { cfg := IntegrationTestConfig{ T: b, TxtarString: files, } builders := make([]*IntegrationTestBuilder, b.N) for i := range builders { builders[i] = NewIntegrationTestBuilder(cfg) } b.ResetTimer() for i := 0; i < b.N; i++ { builders[i].Build() } }) } func TestCascadeConfig(t *testing.T) { c := qt.New(t) // Make sure the cascade from config gets applied even if we're not // having a content file for the home page. for _, withHomeContent := range []bool{true, false} { testName := "Home content file" if !withHomeContent { testName = "No home content file" } c.Run(testName, func(c *qt.C) { b := newTestSitesBuilder(c) b.WithConfigFile("toml", ` baseURL="https://example.org" [cascade] img1 = "img1-config.jpg" imgconfig = "img-config.jpg" `) if withHomeContent { b.WithContent("_index.md", ` --- title: "Home" cascade: img1: "img1-home.jpg" img2: "img2-home.jpg" --- `) } b.WithContent("p1.md", ``) b.Build(BuildCfg{}) p1 := b.H.Sites[0].getPageOldVersion("p1") if withHomeContent { b.Assert(p1.Params(), qt.DeepEquals, maps.Params{ "imgconfig": "img-config.jpg", "draft": bool(false), "iscjklanguage": bool(false), "img1": "img1-home.jpg", "img2": "img2-home.jpg", }) } else { b.Assert(p1.Params(), qt.DeepEquals, maps.Params{ "img1": "img1-config.jpg", "imgconfig": "img-config.jpg", "draft": bool(false), "iscjklanguage": bool(false), }) } }) } } func TestCascade(t *testing.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.H.Sites[0].pageMap.debugPrint("", 999, os.Stdout) // 12|term|/categories/cool|Cascade Category|cat.png|page|html-|\ b.AssertFileContent("public/index.html", ` 12|term|/categories/cool|Cascade Category|cat.png|categories|html-| 12|term|/categories/catsect1|Cascade Category|cat.png|categories|html-| 12|term|/categories/funny|Cascade Category|cat.png|categories|html-| 12|taxonomy|/categories|My Categories|cat.png|categories|html-| 32|term|/categories/sad|Cascade Category|sad.png|categories|html-| 42|term|/tags/blue|Cascade Home|home.png|tags|html-| 42|taxonomy|/tags|Cascade Home|home.png|tags|html-| 42|section|/sectnocontent|Cascade Home|home.png|sectnocontent|html-| 42|section|/sect3|Cascade Home|home.png|sect3|html-| 42|page|/bundle1|Cascade Home|home.png|page|html-| 42|page|/p2|Cascade Home|home.png|page|html-| 42|page|/sect2/p2|Cascade Home|home.png|sect2|html-| 42|page|/sect3/nofrontmatter|Cascade Home|home.png|sect3|html-| 42|page|/sect3/p1|Cascade Home|home.png|sect3|html-| 42|page|/sectnocontent/p1|Cascade Home|home.png|sectnocontent|html-| 42|section|/sectnofrontmatter|Cascade Home|home.png|sectnofrontmatter|html-| 42|term|/tags/green|Cascade Home|home.png|tags|html-| 42|home|/|Home|home.png|page|html-| 42|page|/p1|p1|home.png|page|html-| 42|section|/sect1|Sect1|sect1.png|stype|html-| 42|section|/sect1/s1_2|Sect1_2|sect1.png|stype|html-| 42|page|/sect1/s1_2/p1|Sect1_2_p1|sect1.png|stype|html-| 42|page|/sect1/s1_2/p2|Sect1_2_p2|sect1.png|stype|html-| 42|section|/sect2|Sect2|home.png|sect2|html-| 42|page|/sect2/p1|Sect2_p1|home.png|sect2|html-| 52|page|/sect4/p1|Cascade Home|home.png|sect4|rss-| 52|section|/sect4|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", `https://example.org/sect4/index.xml`) b.AssertFileContent("public/sect4/p1/index.xml", `https://example.org/sect4/p1/index.xml`) b.C.Assert(b.CheckExists("public/sect2/index.xml"), qt.Equals, false) // Check cascade into bundled page b.AssertFileContent("public/bundle1/index.html", `Resources: bp1.md|home.png|`) }) } func TestCascadeEdit(t *testing.T) { p1Content := `--- title: P1 --- ` indexContentNoCascade := ` --- title: Home --- ` indexContentCascade := ` --- title: Section cascade: banner: post.jpg layout: postlayout type: posttype --- ` layout := `Banner: {{ .Params.banner }}|Layout: {{ .Layout }}|Type: {{ .Type }}|Content: {{ .Content }}` newSite := func(t *testing.T, cascade bool) *sitesBuilder { b := newTestSitesBuilder(t).Running() b.WithTemplates("_default/single.html", layout) b.WithTemplates("_default/list.html", layout) if cascade { b.WithContent("post/_index.md", indexContentCascade) } else { b.WithContent("post/_index.md", indexContentNoCascade) } b.WithContent("post/dir/p1.md", p1Content) return b } t.Run("Edit descendant", func(t *testing.T) { t.Parallel() b := newSite(t, true) b.Build(BuildCfg{}) assert := func() { b.Helper() b.AssertFileContent("public/post/dir/p1/index.html", `Banner: post.jpg|`, `Layout: postlayout`, `Type: posttype`, ) } assert() b.EditFiles("content/post/dir/p1.md", p1Content+"\ncontent edit") b.Build(BuildCfg{}) assert() b.AssertFileContent("public/post/dir/p1/index.html", `content edit Banner: post.jpg`, ) }) t.Run("Edit ancestor", func(t *testing.T) { t.Parallel() b := newSite(t, true) b.Build(BuildCfg{}) b.AssertFileContent("public/post/dir/p1/index.html", `Banner: post.jpg|Layout: postlayout|Type: posttype|Content:`) b.EditFiles("content/post/_index.md", strings.Replace(indexContentCascade, "post.jpg", "edit.jpg", 1)) b.Build(BuildCfg{}) b.AssertFileContent("public/post/index.html", `Banner: edit.jpg|Layout: postlayout|Type: posttype|`) b.AssertFileContent("public/post/dir/p1/index.html", `Banner: edit.jpg|Layout: postlayout|Type: posttype|`) }) t.Run("Edit ancestor, add cascade", func(t *testing.T) { t.Parallel() b := newSite(t, true) b.Build(BuildCfg{}) b.AssertFileContent("public/post/dir/p1/index.html", `Banner: post.jpg`) b.EditFiles("content/post/_index.md", indexContentCascade) b.Build(BuildCfg{}) b.AssertFileContent("public/post/index.html", `Banner: post.jpg|Layout: postlayout|Type: posttype|`) b.AssertFileContent("public/post/dir/p1/index.html", `Banner: post.jpg|Layout: postlayout|`) }) t.Run("Edit ancestor, remove cascade", func(t *testing.T) { t.Parallel() b := newSite(t, false) b.Build(BuildCfg{}) b.AssertFileContent("public/post/dir/p1/index.html", `Banner: |Layout: |`) b.EditFiles("content/post/_index.md", indexContentNoCascade) b.Build(BuildCfg{}) b.AssertFileContent("public/post/index.html", `Banner: |Layout: |Type: post|`) b.AssertFileContent("public/post/dir/p1/index.html", `Banner: |Layout: |`) }) t.Run("Edit ancestor, content only", func(t *testing.T) { t.Parallel() b := newSite(t, true) b.Build(BuildCfg{}) b.EditFiles("content/post/_index.md", indexContentCascade+"\ncontent edit") counters := &buildCounters{} b.Build(BuildCfg{testCounters: counters}) b.Assert(int(counters.contentRenderCounter.Load()), qt.Equals, 1) b.AssertFileContent("public/post/index.html", `Banner: post.jpg|Layout: postlayout|Type: posttype|Content:
content edit
`) b.AssertFileContent("public/post/dir/p1/index.html", `Banner: post.jpg|Layout: postlayout|`) }) } func TestCascadeBuildOptionsTaxonomies(t *testing.T) { t.Parallel() files := ` -- hugo.toml -- baseURL="https://example.org" [taxonomies] tag = "tags" [[cascade]] [cascade._build] render = "never" list = "never" publishResources = false [cascade._target] path = '/hidden/**' -- content/p1.md -- --- title: P1 --- -- content/hidden/p2.md -- --- title: P2 tags: [t1, t2] --- -- layouts/_default/list.html -- List: {{ len .Pages }}| -- layouts/_default/single.html -- Single: Tags: {{ site.Taxonomies.tags }}| ` b := Test(t, files) b.AssertFileContent("public/p1/index.html", "Single: Tags: map[]|") b.AssertFileContent("public/tags/index.html", "List: 0|") b.AssertFileExists("public/hidden/p2/index.html", false) b.AssertFileExists("public/tags/t2/index.html", false) } func newCascadeTestBuilder(t testing.TB, langs []string) *sitesBuilder { p := func(m map[string]any) 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]any{ "title": "Home", "cascade": map[string]any{ "title": "Cascade Home", "ICoN": "home.png", "outputs": []string{"HTML"}, "weight": 42, }, }), "p1.md", p(map[string]any{ "title": "p1", }), "p2.md", p(map[string]any{}), "sect1/_index.md", p(map[string]any{ "title": "Sect1", "type": "stype", "cascade": map[string]any{ "title": "Cascade Sect1", "icon": "sect1.png", "type": "stype", "categories": []string{"catsect1"}, }, }), "sect1/s1_2/_index.md", p(map[string]any{ "title": "Sect1_2", }), "sect1/s1_2/p1.md", p(map[string]any{ "title": "Sect1_2_p1", }), "sect1/s1_2/p2.md", p(map[string]any{ "title": "Sect1_2_p2", }), "sect2/_index.md", p(map[string]any{ "title": "Sect2", }), "sect2/p1.md", p(map[string]any{ "title": "Sect2_p1", "categories": []string{"cool", "funny", "sad"}, "tags": []string{"blue", "green"}, }), "sect2/p2.md", p(map[string]any{}), "sect3/p1.md", p(map[string]any{}), // No front matter, see #6855 "sect3/nofrontmatter.md", `**Hello**`, "sectnocontent/p1.md", `**Hello**`, "sectnofrontmatter/_index.md", `**Hello**`, "sect4/_index.md", p(map[string]any{ "title": "Sect4", "cascade": map[string]any{ "weight": 52, "outputs": []string{"RSS"}, }, }), "sect4/p1.md", p(map[string]any{}), "p2.md", p(map[string]any{}), "bundle1/index.md", p(map[string]any{}), "bundle1/bp1.md", p(map[string]any{}), "categories/_index.md", p(map[string]any{ "title": "My Categories", "cascade": map[string]any{ "title": "Cascade Category", "icoN": "cat.png", "weight": 12, }, }), "categories/cool/_index.md", p(map[string]any{}), "categories/sad/_index.md", p(map[string]any{ "cascade": map[string]any{ "icon": "sad.png", "weight": 32, }, }), ) } createContentFiles("en") b.WithTemplates("index.html", ` {{ range .Site.Pages }} {{- .Weight }}|{{ .Kind }}|{{ .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 } 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| `) }) c.Run("slice with environment _target", func(c *qt.C) { b := newBuilder(c) b.WithContent("_index.md", `+++ title = "Home" [[cascade]] p1 = "p1" [cascade._target] path="**p1**" environment="testing" [[cascade]] p2 = "p2" [cascade._target] kind="section" environment="production" +++ `) b.Build(BuildCfg{}) b.AssertFileContent("public/index.html", ` P1|p1:|p2:| S1|p1:|p2:p2| `) }) c.Run("slice with yaml _target", func(c *qt.C) { b := newBuilder(c) b.WithContent("_index.md", `--- title: "Home" cascade: - p1: p1 _target: path: "**p1**" - p2: p2 _target: kind: "section" --- `) b.Build(BuildCfg{}) b.AssertFileContent("public/index.html", ` P1|p1:p1|p2:| S1|p1:|p2:p2| `) }) c.Run("slice with json _target", func(c *qt.C) { b := newBuilder(c) b.WithContent("_index.md", `{ "title": "Home", "cascade": [ { "p1": "p1", "_target": { "path": "**p1**" } },{ "p2": "p2", "_target": { "kind": "section" } } ] } `) b.Build(BuildCfg{}) b.AssertFileContent("public/index.html", ` P1|p1:p1|p2:| S1|p1:|p2:p2| `) }) } func TestCascadeEditIssue12449(t *testing.T) { t.Parallel() files := ` -- hugo.toml -- baseURL = "https://example.com" disableKinds = ['sitemap','rss', 'home', 'taxonomy','term'] disableLiveReload = true -- layouts/_default/list.html -- Title: {{ .Title }}|{{ .Content }}|cascadeparam: {{ .Params.cascadeparam }}| -- layouts/_default/single.html -- Title: {{ .Title }}|{{ .Content }}|cascadeparam: {{ .Params.cascadeparam }}| -- content/mysect/_index.md -- --- title: mysect cascade: description: descriptionvalue params: cascadeparam: cascadeparamvalue --- mysect-content| -- content/mysect/p1/index.md -- --- slug: p1 --- p1-content| -- content/mysect/subsect/_index.md -- --- slug: subsect --- subsect-content| ` b := TestRunning(t, files) // Make the cascade set the title. b.EditFileReplaceAll("content/mysect/_index.md", "description: descriptionvalue", "title: cascadetitle").Build() b.AssertFileContent("public/mysect/subsect/index.html", "Title: cascadetitle|") // Edit cascade title. b.EditFileReplaceAll("content/mysect/_index.md", "title: cascadetitle", "title: cascadetitle-edit").Build() b.AssertFileContent("public/mysect/subsect/index.html", "Title: cascadetitle-edit|") // Revert title change. // The step below failed in #12449. b.EditFileReplaceAll("content/mysect/_index.md", "title: cascadetitle-edit", "description: descriptionvalue").Build() b.AssertFileContent("public/mysect/subsect/index.html", "Title: |") } // Issue 11977. func TestCascadeExtensionInPath(t *testing.T) { t.Parallel() files := ` -- hugo.toml -- baseURL = "https://example.org" [languages] [languages.en] weight = 1 [languages.de] -- content/_index.de.md -- +++ [[cascade]] [cascade.params] foo = 'bar' [cascade._target] path = '/posts/post-1.de.md' +++ -- content/posts/post-1.de.md -- --- title: "Post 1" --- -- layouts/_default/single.html -- {{ .Title }}|{{ .Params.foo }}$ ` b, err := TestE(t, files) b.Assert(err, qt.IsNotNil) b.AssertLogContains(`cascade target path "/posts/post-1.de.md" looks like a path with an extension; since Hugo v0.123.0 this will not match anything, see https://gohugo.io/methods/page/path/`) } func TestCascadeExtensionInPathIgnore(t *testing.T) { t.Parallel() files := ` -- hugo.toml -- baseURL = "https://example.org" ignoreLogs = ['cascade-pattern-with-extension'] [languages] [languages.en] weight = 1 [languages.de] -- content/_index.de.md -- +++ [[cascade]] [cascade.params] foo = 'bar' [cascade._target] path = '/posts/post-1.de.md' +++ -- content/posts/post-1.de.md -- --- title: "Post 1" --- -- layouts/_default/single.html -- {{ .Title }}|{{ .Params.foo }}$ ` b := Test(t, files) b.AssertLogNotContains(`looks like a path with an extension`) } func TestCascadConfigExtensionInPath(t *testing.T) { t.Parallel() files := ` -- hugo.toml -- baseURL = "https://example.org" [[cascade]] [cascade.params] foo = 'bar' [cascade._target] path = '/p1.md' ` b, err := TestE(t, files) b.Assert(err, qt.IsNotNil) b.AssertLogContains(`looks like a path with an extension`) } func TestCascadConfigExtensionInPathIgnore(t *testing.T) { t.Parallel() files := ` -- hugo.toml -- baseURL = "https://example.org" ignoreLogs = ['cascade-pattern-with-extension'] [[cascade]] [cascade.params] foo = 'bar' [cascade._target] path = '/p1.md' ` b := Test(t, files) b.AssertLogNotContains(`looks like a path with an extension`) } func TestCascadeIssue12172(t *testing.T) { t.Parallel() files := ` -- hugo.toml -- disableKinds = ['rss','sitemap','taxonomy','term'] [[cascade]] headless = true [cascade._target] path = '/s1**' -- content/s1/p1.md -- --- title: p1 --- -- layouts/_default/single.html -- {{ .Title }}| -- layouts/_default/list.html -- {{ .Title }}| ` b := Test(t, files) b.AssertFileExists("public/index.html", true) b.AssertFileExists("public/s1/index.html", false) b.AssertFileExists("public/s1/p1/index.html", false) }