diff --git a/common/hugo/version_current.go b/common/hugo/version_current.go index 993fad5a2..ba367ceb5 100644 --- a/common/hugo/version_current.go +++ b/common/hugo/version_current.go @@ -17,7 +17,7 @@ package hugo // This should be the only one. var CurrentVersion = Version{ Major: 0, - Minor: 146, - PatchLevel: 1, - Suffix: "", + Minor: 148, + PatchLevel: 0, + Suffix: "-DEV", } diff --git a/common/paths/pathparser.go b/common/paths/pathparser.go index 4b3feaa14..b0a2f9fc4 100644 --- a/common/paths/pathparser.go +++ b/common/paths/pathparser.go @@ -124,14 +124,15 @@ func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot i if p.posContainerHigh != -1 { return } - mayHaveLang := pp.LanguageIndex != nil + mayHaveLang := p.posIdentifierLanguage == -1 && pp.LanguageIndex != nil mayHaveLang = mayHaveLang && (component == files.ComponentFolderContent || component == files.ComponentFolderLayouts) mayHaveOutputFormat := component == files.ComponentFolderLayouts - mayHaveKind := mayHaveOutputFormat + mayHaveKind := p.posIdentifierKind == -1 && mayHaveOutputFormat + mayHaveLayout := component == files.ComponentFolderLayouts var found bool var high int - if len(p.identifiers) > 0 { + if len(p.identifiersKnown) > 0 { high = lastDot } else { high = len(p.s) @@ -139,9 +140,9 @@ func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot i id := types.LowHigh[string]{Low: i + 1, High: high} sid := p.s[id.Low:id.High] - if len(p.identifiers) == 0 { + if len(p.identifiersKnown) == 0 { // The first is always the extension. - p.identifiers = append(p.identifiers, id) + p.identifiersKnown = append(p.identifiersKnown, id) found = true // May also be the output format. @@ -164,8 +165,8 @@ func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot i } found = langFound if langFound { - p.identifiers = append(p.identifiers, id) - p.posIdentifierLanguage = len(p.identifiers) - 1 + p.identifiersKnown = append(p.identifiersKnown, id) + p.posIdentifierLanguage = len(p.identifiersKnown) - 1 } } @@ -177,28 +178,33 @@ func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot i // false positives on the form css.html. if pp.IsOutputFormat(sid, p.Ext()) { found = true - p.identifiers = append(p.identifiers, id) - p.posIdentifierOutputFormat = len(p.identifiers) - 1 + p.identifiersKnown = append(p.identifiersKnown, id) + p.posIdentifierOutputFormat = len(p.identifiersKnown) - 1 } } if !found && mayHaveKind { if kinds.GetKindMain(sid) != "" { found = true - p.identifiers = append(p.identifiers, id) - p.posIdentifierKind = len(p.identifiers) - 1 + p.identifiersKnown = append(p.identifiersKnown, id) + p.posIdentifierKind = len(p.identifiersKnown) - 1 } } if !found && sid == identifierBaseof { found = true - p.identifiers = append(p.identifiers, id) - p.posIdentifierBaseof = len(p.identifiers) - 1 + p.identifiersKnown = append(p.identifiersKnown, id) + p.posIdentifierBaseof = len(p.identifiersKnown) - 1 + } + + if !found && mayHaveLayout { + p.identifiersKnown = append(p.identifiersKnown, id) + p.posIdentifierLayout = len(p.identifiersKnown) - 1 + found = true } if !found { - p.identifiers = append(p.identifiers, id) - p.identifiersUnknown = append(p.identifiersUnknown, len(p.identifiers)-1) + p.identifiersUnknown = append(p.identifiersUnknown, id) } } @@ -252,13 +258,13 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) { } } - if len(p.identifiers) > 0 { + if len(p.identifiersKnown) > 0 { isContentComponent := p.component == files.ComponentFolderContent || p.component == files.ComponentFolderArchetypes isContent := isContentComponent && pp.IsContentExt(p.Ext()) - id := p.identifiers[len(p.identifiers)-1] + id := p.identifiersKnown[len(p.identifiersKnown)-1] - if id.High > p.posContainerHigh { - b := p.s[p.posContainerHigh:id.High] + if id.Low > p.posContainerHigh { + b := p.s[p.posContainerHigh : id.Low-1] if isContent { switch b { case "index": @@ -294,6 +300,16 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) { } } + if p.pathType == TypeShortcode && p.posIdentifierLayout != -1 { + // myshortcode or myshortcode.html, no layout. + if len(p.identifiersKnown) <= 2 { + p.posIdentifierLayout = -1 + } else { + // First is always the name. + p.posIdentifierLayout-- + } + } + return p, nil } @@ -350,13 +366,14 @@ type Path struct { component string pathType Type - identifiers []types.LowHigh[string] + identifiersKnown []types.LowHigh[string] + identifiersUnknown []types.LowHigh[string] posIdentifierLanguage int posIdentifierOutputFormat int posIdentifierKind int + posIdentifierLayout int posIdentifierBaseof int - identifiersUnknown []int disabled bool trimLeadingSlash bool @@ -388,10 +405,11 @@ func (p *Path) reset() { p.posSectionHigh = -1 p.component = "" p.pathType = 0 - p.identifiers = p.identifiers[:0] + p.identifiersKnown = p.identifiersKnown[:0] p.posIdentifierLanguage = -1 p.posIdentifierOutputFormat = -1 p.posIdentifierKind = -1 + p.posIdentifierLayout = -1 p.posIdentifierBaseof = -1 p.disabled = false p.trimLeadingSlash = false @@ -479,7 +497,7 @@ func (p *Path) Name() string { // Name returns the last element of path without any extension. func (p *Path) NameNoExt() string { if i := p.identifierIndex(0); i != -1 { - return p.s[p.posContainerHigh : p.identifiers[i].Low-1] + return p.s[p.posContainerHigh : p.identifiersKnown[i].Low-1] } return p.s[p.posContainerHigh:] } @@ -491,7 +509,7 @@ func (p *Path) NameNoLang() string { return p.Name() } - return p.s[p.posContainerHigh:p.identifiers[i].Low-1] + p.s[p.identifiers[i].High:] + return p.s[p.posContainerHigh:p.identifiersKnown[i].Low-1] + p.s[p.identifiersKnown[i].High:] } // BaseNameNoIdentifier returns the logical base name for a resource without any identifier (e.g. no extension). @@ -510,15 +528,15 @@ func (p *Path) NameNoIdentifier() string { } func (p *Path) nameLowHigh() types.LowHigh[string] { - if len(p.identifiers) > 0 { - lastID := p.identifiers[len(p.identifiers)-1] + if len(p.identifiersKnown) > 0 { + lastID := p.identifiersKnown[len(p.identifiersKnown)-1] if p.posContainerHigh == lastID.Low { // The last identifier is the name. return lastID } return types.LowHigh[string]{ Low: p.posContainerHigh, - High: p.identifiers[len(p.identifiers)-1].Low - 1, + High: p.identifiersKnown[len(p.identifiersKnown)-1].Low - 1, } } return types.LowHigh[string]{ @@ -566,7 +584,7 @@ func (p *Path) PathNoIdentifier() string { // PathBeforeLangAndOutputFormatAndExt returns the path up to the first identifier that is not a language or output format. func (p *Path) PathBeforeLangAndOutputFormatAndExt() string { - if len(p.identifiers) == 0 { + if len(p.identifiersKnown) == 0 { return p.norm(p.s) } i := p.identifierIndex(0) @@ -582,7 +600,7 @@ func (p *Path) PathBeforeLangAndOutputFormatAndExt() string { return p.norm(p.s) } - id := p.identifiers[i] + id := p.identifiersKnown[i] return p.norm(p.s[:id.Low-1]) } @@ -633,11 +651,11 @@ func (p *Path) BaseNoLeadingSlash() string { } func (p *Path) base(preserveExt, isBundle bool) string { - if len(p.identifiers) == 0 { + if len(p.identifiersKnown) == 0 { return p.norm(p.s) } - if preserveExt && len(p.identifiers) == 1 { + if preserveExt && len(p.identifiersKnown) == 1 { // Preserve extension. return p.norm(p.s) } @@ -659,7 +677,7 @@ func (p *Path) base(preserveExt, isBundle bool) string { } // For txt files etc. we want to preserve the extension. - id := p.identifiers[0] + id := p.identifiersKnown[0] return p.norm(p.s[:high] + p.s[id.Low-1:id.High]) } @@ -676,6 +694,10 @@ func (p *Path) Kind() string { return p.identifierAsString(p.posIdentifierKind) } +func (p *Path) Layout() string { + return p.identifierAsString(p.posIdentifierLayout) +} + func (p *Path) Lang() string { return p.identifierAsString(p.posIdentifierLanguage) } @@ -689,8 +711,8 @@ func (p *Path) Disabled() bool { } func (p *Path) Identifiers() []string { - ids := make([]string, len(p.identifiers)) - for i, id := range p.identifiers { + ids := make([]string, len(p.identifiersKnown)) + for i, id := range p.identifiersKnown { ids[i] = p.s[id.Low:id.High] } return ids @@ -699,7 +721,7 @@ func (p *Path) Identifiers() []string { func (p *Path) IdentifiersUnknown() []string { ids := make([]string, len(p.identifiersUnknown)) for i, id := range p.identifiersUnknown { - ids[i] = p.s[p.identifiers[id].Low:p.identifiers[id].High] + ids[i] = p.s[id.Low:id.High] } return ids } @@ -724,7 +746,7 @@ func (p *Path) IsContentData() bool { return p.pathType == TypeContentData } -func (p Path) ForBundleType(t Type) *Path { +func (p Path) ForType(t Type) *Path { p.pathType = t return &p } @@ -735,12 +757,12 @@ func (p *Path) identifierAsString(i int) string { return "" } - id := p.identifiers[i] + id := p.identifiersKnown[i] return p.s[id.Low:id.High] } func (p *Path) identifierIndex(i int) int { - if i < 0 || i >= len(p.identifiers) { + if i < 0 || i >= len(p.identifiersKnown) { return -1 } return i diff --git a/common/paths/pathparser_test.go b/common/paths/pathparser_test.go index fd1590c73..a6194e756 100644 --- a/common/paths/pathparser_test.go +++ b/common/paths/pathparser_test.go @@ -171,22 +171,25 @@ func TestParse(t *testing.T) { "/a/b.a.b.no.txt", func(c *qt.C, p *Path) { c.Assert(p.Name(), qt.Equals, "b.a.b.no.txt") - c.Assert(p.NameNoIdentifier(), qt.Equals, "b") + c.Assert(p.NameNoIdentifier(), qt.Equals, "b.a.b") c.Assert(p.NameNoLang(), qt.Equals, "b.a.b.txt") - c.Assert(p.Identifiers(), qt.DeepEquals, []string{"txt", "no", "b", "a", "b"}) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"txt", "no"}) c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{"b", "a", "b"}) - c.Assert(p.Base(), qt.Equals, "/a/b.txt") - c.Assert(p.BaseNoLeadingSlash(), qt.Equals, "a/b.txt") + c.Assert(p.Base(), qt.Equals, "/a/b.a.b.txt") + c.Assert(p.BaseNoLeadingSlash(), qt.Equals, "a/b.a.b.txt") c.Assert(p.Path(), qt.Equals, "/a/b.a.b.no.txt") - c.Assert(p.PathNoLang(), qt.Equals, "/a/b.txt") + c.Assert(p.PathNoLang(), qt.Equals, "/a/b.a.b.txt") c.Assert(p.Ext(), qt.Equals, "txt") - c.Assert(p.PathNoIdentifier(), qt.Equals, "/a/b") + c.Assert(p.PathNoIdentifier(), qt.Equals, "/a/b.a.b") }, }, { "Home branch cundle", "/_index.md", func(c *qt.C, p *Path) { + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md"}) + c.Assert(p.IsBranchBundle(), qt.IsTrue) + c.Assert(p.IsBundle(), qt.IsTrue) c.Assert(p.Base(), qt.Equals, "/") c.Assert(p.BaseReTyped("foo"), qt.Equals, "/foo") c.Assert(p.Path(), qt.Equals, "/_index.md") @@ -206,7 +209,8 @@ func TestParse(t *testing.T) { c.Assert(p.ContainerDir(), qt.Equals, "") c.Assert(p.Dir(), qt.Equals, "/a") c.Assert(p.Ext(), qt.Equals, "md") - c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "index"}) + c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{"index"}) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md"}) c.Assert(p.IsBranchBundle(), qt.IsFalse) c.Assert(p.IsBundle(), qt.IsTrue) c.Assert(p.IsLeafBundle(), qt.IsTrue) @@ -228,7 +232,7 @@ func TestParse(t *testing.T) { c.Assert(p.ContainerDir(), qt.Equals, "/a") c.Assert(p.Dir(), qt.Equals, "/a/b") c.Assert(p.Ext(), qt.Equals, "md") - c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "no", "index"}) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "no"}) c.Assert(p.IsBranchBundle(), qt.IsFalse) c.Assert(p.IsBundle(), qt.IsTrue) c.Assert(p.IsLeafBundle(), qt.IsTrue) @@ -250,7 +254,7 @@ func TestParse(t *testing.T) { c.Assert(p.Container(), qt.Equals, "b") c.Assert(p.ContainerDir(), qt.Equals, "/a") c.Assert(p.Ext(), qt.Equals, "md") - c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "no", "_index"}) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "no"}) c.Assert(p.IsBranchBundle(), qt.IsTrue) c.Assert(p.IsBundle(), qt.IsTrue) c.Assert(p.IsLeafBundle(), qt.IsFalse) @@ -289,7 +293,7 @@ func TestParse(t *testing.T) { func(c *qt.C, p *Path) { c.Assert(p.Base(), qt.Equals, "/a/b/index.txt") c.Assert(p.Ext(), qt.Equals, "txt") - c.Assert(p.Identifiers(), qt.DeepEquals, []string{"txt", "no", "index"}) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"txt", "no"}) c.Assert(p.IsLeafBundle(), qt.IsFalse) c.Assert(p.PathNoIdentifier(), qt.Equals, "/a/b/index") }, @@ -372,7 +376,7 @@ func TestParse(t *testing.T) { } for _, test := range tests { c.Run(test.name, func(c *qt.C) { - if test.name != "Basic Markdown file" { + if test.name != "Home branch cundle" { // return } test.assert(c, testParser.Parse(files.ComponentFolderContent, test.path)) @@ -401,10 +405,58 @@ func TestParseLayouts(t *testing.T) { "/list.no.html", func(c *qt.C, p *Path) { c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "no", "list"}) + c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{}) c.Assert(p.Base(), qt.Equals, "/list.html") c.Assert(p.Lang(), qt.Equals, "no") }, }, + { + "Kind", + "/section.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Kind(), qt.Equals, kinds.KindSection) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "no", "section"}) + c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{}) + c.Assert(p.Base(), qt.Equals, "/section.html") + c.Assert(p.Lang(), qt.Equals, "no") + }, + }, + { + "Layout", + "/list.section.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Layout(), qt.Equals, "list") + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "no", "section", "list"}) + c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{}) + c.Assert(p.Base(), qt.Equals, "/list.html") + c.Assert(p.Lang(), qt.Equals, "no") + }, + }, + { + "Layout multiple", + "/maylayout.list.section.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Layout(), qt.Equals, "maylayout") + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "no", "section", "list", "maylayout"}) + c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{}) + c.Assert(p.Base(), qt.Equals, "/maylayout.html") + c.Assert(p.Lang(), qt.Equals, "no") + }, + }, + { + "Layout shortcode", + "/_shortcodes/myshort.list.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Layout(), qt.Equals, "list") + }, + }, + { + "Layout baseof", + "/baseof.list.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Layout(), qt.Equals, "list") + }, + }, { "Lang and output format", "/list.no.amp.not.html", @@ -429,6 +481,20 @@ func TestParseLayouts(t *testing.T) { c.Assert(p.OutputFormat(), qt.Equals, "html") }, }, + { + "Shortcode with layout", + "/_shortcodes/myshortcode.list.html", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/_shortcodes/myshortcode.html") + c.Assert(p.Type(), qt.Equals, TypeShortcode) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "list", "myshortcode"}) + c.Assert(p.PathNoIdentifier(), qt.Equals, "/_shortcodes/myshortcode") + c.Assert(p.PathBeforeLangAndOutputFormatAndExt(), qt.Equals, "/_shortcodes/myshortcode.list") + c.Assert(p.Lang(), qt.Equals, "") + c.Assert(p.Kind(), qt.Equals, "") + c.Assert(p.OutputFormat(), qt.Equals, "html") + }, + }, { "Sub dir", "/pages/home.html", @@ -445,7 +511,7 @@ func TestParseLayouts(t *testing.T) { "/pages/baseof.list.section.fr.amp.html", func(c *qt.C, p *Path) { c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "amp", "fr", "section", "list", "baseof"}) - c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{"list"}) + c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{}) c.Assert(p.Kind(), qt.Equals, kinds.KindSection) c.Assert(p.Lang(), qt.Equals, "fr") c.Assert(p.OutputFormat(), qt.Equals, "amp") @@ -501,6 +567,9 @@ func TestParseLayouts(t *testing.T) { for _, test := range tests { c.Run(test.name, func(c *qt.C) { + if test.name != "Baseof" { + // return + } test.assert(c, testParser.Parse(files.ComponentFolderLayouts, test.path)) }) } diff --git a/common/paths/paths_integration_test.go b/common/paths/paths_integration_test.go index 62d40f527..f5ea3066a 100644 --- a/common/paths/paths_integration_test.go +++ b/common/paths/paths_integration_test.go @@ -78,3 +78,26 @@ disablePathToLower = true b.AssertFileContent("public/en/mysection/mybundle/index.html", "en|Single") b.AssertFileContent("public/fr/MySection/MyBundle/index.html", "fr|Single") } + +func TestIssue13596(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','rss','section','sitemap','taxonomy','term'] +-- content/p1/index.md -- +--- +title: p1 +--- +-- content/p1/a.1.txt -- +-- content/p1/a.2.txt -- +-- layouts/all.html -- +{{ range .Resources.Match "*" }}{{ .Name }}|{{ end }} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", "a.1.txt|a.2.txt|") + b.AssertFileExists("public/p1/a.1.txt", true) + b.AssertFileExists("public/p1/a.2.txt", true) // fails +} diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go index e73153a94..d3ee28490 100644 --- a/config/allconfig/allconfig.go +++ b/config/allconfig/allconfig.go @@ -800,30 +800,58 @@ func (c *Configs) IsZero() bool { func (c *Configs) Init() error { var languages langs.Languages - defaultContentLanguage := c.Base.DefaultContentLanguage - for k, v := range c.LanguageConfigMap { + + var langKeys []string + var hasEn bool + + const en = "en" + + for k := range c.LanguageConfigMap { + langKeys = append(langKeys, k) + if k == en { + hasEn = true + } + } + + // Sort the LanguageConfigSlice by language weight (if set) or lang. + sort.Slice(langKeys, func(i, j int) bool { + ki := langKeys[i] + kj := langKeys[j] + lki := c.LanguageConfigMap[ki] + lkj := c.LanguageConfigMap[kj] + li := lki.Languages[ki] + lj := lkj.Languages[kj] + if li.Weight != lj.Weight { + return li.Weight < lj.Weight + } + return ki < kj + }) + + // See issue #13646. + defaultConfigLanguageFallback := en + if !hasEn { + // Pick the first one. + defaultConfigLanguageFallback = langKeys[0] + } + + if c.Base.DefaultContentLanguage == "" { + c.Base.DefaultContentLanguage = defaultConfigLanguageFallback + } + + for _, k := range langKeys { + v := c.LanguageConfigMap[k] + if v.DefaultContentLanguage == "" { + v.DefaultContentLanguage = defaultConfigLanguageFallback + } + c.LanguageConfigSlice = append(c.LanguageConfigSlice, v) languageConf := v.Languages[k] - language, err := langs.NewLanguage(k, defaultContentLanguage, v.TimeZone, languageConf) + language, err := langs.NewLanguage(k, c.Base.DefaultContentLanguage, v.TimeZone, languageConf) if err != nil { return err } languages = append(languages, language) } - // Sort the sites by language weight (if set) or lang. - sort.Slice(languages, func(i, j int) bool { - li := languages[i] - lj := languages[j] - if li.Weight != lj.Weight { - return li.Weight < lj.Weight - } - return li.Lang < lj.Lang - }) - - for _, l := range languages { - c.LanguageConfigSlice = append(c.LanguageConfigSlice, c.LanguageConfigMap[l.Lang]) - } - // Filter out disabled languages. var n int for _, l := range languages { @@ -836,12 +864,12 @@ func (c *Configs) Init() error { var languagesDefaultFirst langs.Languages for _, l := range languages { - if l.Lang == defaultContentLanguage { + if l.Lang == c.Base.DefaultContentLanguage { languagesDefaultFirst = append(languagesDefaultFirst, l) } } for _, l := range languages { - if l.Lang != defaultContentLanguage { + if l.Lang != c.Base.DefaultContentLanguage { languagesDefaultFirst = append(languagesDefaultFirst, l) } } @@ -927,17 +955,48 @@ func (c Configs) GetByLang(lang string) config.AllProvider { return nil } +func newDefaultConfig() *Config { + return &Config{ + Taxonomies: map[string]string{"tag": "tags", "category": "categories"}, + Sitemap: config.SitemapConfig{Priority: -1, Filename: "sitemap.xml"}, + RootConfig: RootConfig{ + Environment: hugo.EnvironmentProduction, + TitleCaseStyle: "AP", + PluralizeListTitles: true, + CapitalizeListTitles: true, + StaticDir: []string{"static"}, + SummaryLength: 70, + Timeout: "60s", + + CommonDirs: config.CommonDirs{ + ArcheTypeDir: "archetypes", + ContentDir: "content", + ResourceDir: "resources", + PublishDir: "public", + ThemesDir: "themes", + AssetDir: "assets", + LayoutDir: "layouts", + I18nDir: "i18n", + DataDir: "data", + }, + }, + } +} + // fromLoadConfigResult creates a new Config from res. func fromLoadConfigResult(fs afero.Fs, logger loggers.Logger, res config.LoadConfigResult) (*Configs, error) { if !res.Cfg.IsSet("languages") { // We need at least one lang := res.Cfg.GetString("defaultContentLanguage") + if lang == "" { + lang = "en" + } res.Cfg.Set("languages", maps.Params{lang: maps.Params{}}) } bcfg := res.BaseConfig cfg := res.Cfg - all := &Config{} + all := newDefaultConfig() err := decodeConfigFromParams(fs, logger, bcfg, cfg, all, nil) if err != nil { @@ -947,6 +1006,7 @@ func fromLoadConfigResult(fs afero.Fs, logger loggers.Logger, res config.LoadCon langConfigMap := make(map[string]*Config) languagesConfig := cfg.GetStringMap("languages") + var isMultihost bool if err := all.CompileConfig(logger); err != nil { diff --git a/config/allconfig/allconfig_integration_test.go b/config/allconfig/allconfig_integration_test.go index cae04ba85..033947203 100644 --- a/config/allconfig/allconfig_integration_test.go +++ b/config/allconfig/allconfig_integration_test.go @@ -5,6 +5,7 @@ import ( "testing" qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/hugo" "github.com/gohugoio/hugo/config/allconfig" "github.com/gohugoio/hugo/hugolib" "github.com/gohugoio/hugo/media" @@ -234,3 +235,62 @@ baseURL = "https://example.com" b.Assert(c.IsContentFile("foo.md"), qt.Equals, true) b.Assert(len(s), qt.Equals, 6) } + +func TestMergeDeep(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +theme = ["theme1", "theme2"] +_merge = "deep" +-- themes/theme1/hugo.toml -- +[sitemap] +filename = 'mysitemap.xml' +[services] +[services.googleAnalytics] +id = 'foo bar' +[taxonomies] + foo = 'bars' +-- themes/theme2/config/_default/hugo.toml -- +[taxonomies] + bar = 'baz' +-- layouts/home.html -- +GA ID: {{ site.Config.Services.GoogleAnalytics.ID }}. + +` + + b := hugolib.Test(t, files) + + conf := b.H.Configs + base := conf.Base + + b.Assert(base.Environment, qt.Equals, hugo.EnvironmentProduction) + b.Assert(base.BaseURL, qt.Equals, "https://example.com") + b.Assert(base.Sitemap.Filename, qt.Equals, "mysitemap.xml") + b.Assert(base.Taxonomies, qt.DeepEquals, map[string]string{"bar": "baz", "foo": "bars"}) + + b.AssertFileContent("public/index.html", "GA ID: foo bar.") +} + +func TestDefaultConfigLanguageBlankWhenNoEnglishExists(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +[languages] +[languages.nn] +weight = 20 +[languages.sv] +weight = 10 +[languages.sv.taxonomies] + tag = "taggar" +-- layouts/all.html -- +All. +` + + b := hugolib.Test(t, files) + + b.Assert(b.H.Conf.DefaultContentLanguage(), qt.Equals, "sv") +} diff --git a/config/allconfig/alldecoders.go b/config/allconfig/alldecoders.go index 0bf8508d9..1cfa1afc4 100644 --- a/config/allconfig/alldecoders.go +++ b/config/allconfig/alldecoders.go @@ -249,14 +249,18 @@ var allDecoderSetups = map[string]decodeWeight{ key: "sitemap", decode: func(d decodeWeight, p decodeConfig) error { var err error - p.c.Sitemap, err = config.DecodeSitemap(config.SitemapConfig{Priority: -1, Filename: "sitemap.xml"}, p.p.GetStringMap(d.key)) + if p.p.IsSet(d.key) { + p.c.Sitemap, err = config.DecodeSitemap(p.c.Sitemap, p.p.GetStringMap(d.key)) + } return err }, }, "taxonomies": { key: "taxonomies", decode: func(d decodeWeight, p decodeConfig) error { - p.c.Taxonomies = maps.CleanConfigStringMapString(p.p.GetStringMapString(d.key)) + if p.p.IsSet(d.key) { + p.c.Taxonomies = maps.CleanConfigStringMapString(p.p.GetStringMapString(d.key)) + } return nil }, }, @@ -306,15 +310,17 @@ var allDecoderSetups = map[string]decodeWeight{ } // Validate defaultContentLanguage. - var found bool - for lang := range p.c.Languages { - if lang == p.c.DefaultContentLanguage { - found = true - break + if p.c.DefaultContentLanguage != "" { + var found bool + for lang := range p.c.Languages { + if lang == p.c.DefaultContentLanguage { + found = true + break + } + } + if !found { + return fmt.Errorf("config value %q for defaultContentLanguage does not match any language definition", p.c.DefaultContentLanguage) } - } - if !found { - return fmt.Errorf("config value %q for defaultContentLanguage does not match any language definition", p.c.DefaultContentLanguage) } return nil diff --git a/config/allconfig/load.go b/config/allconfig/load.go index f224009ac..2d9185f6f 100644 --- a/config/allconfig/load.go +++ b/config/allconfig/load.go @@ -159,63 +159,9 @@ func (l configLoader) applyConfigAliases() error { func (l configLoader) applyDefaultConfig() error { defaultSettings := maps.Params{ - "baseURL": "", - "cleanDestinationDir": false, - "watch": false, - "contentDir": "content", - "resourceDir": "resources", - "publishDir": "public", - "publishDirOrig": "public", - "themesDir": "themes", - "assetDir": "assets", - "layoutDir": "layouts", - "i18nDir": "i18n", - "dataDir": "data", - "archetypeDir": "archetypes", - "configDir": "config", - "staticDir": "static", - "buildDrafts": false, - "buildFuture": false, - "buildExpired": false, - "params": maps.Params{}, - "environment": hugo.EnvironmentProduction, - "uglyURLs": false, - "verbose": false, - "ignoreCache": false, - "canonifyURLs": false, - "relativeURLs": false, - "removePathAccents": false, - "titleCaseStyle": "AP", - "taxonomies": maps.Params{"tag": "tags", "category": "categories"}, - "permalinks": maps.Params{}, - "sitemap": maps.Params{"priority": -1, "filename": "sitemap.xml"}, - "menus": maps.Params{}, - "disableLiveReload": false, - "pluralizeListTitles": true, - "capitalizeListTitles": true, - "forceSyncStatic": false, - "footnoteAnchorPrefix": "", - "footnoteReturnLinkContents": "", - "newContentEditor": "", - "paginate": 0, // Moved into the paginator struct in Hugo v0.128.0. - "paginatePath": "", // Moved into the paginator struct in Hugo v0.128.0. - "summaryLength": 70, - "rssLimit": -1, - "sectionPagesMenu": "", - "disablePathToLower": false, - "hasCJKLanguage": false, - "enableEmoji": false, - "defaultContentLanguage": "en", - "defaultContentLanguageInSubdir": false, - "enableMissingTranslationPlaceholders": false, - "enableGitInfo": false, - "ignoreFiles": make([]string, 0), - "disableAliases": false, - "debug": false, - "disableFastRender": false, - "timeout": "30s", - "timeZone": "", - "enableInlineShortcodes": false, + // These dirs are used early/before we build the config struct. + "themesDir": "themes", + "configDir": "config", } l.cfg.SetDefaults(defaultSettings) diff --git a/config/commonConfig.go b/config/commonConfig.go index 3dfd9b409..947078672 100644 --- a/config/commonConfig.go +++ b/config/commonConfig.go @@ -17,6 +17,7 @@ import ( "fmt" "net/http" "regexp" + "slices" "sort" "strings" @@ -28,7 +29,6 @@ import ( "github.com/gohugoio/hugo/common/herrors" "github.com/mitchellh/mapstructure" "github.com/spf13/cast" - "slices" ) type BaseConfig struct { diff --git a/config/configProvider.go b/config/configProvider.go index 5bda2c55a..c21342dce 100644 --- a/config/configProvider.go +++ b/config/configProvider.go @@ -76,7 +76,7 @@ type AllProvider interface { } // We cannot import the media package as that would create a circular dependency. -// This interface defineds a sub set of what media.ContentTypes provides. +// This interface defines a subset of what media.ContentTypes provides. type ContentTypesProvider interface { IsContentSuffix(suffix string) bool IsContentFile(filename string) bool diff --git a/config/services/servicesConfig.go b/config/services/servicesConfig.go index f302244d4..f9d5e1a6e 100644 --- a/config/services/servicesConfig.go +++ b/config/services/servicesConfig.go @@ -101,6 +101,9 @@ func DecodeConfig(cfg config.Provider) (c Config, err error) { if c.RSS.Limit == 0 { c.RSS.Limit = cfg.GetInt(rssLimitKey) + if c.RSS.Limit == 0 { + c.RSS.Limit = -1 + } } return diff --git a/create/skeletons/theme/layouts/single.html b/create/skeletons/theme/layouts/page.html similarity index 100% rename from create/skeletons/theme/layouts/single.html rename to create/skeletons/theme/layouts/page.html diff --git a/create/skeletons/theme/layouts/list.html b/create/skeletons/theme/layouts/section.html similarity index 100% rename from create/skeletons/theme/layouts/list.html rename to create/skeletons/theme/layouts/section.html diff --git a/docs/content/en/configuration/introduction.md b/docs/content/en/configuration/introduction.md index 121a483c4..8f8ad4c1e 100644 --- a/docs/content/en/configuration/introduction.md +++ b/docs/content/en/configuration/introduction.md @@ -249,7 +249,7 @@ HUGO_FILE_LOG_FORMAT HUGO_MEMORYLIMIT : {{< new-in 0.123.0 />}} -: (`int`) The maximum amount of system memory, in gigabytes, that Hugo can use while rendering your site. Default is 25% of total system memory. Note that The `HUGO_MEMORYLIMIT` is a “best effort” setting. Don't expect Hugo to build a million pages with only 1 GB memory. You can get more information about how this behaves during the build by building with `hugo --logLevel info` and look for the `dynacache` label. +: (`int`) The maximum amount of system memory, in gigabytes, that Hugo can use while rendering your site. Default is 25% of total system memory. Note that `HUGO_MEMORYLIMIT` is a "best effort" setting. Don't expect Hugo to build a million pages with only 1 GB of memory. You can get more information about how this behaves during the build by building with `hugo --logLevel info` and look for the `dynacache` label. HUGO_NUMWORKERMULTIPLIER : (`int`) The number of workers used in parallel processing. Default is the number of logical CPUs. diff --git a/docs/content/en/content-management/menus.md b/docs/content/en/content-management/menus.md index ab1bcbfa1..6d01173dc 100644 --- a/docs/content/en/content-management/menus.md +++ b/docs/content/en/content-management/menus.md @@ -11,7 +11,7 @@ aliases: [/extras/menus/] To create a menu for your site: 1. Define the menu entries -1. [Localize] each entry +1. [Localize](multilingual/#menus) each entry 1. Render the menu with a [template] Create multiple menus, either flat or nested. For example, create a main menu for the header, and a separate menu for the footer. diff --git a/docs/content/en/contribute/documentation.md b/docs/content/en/contribute/documentation.md index 1d185d21d..68129912a 100644 --- a/docs/content/en/contribute/documentation.md +++ b/docs/content/en/contribute/documentation.md @@ -112,7 +112,7 @@ Yes → Hugo is fast. ### Function and method descriptions -Start descriptions in the functions and methods sections with "Returns", of for booelan values, "Reports whether". +Start descriptions in the functions and methods sections with "Returns", or for boolean values, "Reports whether". ### File paths and names diff --git a/docs/content/en/functions/collections/Where.md b/docs/content/en/functions/collections/Where.md index 1df84afc4..84fd1d21e 100644 --- a/docs/content/en/functions/collections/Where.md +++ b/docs/content/en/functions/collections/Where.md @@ -87,7 +87,6 @@ Use any of the following logical operators: : (`bool`) Reports whether the given field value (a slice) contains one or more elements in common with `VALUE`. See [details](/functions/collections/intersect). `like` -: {{< new-in 0.116.0 />}} : (`bool`) Reports whether the given field value matches the [regular expression](g) specified in `VALUE`. Use the `like` operator to compare `string` values. The `like` operator returns `false` when comparing other data types to the regular expression. > [!note] @@ -167,8 +166,6 @@ For example, to return a collection of pages where any of the terms in the "genr ## Regular expression comparison -{{< new-in 0.116.0 />}} - To return a collection of pages where the "author" page parameter begins with either "victor" or "Victor": ```go-html-template diff --git a/docs/content/en/functions/css/Sass.md b/docs/content/en/functions/css/Sass.md index 1d5487130..03a4c7451 100644 --- a/docs/content/en/functions/css/Sass.md +++ b/docs/content/en/functions/css/Sass.md @@ -12,13 +12,66 @@ params: {{< new-in 0.128.0 />}} -```go-html-template +Transpile Sass to CSS using the LibSass transpiler included in Hugo's extended and extended/deploy editions, or [install Dart Sass](#dart-sass) to use the latest features of the Sass language. + +Sass has two forms of syntax: [SCSS] and [indented]. Hugo supports both. + +[scss]: https://sass-lang.com/documentation/syntax#scss +[indented]: https://sass-lang.com/documentation/syntax#the-indented-syntax + +## Options + +enableSourceMap +: (`bool`) Whether to generate a source map. Default is `false`. + +includePaths +: (`slice`) A slice of paths, relative to the project root, that the transpiler will use when resolving `@use` and `@import` statements. + +outputStyle +: (`string`) The output style of the resulting CSS. With LibSass, one of `nested` (default), `expanded`, `compact`, or `compressed`. With Dart Sass, either `expanded` (default) or `compressed`. + +precision +: (`int`) The precision of floating point math. Applicable to LibSass. Default is `8`. + +silenceDeprecations +: {{< new-in 0.139.0 />}} +: (`slice`) A slice of deprecation IDs to silence. IDs are enclosed in brackets within Dart Sass warning messages (e.g., `import` in `WARN Dart Sass: DEPRECATED [import]`). Applicable to Dart Sass. Default is `false`. + +silenceDependencyDeprecations +: {{< new-in 0.146.0 />}} +: (`bool`) Whether to silence deprecation warnings from dependencies, where a dependency is considered any file transitively imported through a load path. This does not apply to `@warn` or `@debug` rules.Default is `false`. + +sourceMapIncludeSources +: (`bool`) Whether to embed sources in the generated source map. Applicable to Dart Sass. Default is `false`. + +targetPath +: (`string`) The publish path for the transformed resource, relative to the[`publishDir`]. If unset, the target path defaults to the asset's original path with a `.css` extension. + +transpiler +: (`string`) The transpiler to use, either `libsass` or `dartsass`. Hugo's extended and extended/deploy editions include the LibSass transpiler. To use the Dart Sass transpiler, see the [installation instructions](#dart-sass). Default is `libsass`. + +vars +: (`map`) A map of key-value pairs that will be available in the `hugo:vars` namespace. Useful for [initializing Sass variables from Hugo templates](https://discourse.gohugo.io/t/42053/). + + ```scss + // LibSass + @import "hugo:vars"; + + // Dart Sass + @use "hugo:vars" as v; + ``` + +## Example + +```go-html-template {copy=true} {{ with resources.Get "sass/main.scss" }} {{ $opts := dict "enableSourceMap" (not hugo.IsProduction) "outputStyle" (cond hugo.IsProduction "compressed" "expanded") "targetPath" "css/main.css" - "transpiler" "libsass" + "transpiler" "dartsass" + "vars" site.Params.styles + "includePaths" (slice "node_modules/bootstrap/scss") }} {{ with . | toCSS $opts }} {{ if hugo.IsProduction }} @@ -32,63 +85,6 @@ params: {{ end }} ``` -Transpile Sass to CSS using the LibSass transpiler included in Hugo's extended and extended/deploy editions, or [install Dart Sass](#dart-sass) to use the latest features of the Sass language. - -Sass has two forms of syntax: [SCSS] and [indented]. Hugo supports both. - -[scss]: https://sass-lang.com/documentation/syntax#scss -[indented]: https://sass-lang.com/documentation/syntax#the-indented-syntax - -## Options - -transpiler -: (`string`) The transpiler to use, either `libsass` (default) or `dartsass`. Hugo's extended and extended/deploy editions include the LibSass transpiler. To use the Dart Sass transpiler, see the [installation instructions](#dart-sass) below. - -targetPath -: (`string`) If not set, the transformed resource's target path will be the original path of the asset file with its extension replaced by `.css`. - -vars -: (`map`) A map of key-value pairs that will be available in the `hugo:vars` namespace. Useful for [initializing Sass variables from Hugo templates](https://discourse.gohugo.io/t/42053/). - -```scss -// LibSass -@import "hugo:vars"; - -// Dart Sass -@use "hugo:vars" as v; -``` - -outputStyle -: (`string`) Output styles available to LibSass include `nested` (default), `expanded`, `compact`, and `compressed`. Output styles available to Dart Sass include `expanded` (default) and `compressed`. - -precision -: (`int`) Precision of floating point math. Not applicable to Dart Sass. - -enableSourceMap -: (`bool`) Whether to generate a source map. Default is `false`. - -sourceMapIncludeSources -: (`bool`) Whether to embed sources in the generated source map. Not applicable to LibSass. Default is `false`. - -includePaths -: (`slice`) A slice of paths, relative to the project root, that the transpiler will use when resolving `@use` and `@import` statements. - -```go-html-template -{{ $opts := dict - "transpiler" "dartsass" - "targetPath" "css/style.css" - "vars" site.Params.styles - "enableSourceMap" (not hugo.IsProduction) - "includePaths" (slice "node_modules/bootstrap/scss") -}} -{{ with resources.Get "sass/main.scss" | toCSS $opts | minify | fingerprint }} - -{{ end }} -``` - -silenceDeprecations -: (`slice`) {{< new-in 0.139.0 />}} A slice of deprecation IDs to silence. The deprecation IDs are printed to in the warning message, e.g "import" in `WARN Dart Sass: DEPRECATED [import] ...`. This is for Dart Sass only. - ## Dart Sass Hugo's extended and extended/deploy editions include [LibSass] to transpile Sass to CSS. In 2020, the Sass team deprecated LibSass in favor of [Dart Sass]. @@ -121,6 +117,9 @@ You may also install [prebuilt binaries] for Linux, macOS, and Windows. Run `hugo env` to list the active transpilers. +> [!note] +> If you build Hugo from source and run `mage test -v`, the test will fail if you install Dart Sass as a Snap package. This is due to the Snap package's strict confinement model. + ### Installing in a production environment For [CI/CD](g) deployments (e.g., GitHub Pages, GitLab Pages, Netlify, etc.) you must edit the workflow to install Dart Sass before Hugo builds the site[^2]. Some providers allow you to use one of the package managers above, or you can download and extract one of the prebuilt binaries. @@ -136,8 +135,6 @@ To install Dart Sass for your builds on GitHub Pages, add this step to the GitHu run: sudo snap install dart-sass ``` -If you are using GitHub Pages for the first time with your repository, GitHub provides a [starter workflow] for Hugo that includes Dart Sass. This is the simplest way to get started. - #### GitLab Pages To install Dart Sass for your builds on GitLab Pages, the `.gitlab-ci.yml` file should look something like this: @@ -194,34 +191,6 @@ command = """\ """ ``` -### Example - -To transpile with Dart Sass, set `transpiler` to `dartsass` in the options map passed to `css.Sass`. For example: - -```go-html-template -{{ with resources.Get "sass/main.scss" }} - {{ $opts := dict - "enableSourceMap" (not hugo.IsProduction) - "outputStyle" (cond hugo.IsProduction "compressed" "expanded") - "targetPath" "css/main.css" - "transpiler" "dartsass" - }} - {{ with . | toCSS $opts }} - {{ if hugo.IsProduction }} - {{ with . | fingerprint }} - - {{ end }} - {{ else }} - - {{ end }} - {{ end }} -{{ end }} -``` - -### Miscellaneous - -If you build Hugo from source and run `mage test -v`, the test will fail if you install Dart Sass as a Snap package. This is due to the Snap package's strict confinement model. - [brew.sh]: https://brew.sh/ [chocolatey.org]: https://community.chocolatey.org/packages/sass [dart sass]: https://sass-lang.com/dart-sass @@ -232,3 +201,4 @@ If you build Hugo from source and run `mage test -v`, the test will fail if you [snap package]: /installation/linux/#snap [snapcraft.io]: https://snapcraft.io/dart-sass [starter workflow]: https://github.com/actions/starter-workflows/blob/main/pages/hugo.yml +[`publishDir`]: /configuration/all/#publishdir diff --git a/docs/content/en/functions/go-template/template.md b/docs/content/en/functions/go-template/template.md index dac1fa3be..053cfcc22 100644 --- a/docs/content/en/functions/go-template/template.md +++ b/docs/content/en/functions/go-template/template.md @@ -10,7 +10,18 @@ params: signatures: ['template NAME [CONTEXT]'] --- -Use the `template` function to execute [embedded templates]. For example: +Use the `template` function to execute any of these [embedded templates](g): + +- [`disqus.html`] +- [`google_analytics.html`] +- [`opengraph.html`] +- [`pagination.html`] +- [`schema.html`] +- [`twitter_cards.html`] + + + +For example: ```go-html-template {{ range (.Paginate .Pages).Pages }} @@ -39,8 +50,21 @@ The example above can be rewritten using an [inline partial] template: {{ end }} ``` +The key distinctions between the preceding two examples are: + +1. Inline partials are globally scoped. That means that an inline partial defined in _one_ template may be called from _any_ template. +2. Leveraging the [`partialCached`] function when calling an inline partial allows for performance optimization through result caching. +3. An inline partial can [`return`] a value of any data type instead of rendering a string. + {{% include "/_common/functions/go-template/text-template.md" %}} +[`disqus.html`]: /templates/embedded/#disqus +[`google_analytics.html`]: /templates/embedded/#google-analytics +[`opengraph.html`]: /templates/embedded/#open-graph +[`pagination.html`]: /templates/embedded/#pagination +[`partialCached`]: /functions/partials/includecached/ [`partial`]: /functions/partials/include/ +[`return`]: /functions/go-template/return/ +[`schema.html`]: /templates/embedded/#schema +[`twitter_cards.html`]: /templates/embedded/#x-twitter-cards [inline partial]: /templates/partial/#inline-partials -[embedded templates]: /templates/embedded/ diff --git a/docs/content/en/functions/images/Text.md b/docs/content/en/functions/images/Text.md index 94cdb4e9d..8f7e730ba 100644 --- a/docs/content/en/functions/images/Text.md +++ b/docs/content/en/functions/images/Text.md @@ -18,6 +18,9 @@ alignx : {{< new-in 0.141.0 />}} : (`string`) The horizontal alignment of the text relative to the horizontal offset, one of `left`, `center`, or `right`. Default is `left`. +aligny +: (`string`) The vertical alignment of the text relative to the vertical offset, one of `top`, `center`, or `bottom`. Default is `top`. + color : (`string`) The font color, either a 3-digit or 6-digit hexadecimal color code. Default is `#ffffff` (white). diff --git a/docs/content/en/functions/templates/Current.md b/docs/content/en/functions/templates/Current.md new file mode 100644 index 000000000..805aeec05 --- /dev/null +++ b/docs/content/en/functions/templates/Current.md @@ -0,0 +1,155 @@ +--- +title: templates.Current +description: Returns information about the currently executing template. +categories: [] +keywords: [] +params: + functions_and_methods: + aliases: [] + returnType: tpl.CurrentTemplateInfo + signatures: [templates.Current] +--- + +> [!note] +> This function is experimental and subject to change. + +{{< new-in 0.146.0 />}} + +The `templates.Current` function provides introspection capabilities, allowing you to access details about the currently executing templates. This is useful for debugging complex template hierarchies and understanding the flow of execution during rendering. + +## Methods + +Ancestors +: (`tpl.CurrentTemplateInfos`) Returns a slice containing information about each template in the current execution chain, starting from the parent of the current template and going up towards the initial template called. It excludes any base template applied via `define` and `block`. You can chain the `Reverse` method to this result to get the slice in chronological execution order. + +Base +: (`tpl.CurrentTemplateInfoCommonOps`) Returns an object representing the base template that was applied to the current template, if any. This may be `nil`. + +Filename +: (`string`) Returns the absolute path of the current template. This will be empty for embedded templates. + +Name +: (`string`) Returns the name of the current template. This is usually the path relative to the layouts directory. + +Parent +: (`tpl.CurrentTemplateInfo`) Returns an object representing the parent of the current template, if any. This may be `nil`. + +## Examples + +The examples below help visualize template execution and require a `debug` parameter set to `true` in your site configuration: + +{{< code-toggle file=hugo >}} +[params] +debug = true +{{< /code-toggle >}} + +### Boundaries + +To visually mark where a template begins and ends execution: + +```go-html-template {file="layouts/_default/single.html"} +{{ define "main" }} + {{ if site.Params.debug }} +
{{ . }} | + {{ end }} +
---|
{{ . }} | + {{ end }} +
name | +type | +breed | +age | +
---|---|---|---|
{{ .name }} | +{{ .type }} | +{{ .breed }} | +{{ .age }} | +
c
-- layouts/_default/single.html -- +Path: {{ .Path }}|{{.Kind }} |{{ (.Resources.Get "a.html").RelPermalink -}} |{{ (.Resources.Get "b.html").RelPermalink -}} |{{ (.Resources.Get "c.html").Publish }} diff --git a/hugolib/content_render_hooks_test.go b/hugolib/content_render_hooks_test.go index 56ae0a052..c4e15a5c6 100644 --- a/hugolib/content_render_hooks_test.go +++ b/hugolib/content_render_hooks_test.go @@ -464,8 +464,6 @@ title: "Home" ` b := Test(t, files) - // b.DebugPrint("", tplimpl.CategoryShortcode) - b.AssertFileContentExact("public/index.xml", "My shortcode XML.") b.AssertFileContentExact("public/index.html", "My shortcode HTML.") s := b.H.Sites[0] diff --git a/hugolib/filesystems/basefs.go b/hugolib/filesystems/basefs.go index 3e9b92087..b32b8796f 100644 --- a/hugolib/filesystems/basefs.go +++ b/hugolib/filesystems/basefs.go @@ -397,7 +397,7 @@ func (d *SourceFilesystem) mounts() []hugofs.FileMetaInfo { }) // Filter out any mounts not belonging to this filesystem. - // TODO(bep) I think this is superflous. + // TODO(bep) I think this is superfluous. n := 0 for _, mm := range m { if mm.Meta().Component == d.Name { diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go index 4ea6f420d..3c2f1ad74 100644 --- a/hugolib/integrationtest_builder.go +++ b/hugolib/integrationtest_builder.go @@ -918,7 +918,7 @@ type IntegrationTestConfig struct { // The files to use on txtar format, see // https://pkg.go.dev/golang.org/x/exp/cmd/txtar - // There are some conentions used in this test setup. + // There are some contentions used in this test setup. // - §§§ can be used to wrap code fences. // - §§ can be used to wrap multiline strings. // - filenames prefixed with sourcefilename: will be read from the file system relative to the current dir. diff --git a/hugolib/page.go b/hugolib/page.go index b36b9712c..84d4d1ea8 100644 --- a/hugolib/page.go +++ b/hugolib/page.go @@ -482,12 +482,12 @@ func (po *pageOutput) GetInternalTemplateBasePathAndDescriptor() (string, tplimp f := po.f base := p.PathInfo().BaseReTyped(p.m.pageConfig.Type) return base, tplimpl.TemplateDescriptor{ - Kind: p.Kind(), - Lang: p.Language().Lang, - Layout: p.Layout(), - OutputFormat: f.Name, - MediaType: f.MediaType.Type, - IsPlainText: f.IsPlainText, + Kind: p.Kind(), + Lang: p.Language().Lang, + LayoutFromUser: p.Layout(), + OutputFormat: f.Name, + MediaType: f.MediaType.Type, + IsPlainText: f.IsPlainText, } } @@ -495,8 +495,8 @@ func (p *pageState) resolveTemplate(layouts ...string) (*tplimpl.TemplInfo, bool dir, d := p.GetInternalTemplateBasePathAndDescriptor() if len(layouts) > 0 { - d.Layout = layouts[0] - d.LayoutMustMatch = true + d.LayoutFromUser = layouts[0] + d.LayoutFromUserMustMatch = true } q := tplimpl.TemplateQuery{ diff --git a/hugolib/page__content.go b/hugolib/page__content.go index 5f7d6f930..20abb7884 100644 --- a/hugolib/page__content.go +++ b/hugolib/page__content.go @@ -850,7 +850,7 @@ func (c *cachedContentScope) contentPlain(ctx context.Context) (contentPlainPlai }) if err != nil { if herrors.IsTimeoutError(err) { - err = fmt.Errorf("timed out rendering the page content. You may have a circular loop in a shortcode, or your site may have resources that take longer to build than the `timeout` limit in your Hugo config file: %w", err) + err = fmt.Errorf("timed out rendering the page content. Extend the `timeout` limit in your Hugo config file: %w", err) } return contentPlainPlainWords{}, err } diff --git a/hugolib/shortcode.go b/hugolib/shortcode.go index 3ac0940e2..cc8a145d9 100644 --- a/hugolib/shortcode.go +++ b/hugolib/shortcode.go @@ -677,13 +677,7 @@ Loop: // Used to check if the template expects inner content, // so just pick one arbitrarily with the same name. - q := tplimpl.TemplateQuery{ - Path: "", - Name: sc.name, - Category: tplimpl.CategoryShortcode, - Consider: nil, - } - templ := s.s.TemplateStore.LookupShortcode(q) + templ := s.s.TemplateStore.LookupShortcodeByName(sc.name) if templ == nil { return nil, fmt.Errorf("%s: template for shortcode %q not found", errorPrefix, sc.name) } diff --git a/hugolib/site_test.go b/hugolib/site_test.go index a9fe977cf..4d68602e5 100644 --- a/hugolib/site_test.go +++ b/hugolib/site_test.go @@ -978,7 +978,7 @@ func TestRefLinking(t *testing.T) { {".", "", true, "/level2/level3/"}, {"./", "", true, "/level2/level3/"}, - {"embedded.dot.md", "", true, "/level2/level3/embedded/"}, + {"embedded.dot.md", "", true, "/level2/level3/embedded.dot/"}, // test empty link, as well as fragment only link {"", "", true, ""}, diff --git a/hugolib/sitemap_test.go b/hugolib/sitemap_test.go index 1c2642468..922ecbc12 100644 --- a/hugolib/sitemap_test.go +++ b/hugolib/sitemap_test.go @@ -139,7 +139,7 @@ weight = 1 languageName = "English" [languages.nn] weight = 2 --- layouts/_default/list.xml -- +-- layouts/list.xml -- Site: {{ .Site.Title }}| -- layouts/home -- Home. diff --git a/hugoreleaser.env b/hugoreleaser.env index c88ba3e46..9d9a10112 100644 --- a/hugoreleaser.env +++ b/hugoreleaser.env @@ -1,7 +1,15 @@ # Release env. # These will be replaced by script before release. -HUGORELEASER_TAG=v0.146.0 -HUGORELEASER_COMMITISH=5d1b9d39858bb0b2e505af9f649bfb55295ecca1 +HUGORELEASER_TAG=v0.147.0 +HUGORELEASER_COMMITISH=7d0039b86ddd6397816cc3383cb0cfa481b15f32 + + + + + + + + diff --git a/parser/metadecoders/decoder.go b/parser/metadecoders/decoder.go index 1655ea513..419fbf4d2 100644 --- a/parser/metadecoders/decoder.go +++ b/parser/metadecoders/decoder.go @@ -36,16 +36,22 @@ import ( // Decoder provides some configuration options for the decoders. type Decoder struct { - // Delimiter is the field delimiter used in the CSV decoder. It defaults to ','. + // Delimiter is the field delimiter. Used in the CSV decoder. Default is + // ','. Delimiter rune - // Comment, if not 0, is the comment character used in the CSV decoder. Lines beginning with the - // Comment character without preceding whitespace are ignored. + // Comment, if not 0, is the comment character. Lines beginning with the + // Comment character without preceding whitespace are ignored. Used in the + // CSV decoder. Comment rune // If true, a quote may appear in an unquoted field and a non-doubled quote - // may appear in a quoted field. It defaults to false. + // may appear in a quoted field. Used in the CSV decoder. Default is false. LazyQuotes bool + + // The target data type, either slice or map. Used in the CSV decoder. + // Default is slice. + TargetType string } // OptionsKey is used in cache keys. @@ -54,12 +60,14 @@ func (d Decoder) OptionsKey() string { sb.WriteRune(d.Delimiter) sb.WriteRune(d.Comment) sb.WriteString(strconv.FormatBool(d.LazyQuotes)) + sb.WriteString(d.TargetType) return sb.String() } // Default is a Decoder in its default configuration. var Default = Decoder{ - Delimiter: ',', + Delimiter: ',', + TargetType: "slice", } // UnmarshalToMap will unmarshall data in format f into a new map. This is @@ -122,7 +130,14 @@ func (d Decoder) Unmarshal(data []byte, f Format) (any, error) { if len(data) == 0 { switch f { case CSV: - return make([][]string, 0), nil + switch d.TargetType { + case "map": + return make(map[string]any), nil + case "slice": + return make([][]string, 0), nil + default: + return nil, fmt.Errorf("invalid targetType: expected either slice or map, received %s", d.TargetType) + } default: return make(map[string]any), nil } @@ -232,10 +247,36 @@ func (d Decoder) unmarshalCSV(data []byte, v any) error { switch vv := v.(type) { case *any: - *vv = records - default: - return fmt.Errorf("CSV cannot be unmarshaled into %T", v) + switch d.TargetType { + case "map": + if len(records) < 2 { + return fmt.Errorf("cannot unmarshal CSV into %T: expected at least a header row and one data row", v) + } + seen := make(map[string]bool, len(records[0])) + for _, fieldName := range records[0] { + if seen[fieldName] { + return fmt.Errorf("cannot unmarshal CSV into %T: header row contains duplicate field names", v) + } + seen[fieldName] = true + } + + sm := make([]map[string]string, len(records)-1) + for i, record := range records[1:] { + m := make(map[string]string, len(records[0])) + for j, col := range record { + m[records[0][j]] = col + } + sm[i] = m + } + *vv = sm + case "slice": + *vv = records + default: + return fmt.Errorf("cannot unmarshal CSV into %T: invalid targetType: expected either slice or map, received %s", v, d.TargetType) + } + default: + return fmt.Errorf("cannot unmarshal CSV into %T", v) } return nil diff --git a/resources/images/filters.go b/resources/images/filters.go index 9c2b9b46f..1e44f1184 100644 --- a/resources/images/filters.go +++ b/resources/images/filters.go @@ -79,6 +79,7 @@ func (*Filters) Text(text string, options ...any) gift.Filter { x: 10, y: 10, alignx: "left", + aligny: "top", linespacing: 2, } @@ -102,6 +103,11 @@ func (*Filters) Text(text string, options ...any) gift.Filter { if tf.alignx != "left" && tf.alignx != "center" && tf.alignx != "right" { panic("alignx must be one of left, center, right") } + case "aligny": + tf.aligny = cast.ToString(v) + if tf.aligny != "top" && tf.aligny != "center" && tf.aligny != "bottom" { + panic("aligny must be one of top, center, bottom") + } case "linespacing": tf.linespacing = cast.ToInt(v) diff --git a/resources/images/images_golden_integration_test.go b/resources/images/images_golden_integration_test.go index c49de7bd1..5397bee23 100644 --- a/resources/images/images_golden_integration_test.go +++ b/resources/images/images_golden_integration_test.go @@ -252,8 +252,8 @@ Home. "linespacing" 8 "size" 28 "x" (div $sunset.Width 2 | int) + "y" (div $sunset.Height 2 | int) "alignx" "center" - "y" 190 }} {{ $text := "Pariatur deserunt sunt nisi sunt tempor quis eu. Sint et nulla enim officia sunt cupidatat. Eu amet ipsum qui velit cillum cillum ad Lorem in non ad aute." }} @@ -262,6 +262,11 @@ Home. {{ template "filters" (dict "name" "text_alignx-right.jpg" "img" $sunset "filters" (images.Text $text $textOpts )) }} {{ $textOpts = (dict "alignx" "left") | merge $textOpts }} {{ template "filters" (dict "name" "text_alignx-left.jpg" "img" $sunset "filters" (images.Text $text $textOpts )) }} +{{ $textOpts = (dict "alignx" "center" "aligny" "center") | merge $textOpts }} +{{ $text = "Est exercitation deserunt exercitation nostrud magna. Eiusmod anim deserunt sit elit dolore ea incididunt nisi. Ea ullamco excepteur voluptate occaecat duis pariatur proident cupidatat. Eu id esse qui consectetur commodo ad ex esse cupidatat velit duis cupidatat. Aliquip irure tempor consequat non amet in mollit ipsum officia tempor laborum." }} +{{ template "filters" (dict "name" "text_alignx-center_aligny-center.jpg" "img" $sunset "filters" (images.Text $text $textOpts )) }} +{{ $textOpts = (dict "alignx" "center" "aligny" "bottom") | merge $textOpts }} +{{ template "filters" (dict "name" "text_alignx-center_aligny-bottom.jpg" "img" $sunset "filters" (images.Text $text $textOpts )) }} {{ define "filters"}} {{ if lt (len (path.Ext .name)) 4 }} @@ -279,6 +284,8 @@ Home. opts.T = t opts.Name = name opts.Files = files + // opts.WriteFiles = true + // opts.DevMode = true imagetesting.RunGolden(opts) } diff --git a/resources/images/testdata/images_golden/filters/text/text_alignx-center.jpg b/resources/images/testdata/images_golden/filters/text/text_alignx-center.jpg index 090600f5f..94bcb811a 100644 Binary files a/resources/images/testdata/images_golden/filters/text/text_alignx-center.jpg and b/resources/images/testdata/images_golden/filters/text/text_alignx-center.jpg differ diff --git a/resources/images/testdata/images_golden/filters/text/text_alignx-center_aligny-bottom.jpg b/resources/images/testdata/images_golden/filters/text/text_alignx-center_aligny-bottom.jpg new file mode 100644 index 000000000..ca8893e4e Binary files /dev/null and b/resources/images/testdata/images_golden/filters/text/text_alignx-center_aligny-bottom.jpg differ diff --git a/resources/images/testdata/images_golden/filters/text/text_alignx-center_aligny-center.jpg b/resources/images/testdata/images_golden/filters/text/text_alignx-center_aligny-center.jpg new file mode 100644 index 000000000..828b6e6a3 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/text/text_alignx-center_aligny-center.jpg differ diff --git a/resources/images/testdata/images_golden/filters/text/text_alignx-left.jpg b/resources/images/testdata/images_golden/filters/text/text_alignx-left.jpg index d77e301df..2894fae42 100644 Binary files a/resources/images/testdata/images_golden/filters/text/text_alignx-left.jpg and b/resources/images/testdata/images_golden/filters/text/text_alignx-left.jpg differ diff --git a/resources/images/testdata/images_golden/filters/text/text_alignx-right.jpg b/resources/images/testdata/images_golden/filters/text/text_alignx-right.jpg index 3b727234a..207e88a49 100644 Binary files a/resources/images/testdata/images_golden/filters/text/text_alignx-right.jpg and b/resources/images/testdata/images_golden/filters/text/text_alignx-right.jpg differ diff --git a/resources/images/text.go b/resources/images/text.go index 324878839..f3943a475 100644 --- a/resources/images/text.go +++ b/resources/images/text.go @@ -36,6 +36,7 @@ type textFilter struct { color color.Color x, y int alignx string + aligny string size float64 linespacing int fontSource hugio.ReadSeekCloserProvider @@ -110,12 +111,19 @@ func (f textFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) } finalLines = append(finalLines, currentLine) } + // Total height of the text from the top of the first line to the baseline of the last line + totalHeight := len(finalLines)*fontHeight + (len(finalLines)-1)*f.linespacing // Correct y position based on font and size - f.y = f.y + fontHeight - - // Start position - y := f.y + y := f.y + fontHeight + switch f.aligny { + case "top": + // Do nothing + case "center": + y = y - totalHeight/2 + case "bottom": + y = y - totalHeight + } // Draw text line by line for _, line := range finalLines { diff --git a/testscripts/commands/new.txt b/testscripts/commands/new.txt index 433e238bf..cd338203f 100644 --- a/testscripts/commands/new.txt +++ b/testscripts/commands/new.txt @@ -36,8 +36,8 @@ checkfile content/posts/post-3/bryce-canyon.jpg checkfile content/posts/post-3/index.md checkfile layouts/baseof.html checkfile layouts/home.html -checkfile layouts/list.html -checkfile layouts/single.html +checkfile layouts/section.html +checkfile layouts/page.html checkfile layouts/taxonomy.html checkfile layouts/term.html checkfile layouts/_partials/footer.html diff --git a/tpl/collections/collections_integration_test.go b/tpl/collections/collections_integration_test.go index cc60770f9..b60aaea87 100644 --- a/tpl/collections/collections_integration_test.go +++ b/tpl/collections/collections_integration_test.go @@ -278,3 +278,23 @@ disableKinds = ['rss','sitemap', 'taxonomy', 'term', 'page'] b.AssertFileContentExact("public/index.html", "0: /a3_b1.html\n\n1: /b2.html\n\n2: /a1.html\n\n3: /a2.html\n$") } + +// Issue 13621. +func TestWhereNotInEmptySlice(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/home.html -- +{{- $pages := where site.RegularPages "Kind" "not in" (slice) -}} +Len: {{ $pages | len }}| +-- layouts/all.html -- +All|{{ .Title }}| +-- content/p1.md -- + +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "Len: 1|") +} diff --git a/tpl/collections/where.go b/tpl/collections/where.go index b15cfe781..ee49d0bbb 100644 --- a/tpl/collections/where.go +++ b/tpl/collections/where.go @@ -138,6 +138,9 @@ func (ns *Namespace) checkCondition(v, mv reflect.Value, op string) (bool, error } if mv.Len() == 0 { + if op == "not in" { + return true, nil + } return false, nil } diff --git a/tpl/collections/where_test.go b/tpl/collections/where_test.go index 60f97e607..ecf748f93 100644 --- a/tpl/collections/where_test.go +++ b/tpl/collections/where_test.go @@ -761,6 +761,7 @@ func TestCheckCondition(t *testing.T) { expect{true, false}, }, {reflect.ValueOf(123), reflect.ValueOf([]int{45, 678}), "not in", expect{true, false}}, + {reflect.ValueOf(123), reflect.ValueOf([]int{}), "not in", expect{true, false}}, {reflect.ValueOf("foo"), reflect.ValueOf([]string{"bar", "baz"}), "not in", expect{true, false}}, { reflect.ValueOf(time.Date(2015, time.May, 26, 19, 18, 56, 12345, time.UTC)), diff --git a/tpl/partials/partials.go b/tpl/partials/partials.go index b9ef4b244..19882e36a 100644 --- a/tpl/partials/partials.go +++ b/tpl/partials/partials.go @@ -24,13 +24,13 @@ import ( "time" "github.com/bep/lazycache" - "github.com/gohugoio/hugo/common/constants" "github.com/gohugoio/hugo/common/hashing" "github.com/gohugoio/hugo/identity" + texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate" "github.com/gohugoio/hugo/tpl" - texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate" + "github.com/gohugoio/hugo/tpl/tplimpl" bp "github.com/gohugoio/hugo/bufferpool" "github.com/gohugoio/hugo/deps" @@ -109,7 +109,7 @@ func (c *contextWrapper) Set(in any) string { // A string if the partial is a text/template, or template.HTML when html/template. // Note that ctx is provided by Hugo, not the end user. func (ns *Namespace) Include(ctx context.Context, name string, contextList ...any) (any, error) { - res := ns.includWithTimeout(ctx, name, contextList...) + res := ns.include(ctx, name, contextList...) if res.err != nil { return nil, res.err } @@ -121,49 +121,36 @@ func (ns *Namespace) Include(ctx context.Context, name string, contextList ...an return res.result, nil } -func (ns *Namespace) includWithTimeout(ctx context.Context, name string, dataList ...any) includeResult { +func (ns *Namespace) include(ctx context.Context, name string, dataList ...any) includeResult { + v, err := ns.lookup(name) + if err != nil { + return includeResult{err: err} + } + return ns.doInclude(ctx, v, dataList...) +} + +func (ns *Namespace) lookup(name string) (*tplimpl.TemplInfo, error) { if strings.HasPrefix(name, "partials/") { // This is most likely not what the user intended. // This worked before Hugo 0.146.0. ns.deps.Log.Warnidf(constants.WarnPartialSuperfluousPrefix, "Partial name %q starting with 'partials/' (as in {{ partial \"%s\"}}) is most likely not what you want. Before 0.146.0 we did a double lookup in this situation.", name, name) } - // Create a new context with a timeout not connected to the incoming context. - timeoutCtx, cancel := context.WithTimeout(context.Background(), ns.deps.Conf.Timeout()) - defer cancel() - - res := make(chan includeResult, 1) - - go func() { - res <- ns.include(ctx, name, dataList...) - }() - - select { - case r := <-res: - return r - case <-timeoutCtx.Done(): - err := timeoutCtx.Err() - if err == context.DeadlineExceeded { - //lint:ignore ST1005 end user message. - err = fmt.Errorf("partial %q timed out after %s. This is most likely due to infinite recursion. If this is just a slow template, you can try to increase the 'timeout' config setting.", name, ns.deps.Conf.Timeout()) - } - return includeResult{err: err} + v := ns.deps.TemplateStore.LookupPartial(name) + if v == nil { + return nil, fmt.Errorf("partial %q not found", name) } + return v, nil } // include is a helper function that lookups and executes the named partial. // Returns the final template name and the rendered output. -func (ns *Namespace) include(ctx context.Context, name string, dataList ...any) includeResult { +func (ns *Namespace) doInclude(ctx context.Context, templ *tplimpl.TemplInfo, dataList ...any) includeResult { var data any if len(dataList) > 0 { data = dataList[0] } - v := ns.deps.TemplateStore.LookupPartial(name) - if v == nil { - return includeResult{err: fmt.Errorf("partial %q not found", name)} - } - templ := v - info := v.ParseInfo + info := templ.ParseInfo var w io.Writer @@ -212,6 +199,20 @@ func (ns *Namespace) IncludeCached(ctx context.Context, name string, context any Variants: variants, } depsManagerIn := tpl.Context.GetDependencyManagerInCurrentScope(ctx) + ti, err := ns.lookup(name) + if err != nil { + return nil, err + } + + if parent := tpl.Context.CurrentTemplate.Get(ctx); parent != nil { + for parent != nil { + if parent.CurrentTemplateInfoOps == ti { + // This will deadlock if we continue. + return nil, fmt.Errorf("circular call stack detected in partial %q", ti.Filename()) + } + parent = parent.Parent + } + } r, found, err := ns.cachedPartials.cache.GetOrCreate(key.Key(), func(string) (includeResult, error) { var depsManagerShared identity.Manager @@ -221,7 +222,7 @@ func (ns *Namespace) IncludeCached(ctx context.Context, name string, context any depsManagerShared = identity.NewManager("partials") ctx = tpl.Context.DependencyManagerScopedProvider.Set(ctx, depsManagerShared.(identity.DependencyManagerScopedProvider)) } - r := ns.includWithTimeout(ctx, key.Name, context) + r := ns.doInclude(ctx, ti, context) if ns.deps.Conf.Watching() { r.mangager = depsManagerShared } diff --git a/tpl/partials/partials_integration_test.go b/tpl/partials/partials_integration_test.go index 6fab3abd8..0fa47104d 100644 --- a/tpl/partials/partials_integration_test.go +++ b/tpl/partials/partials_integration_test.go @@ -256,7 +256,6 @@ func TestIncludeTimeout(t *testing.T) { files := ` -- config.toml -- baseURL = 'http://example.com/' -timeout = '200ms' -- layouts/index.html -- {{ partials.Include "foo.html" . }} -- layouts/partials/foo.html -- @@ -271,7 +270,7 @@ timeout = '200ms' ).BuildE() b.Assert(err, qt.Not(qt.IsNil)) - b.Assert(err.Error(), qt.Contains, "timed out") + b.Assert(err.Error(), qt.Contains, "maximum template call stack size exceeded") } func TestIncludeCachedTimeout(t *testing.T) { @@ -284,6 +283,8 @@ timeout = '200ms' -- layouts/index.html -- {{ partials.IncludeCached "foo.html" . }} -- layouts/partials/foo.html -- +{{ partialCached "bar.html" . }} +-- layouts/partials/bar.html -- {{ partialCached "foo.html" . }} ` @@ -295,7 +296,7 @@ timeout = '200ms' ).BuildE() b.Assert(err, qt.Not(qt.IsNil)) - b.Assert(err.Error(), qt.Contains, "timed out") + b.Assert(err.Error(), qt.Contains, `error calling partialCached: circular call stack detected in partial`) } // See Issue #10789 diff --git a/tpl/template.go b/tpl/template.go index f69ae2210..877422123 100644 --- a/tpl/template.go +++ b/tpl/template.go @@ -160,6 +160,7 @@ type CurrentTemplateInfoCommonOps interface { // CurrentTemplateInfo as returned in templates.Current. type CurrentTemplateInfo struct { Parent *CurrentTemplateInfo + Level int CurrentTemplateInfoOps } diff --git a/tpl/templates/templates_integration_test.go b/tpl/templates/templates_integration_test.go index a0dcf0348..d16333ed4 100644 --- a/tpl/templates/templates_integration_test.go +++ b/tpl/templates/templates_integration_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Hugo Authors. All rights reserved. +// Copyright 2025 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. @@ -17,6 +17,7 @@ import ( "path/filepath" "testing" + qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/hugolib" ) @@ -166,3 +167,135 @@ p3.current.Ancestors.Reverse: {{ with templates.Current }}{{ range .Ancestors.Re "p2.current.Ancestors: _partials/p1.html|all.html", ) } + +func TestBaseOfIssue13583(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- content/_index.md -- +--- +title: "Home" +outputs: ["html", "amp"] +--- +title: "Home" +-- layouts/baseof.html -- +layouts/baseof.html +{{ block "main" . }}{{ end }} +-- layouts/baseof.amp.html -- +layouts/baseof.amp.html +{{ block "main" . }}{{ end }} +-- layouts/home.html -- +{{ define "main" }} +Home. +{{ end }} + +` + b := hugolib.Test(t, files) + b.AssertFileContent("public/index.html", "layouts/baseof.html") + b.AssertFileContent("public/amp/index.html", "layouts/baseof.amp.html") +} + +func TestAllVsAmp(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- content/_index.md -- +--- +title: "Home" +outputs: ["html", "amp"] +--- +title: "Home" +-- layouts/all.html -- +All. + +` + b := hugolib.Test(t, files) + b.AssertFileContent("public/index.html", "All.") + b.AssertFileContent("public/amp/index.html", "All.") +} + +// Issue #13584. +func TestLegacySectionSection(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- content/mysection/_index.md -- +-- layouts/section/section.html -- +layouts/section/section.html + +` + b := hugolib.Test(t, files) + b.AssertFileContent("public/mysection/index.html", "layouts/section/section.html") +} + +func TestErrorMessageParseError(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/home.html -- +Line 1. +Line 2. {{ foo }} <- this func does not exist. +Line 3. +` + + b, err := hugolib.TestE(t, files) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`"/layouts/home.html:2:1": parse of template failed: template: home.html:2: function "foo" not defined`)) +} + +func TestErrorMessageExecuteError(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/home.html -- +Line 1. +Line 2. {{ .Foo }} <- this method does not exist. +Line 3. +` + + b, err := hugolib.TestE(t, files) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, filepath.FromSlash(` "/layouts/home.html:2:11": execute of template failed`)) +} + +func TestPartialReturnPanicIssue13600(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/home.html -- +Partial: {{ partial "p1.html" . }} +-- layouts/_partials/p1.html -- +P1. +{{ return ( delimit . ", " ) | string }} +` + + b, err := hugolib.TestE(t, files) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, "wrong number of args for string: want 1 got 0") +} + +func TestPartialWithoutSuffixIssue13601(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/home.html -- +P1: {{ partial "p1" . }} +P2: {{ partial "p2" . }} +-- layouts/_partials/p1 -- +P1. +-- layouts/_partials/p2 -- +P2. +{{ return "foo bar" }} + +` + + b := hugolib.Test(t, files) + b.AssertFileContent("public/index.html", "P1: P1.\nP2: foo bar") +} diff --git a/tpl/tplimpl/embedded/templates/_shortcodes/instagram.html b/tpl/tplimpl/embedded/templates/_shortcodes/instagram.html index 17188c6fd..804038e7d 100644 --- a/tpl/tplimpl/embedded/templates/_shortcodes/instagram.html +++ b/tpl/tplimpl/embedded/templates/_shortcodes/instagram.html @@ -3,7 +3,7 @@ {{- with .Get 0 -}} {{- template "render-instagram" (dict "id" . "pc" $pc) -}} {{- else -}} - {{- errorf "The %q shortocde requires a single positional parameter, the ID of the Instagram post. See %s" .Name .Position -}} + {{- errorf "The %q shortcode requires a single positional parameter, the ID of the Instagram post. See %s" .Name .Position -}} {{- end -}} {{- end -}} diff --git a/tpl/tplimpl/embedded/templates/_shortcodes/vimeo.html b/tpl/tplimpl/embedded/templates/_shortcodes/vimeo.html index 3ce470c6e..2588ac86c 100644 --- a/tpl/tplimpl/embedded/templates/_shortcodes/vimeo.html +++ b/tpl/tplimpl/embedded/templates/_shortcodes/vimeo.html @@ -4,11 +4,11 @@ Renders an embedded Vimeo video. Accepts named or positional arguments. If positional, order is id, class, title, then loading. -@param {string} [id] The video id. Optional if the id is provided as first positional argument. +@param {bool} [allowFullScreen=true] Whether the iframe element can activate full screen mode. @param {string} [class] The class attribute of the wrapping div element. When specified, removes the style attributes from the iframe element and its wrapping div element. +@param {string} [id] The video id. Optional if the id is the first and only positional argument. @param {string} [loading=eager] The loading attribute of the iframe element. @param {string} [title=Vimeo video] The title attribute of the iframe element. -@param {bool} [allowFullScreen=true] Whether the iframe element can activate full screen mode. @returns {template.HTML} @@ -22,16 +22,16 @@ title, then loading. {{- else }} {{- $dnt := cond $pc.EnableDNT 1 0 }} - {{- $id := or (.Get "id") (.Get 0) "" }} - {{- $class := or (.Get "class") (.Get 1) "" }} - {{- $title := or (.Get "title") (.Get 2) "Vimeo video" }} - {{- $loading := or (.Get "loading") (.Get 3) "eager" }} - {{- $allowFullScreen := or (.Get "allowFullScreen") (.Get 4) true }} + {{- $allowFullScreen := true }} + {{- $class := or (.Get "class") }} + {{- $id := or (.Get "id") (.Get 0) }} + {{- $loading := or (.Get "loading") }} + {{- $title := or (.Get "title") }} - {{- if in (slice "false" false 0) ($.Get "allowFullScreen") }} - {{- $allowFullScreen = false }} - {{- else if in (slice "true" true 1) ($.Get "allowFullScreen") }} + {{- if in (slice "true" true 1) (.Get "allowFullScreen") }} {{- $allowFullScreen = true }} + {{- else if in (slice "false" false 0) (.Get "allowFullScreen") }} + {{- $allowFullScreen = false }} {{- end }} {{- $iframeAllowList := "" }} diff --git a/tpl/tplimpl/embedded/templates/_shortcodes/vimeo_simple.html b/tpl/tplimpl/embedded/templates/_shortcodes/vimeo_simple.html index 11f19b1f6..86a6dfc9f 100644 --- a/tpl/tplimpl/embedded/templates/_shortcodes/vimeo_simple.html +++ b/tpl/tplimpl/embedded/templates/_shortcodes/vimeo_simple.html @@ -6,14 +6,14 @@ {{- $ctx = merge $ctx (dict "id" . "class" ($.Get "class")) -}} {{- template "render-vimeo" $ctx -}} {{- else -}} - {{- errorf "The %q shortocde requires a single named parameter, the ID of the Vimeo video. See %s" .Name .Position -}} + {{- errorf "The %q shortcode requires a single named parameter, the ID of the Vimeo video. See %s" .Name .Position -}} {{- end -}} {{- else -}} {{- with .Get 0 -}} {{- $ctx = merge $ctx (dict "id" . "class" ($.Get 1)) -}} {{- template "render-vimeo" $ctx -}} {{- else -}} - {{- errorf "The %q shortocde requires a single positional parameter, the ID of the Vimeo video. See %s" .Name .Position -}} + {{- errorf "The %q shortcode requires a single positional parameter, the ID of the Vimeo video. See %s" .Name .Position -}} {{- end -}} {{- end -}} {{- end -}} diff --git a/tpl/tplimpl/embedded/templates/_shortcodes/youtube.html b/tpl/tplimpl/embedded/templates/_shortcodes/youtube.html index cebe50626..18b086944 100644 --- a/tpl/tplimpl/embedded/templates/_shortcodes/youtube.html +++ b/tpl/tplimpl/embedded/templates/_shortcodes/youtube.html @@ -6,7 +6,7 @@ Renders an embedded YouTube video. @param {string} [class] The class attribute of the wrapping div element. When specified, removes the style attributes from the iframe element and its wrapping div element. @param {bool} [controls=true] Whether to display the video controls. @param {int} [end] The time, measured in seconds from the start of the video, when the player should stop playing the video. -@param {string} [id] The video id. Optional if the id is provided as first positional argument. +@param {string} [id] The video id. Optional if the id is the first and only positional argument. @param {string} [loading=eager] The loading attribute of the iframe element. @param {bool} [loop=false] Whether to indefinitely repeat the video. Ignores the start and end arguments after the first play. @param {bool} [mute=false] Whether to mute the video. Always true when autoplay is true. @@ -41,27 +41,29 @@ Renders an embedded YouTube video. {{- /* Get arguments. */}} {{- if in (slice "true" true 1) ($.Get "allowFullScreen") }} - {{- $iframeAllowList = printf "%s; fullscreen" $iframeAllowList }} + {{- $allowFullScreen = true }} + {{- else if in (slice "false" false 0) ($.Get "allowFullScreen") }} + {{- $allowFullScreen = false }} {{- end }} - {{- if in (slice "false" false 0) ($.Get "autoplay") }} - {{- $autoplay = 0 }} - {{- else if in (slice "true" true 1) ($.Get "autoplay") }} + {{- if in (slice "true" true 1) ($.Get "autoplay") }} {{- $autoplay = 1 }} + {{- else if in (slice "false" false 0) ($.Get "autoplay") }} + {{- $autoplay = 0 }} {{- end }} - {{- if in (slice "false" false 0) ($.Get "controls") }} - {{- $controls = 0 }} - {{- else if in (slice "true" true 1) ($.Get "controls") }} + {{- if in (slice "true" true 1) ($.Get "controls") }} {{- $controls = 1 }} + {{- else if in (slice "false" false 0) ($.Get "controls") }} + {{- $controls = 0 }} {{- end }} - {{- if in (slice "false" false 0) ($.Get "loop") }} - {{- $loop = 0 }} - {{- else if in (slice "true" true 1) ($.Get "loop") }} + {{- if in (slice "true" true 1) ($.Get "loop") }} {{- $loop = 1 }} + {{- else if in (slice "false" false 0) ($.Get "loop") }} + {{- $loop = 0 }} {{- end }} - {{- if in (slice "false" false 0) ($.Get "mute") }} - {{- $mute = 0 }} - {{- else if or (in (slice "true" true 1) ($.Get "mute")) $autoplay }} + {{- if or (in (slice "true" true 1) ($.Get "mute")) $autoplay }} {{- $mute = 1 }} + {{- else if in (slice "false" false 0) ($.Get "mute") }} + {{- $mute = 0 }} {{- end }} {{- $class := or ($.Get "class") $class }} {{- $end := or ($.Get "end") $end }} @@ -69,6 +71,11 @@ Renders an embedded YouTube video. {{- $start := or ($.Get "start") $start }} {{- $title := or ($.Get "title") $title }} + {{- /* Adjust iframeAllowList. */}} + {{- if $allowFullScreen }} + {{- $iframeAllowList = printf "%s; fullscreen" $iframeAllowList }} + {{- end }} + {{- /* Define src attribute. */}} {{- $host := cond $pc.PrivacyEnhanced "www.youtube-nocookie.com" "www.youtube.com" }} {{- $src := printf "https://%s/embed/%s" $host $id }} diff --git a/tpl/tplimpl/legacy_integration_test.go b/tpl/tplimpl/legacy_integration_test.go new file mode 100644 index 000000000..a96e35fca --- /dev/null +++ b/tpl/tplimpl/legacy_integration_test.go @@ -0,0 +1,38 @@ +// Copyright 2025 The Hugo Authors. All rights reserved. +// +// Portions Copyright The Go Authors. + +// 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 tplimpl_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestLegacyPartialIssue13599(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/partials/mypartial.html -- +Mypartial. +-- layouts/_default/index.html -- +mypartial: {{ template "partials/mypartial.html" . }} + +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "Mypartial.") +} diff --git a/tpl/tplimpl/shortcodes_integration_test.go b/tpl/tplimpl/shortcodes_integration_test.go index 9c541a1e2..9d7af4a3d 100644 --- a/tpl/tplimpl/shortcodes_integration_test.go +++ b/tpl/tplimpl/shortcodes_integration_test.go @@ -488,9 +488,9 @@ Content: {{ .Content }} // Regular mode b := hugolib.Test(t, files) - b.AssertFileContent("public/p1/index.html", "f7687b0c4e85b7d4") - b.AssertFileContent("public/p2/index.html", "f7687b0c4e85b7d4") - b.AssertFileContent("public/p3/index.html", "caca499bdc7f1e1e") + b.AssertFileContent("public/p1/index.html", "82566e6b8d04b53e") + b.AssertFileContent("public/p2/index.html", "82566e6b8d04b53e") + b.AssertFileContent("public/p3/index.html", "2b5f9cc3167d1336") // Simple mode files = strings.ReplaceAll(files, "privacy.vimeo.simple = false", "privacy.vimeo.simple = true") @@ -687,12 +687,12 @@ title: p2 b := hugolib.Test(t, files) - b.AssertFileContent("public/p1/index.html", "5156322adda11844") + b.AssertFileContent("public/p1/index.html", "4b54bf9bd03946ec") b.AssertFileContent("public/p2/index.html", "289c655e727e596c") files = strings.ReplaceAll(files, "privacy.youtube.privacyEnhanced = false", "privacy.youtube.privacyEnhanced = true") b = hugolib.Test(t, files) - b.AssertFileContent("public/p1/index.html", "599174706edf963a") + b.AssertFileContent("public/p1/index.html", "78eb19b5c6f3768f") b.AssertFileContent("public/p2/index.html", "a6db910a9cf54bc1") } diff --git a/tpl/tplimpl/templatedescriptor.go b/tpl/tplimpl/templatedescriptor.go index 21649b032..ea47afc88 100644 --- a/tpl/tplimpl/templatedescriptor.go +++ b/tpl/tplimpl/templatedescriptor.go @@ -22,8 +22,9 @@ const baseNameBaseof = "baseof" // This is used both as a key and in lookups. type TemplateDescriptor struct { // Group 1. - Kind string // page, home, section, taxonomy, term (and only those) - Layout string // list, single, baseof, mycustomlayout. + Kind string // page, home, section, taxonomy, term (and only those) + LayoutFromTemplate string // list, single, all,mycustomlayout + LayoutFromUser string // custom layout set in front matter, e.g. list, single, all, mycustomlayout // Group 2. OutputFormat string // rss, csv ... @@ -34,23 +35,21 @@ type TemplateDescriptor struct { Variant2 string // contextual variant, e.g. "id" in render. // Misc. - LayoutMustMatch bool // If set, we only look for the exact layout. - IsPlainText bool // Whether this is a plain text template. + LayoutFromUserMustMatch bool // If set, we only look for the exact layout. + IsPlainText bool // Whether this is a plain text template. } func (d *TemplateDescriptor) normalizeFromFile() { - // fmt.Println("normalizeFromFile", "kind:", d.Kind, "layout:", d.Layout, "of:", d.OutputFormat) - - if d.Layout == d.OutputFormat { - d.Layout = "" + if d.LayoutFromTemplate == d.OutputFormat { + d.LayoutFromTemplate = "" } if d.Kind == kinds.KindTemporary { d.Kind = "" } - if d.Layout == d.Kind { - d.Layout = "" + if d.LayoutFromTemplate == d.Kind { + d.LayoutFromTemplate = "" } } @@ -60,12 +59,13 @@ type descriptorHandler struct { // Note that this in this setup is usually a descriptor constructed from a page, // so we want to find the best match for that page. -func (s descriptorHandler) compareDescriptors(category Category, this, other TemplateDescriptor) weight { - if this.LayoutMustMatch && this.Layout != other.Layout { +func (s descriptorHandler) compareDescriptors(category Category, isEmbedded bool, this, other TemplateDescriptor) weight { + if this.LayoutFromUserMustMatch && this.LayoutFromUser != other.LayoutFromTemplate { return weightNoMatch } - w := this.doCompare(category, other) + w := this.doCompare(category, isEmbedded, s.opts.DefaultContentLanguage, other) + if w.w1 <= 0 { if category == CategoryMarkup && (this.Variant1 == other.Variant1) && (this.Variant2 == other.Variant2 || this.Variant2 != "" && other.Variant2 == "") { // See issue 13242. @@ -82,7 +82,7 @@ func (s descriptorHandler) compareDescriptors(category Category, this, other Tem } //lint:ignore ST1006 this vs other makes it easier to reason about. -func (this TemplateDescriptor) doCompare(category Category, other TemplateDescriptor) weight { +func (this TemplateDescriptor) doCompare(category Category, isEmbedded bool, defaultContentLanguage string, other TemplateDescriptor) weight { w := weightNoMatch // HTML in plain text is OK, but not the other way around. @@ -92,22 +92,15 @@ func (this TemplateDescriptor) doCompare(category Category, other TemplateDescri if other.Kind != "" && other.Kind != this.Kind { return w } - if other.Layout != "" && other.Layout != layoutAll && other.Layout != this.Layout { - if isLayoutCustom(this.Layout) { - if this.Kind == "" { - this.Layout = "" - } else if this.Kind == kinds.KindPage { - this.Layout = layoutSingle - } else { - this.Layout = layoutList + + if other.LayoutFromTemplate != "" && other.LayoutFromTemplate != layoutAll { + if this.LayoutFromUser == "" || this.LayoutFromUser != other.LayoutFromTemplate { + if other.LayoutFromTemplate != this.LayoutFromTemplate { + return w } } - - // Test again. - if other.Layout != this.Layout { - return w - } } + if other.Lang != "" && other.Lang != this.Lang { return w } @@ -120,33 +113,43 @@ func (this TemplateDescriptor) doCompare(category Category, other TemplateDescri // We want e.g. home page in amp output format (media type text/html) to // find a template even if one isn't specified for that output format, // when one exist for the html output format (same media type). - if category != CategoryBaseof && (this.Kind == "" || (this.Kind != other.Kind && this.Layout != other.Layout)) { + skip := category != CategoryBaseof && (this.Kind == "" || (this.Kind != other.Kind && (this.LayoutFromTemplate != other.LayoutFromTemplate && other.LayoutFromTemplate != layoutAll))) + if this.LayoutFromUser != "" { + skip = skip && (this.LayoutFromUser != other.LayoutFromTemplate) + } + if skip { return w } // Continue. } - // One example of variant1 and 2 is for render codeblocks: - // variant1=codeblock, variant2=go (language). - if other.Variant1 != "" && other.Variant1 != this.Variant1 { + if other.MediaType != this.MediaType { return w } - // If both are set and different, no match. - if other.Variant2 != "" && this.Variant2 != "" && other.Variant2 != this.Variant2 { - return w + // One example of variant1 and 2 is for render codeblocks: + // variant1=codeblock, variant2=go (language). + if other.Variant1 != "" { + if other.Variant1 != this.Variant1 { + return w + } + + if other.Variant2 != "" && other.Variant2 != this.Variant2 { + return w + } } const ( - weightKind = 3 // page, home, section, taxonomy, term (and only those) - weightcustomLayout = 4 // custom layout (mylayout, set in e.g. front matter) - weightLayout = 2 // standard layouts (single,list,all) - weightOutputFormat = 2 // a configured output format (e.g. rss, html, json) - weightMediaType = 1 // a configured media type (e.g. text/html, text/plain) - weightLang = 1 // a configured language (e.g. en, nn, fr, ...) - weightVariant1 = 4 // currently used for render hooks, e.g. "link", "image" - weightVariant2 = 2 // currently used for render hooks, e.g. the language "go" in code blocks. + weightKind = 5 // page, home, section, taxonomy, term (and only those) + weightcustomLayout = 6 // custom layout (mylayout, set in e.g. front matter) + weightLayoutStandard = 4 // standard layouts (single,list) + weightLayoutAll = 2 // the "all" layout + weightOutputFormat = 4 // a configured output format (e.g. rss, html, json) + weightMediaType = 1 // a configured media type (e.g. text/html, text/plain) + weightLang = 1 // a configured language (e.g. en, nn, fr, ...) + weightVariant1 = 6 // currently used for render hooks, e.g. "link", "image" + weightVariant2 = 4 // currently used for render hooks, e.g. the language "go" in code blocks. // We will use the values for group 2 and 3 // if the distance up to the template is shorter than @@ -170,17 +173,21 @@ func (this TemplateDescriptor) doCompare(category Category, other TemplateDescri w.w2 = weight2Group1 } - if other.Layout != "" && other.Layout == this.Layout || other.Layout == layoutAll { - if isLayoutCustom(this.Layout) { - w.w1 += weightcustomLayout - w.w2 = weight2Group2 - } else { - w.w1 += weightLayout - w.w2 = weight2Group1 - } + if other.LayoutFromTemplate != "" && (other.LayoutFromTemplate == this.LayoutFromTemplate) { + w.w1 += weightLayoutStandard + w.w2 = weight2Group1 + } else if other.LayoutFromTemplate == layoutAll { + w.w1 += weightLayoutAll + w.w2 = weight2Group1 } - if other.Lang != "" && other.Lang == this.Lang { + // LayoutCustom is only set in this (usually from Page.Layout). + if this.LayoutFromUser != "" && this.LayoutFromUser == other.LayoutFromTemplate { + w.w1 += weightcustomLayout + w.w2 = weight2Group2 + } + + if (other.Lang != "" && other.Lang == this.Lang) || (other.Lang == "" && this.Lang == defaultContentLanguage) { w.w1 += weightLang w.w3 += weight3 } @@ -199,12 +206,9 @@ func (this TemplateDescriptor) doCompare(category Category, other TemplateDescri w.w1 += weightVariant1 } - if other.Variant2 != "" && other.Variant2 == this.Variant2 { + if other.Variant1 != "" && other.Variant2 == this.Variant2 { w.w1 += weightVariant2 } - if other.Variant2 != "" && this.Variant2 == "" { - w.w1-- - } return w } diff --git a/tpl/tplimpl/templatedescriptor_test.go b/tpl/tplimpl/templatedescriptor_test.go index 76201287d..20ab47fba 100644 --- a/tpl/tplimpl/templatedescriptor_test.go +++ b/tpl/tplimpl/templatedescriptor_test.go @@ -20,14 +20,14 @@ func TestTemplateDescriptorCompare(t *testing.T) { less := func(category Category, this, other1, other2 TemplateDescriptor) { c.Helper() - result1 := dh.compareDescriptors(category, this, other1) - result2 := dh.compareDescriptors(category, this, other2) + result1 := dh.compareDescriptors(category, false, this, other1) + result2 := dh.compareDescriptors(category, false, this, other2) c.Assert(result1.w1 < result2.w1, qt.IsTrue, qt.Commentf("%d < %d", result1, result2)) } check := func(category Category, this, other TemplateDescriptor, less bool) { c.Helper() - result := dh.compareDescriptors(category, this, other) + result := dh.compareDescriptors(category, false, this, other) if less { c.Assert(result.w1 < 0, qt.IsTrue, qt.Commentf("%d", result)) } else { @@ -38,29 +38,29 @@ func TestTemplateDescriptorCompare(t *testing.T) { check( CategoryBaseof, - TemplateDescriptor{Kind: "", Layout: "", Lang: "", OutputFormat: "404", MediaType: "text/html"}, - TemplateDescriptor{Kind: "", Layout: "", Lang: "", OutputFormat: "html", MediaType: "text/html"}, + TemplateDescriptor{Kind: "", LayoutFromTemplate: "", Lang: "", OutputFormat: "404", MediaType: "text/html"}, + TemplateDescriptor{Kind: "", LayoutFromTemplate: "", Lang: "", OutputFormat: "html", MediaType: "text/html"}, false, ) check( CategoryLayout, TemplateDescriptor{Kind: "", Lang: "en", OutputFormat: "404", MediaType: "text/html"}, - TemplateDescriptor{Kind: "", Layout: "", Lang: "", OutputFormat: "alias", MediaType: "text/html"}, + TemplateDescriptor{Kind: "", LayoutFromTemplate: "", Lang: "", OutputFormat: "alias", MediaType: "text/html"}, true, ) less( CategoryLayout, - TemplateDescriptor{Kind: kinds.KindHome, Layout: "list", OutputFormat: "html"}, - TemplateDescriptor{Layout: "list", OutputFormat: "html"}, + TemplateDescriptor{Kind: kinds.KindHome, LayoutFromTemplate: "list", OutputFormat: "html"}, + TemplateDescriptor{LayoutFromTemplate: "list", OutputFormat: "html"}, TemplateDescriptor{Kind: kinds.KindHome, OutputFormat: "html"}, ) check( CategoryLayout, - TemplateDescriptor{Kind: kinds.KindHome, Layout: "list", OutputFormat: "html", MediaType: "text/html"}, - TemplateDescriptor{Kind: kinds.KindHome, Layout: "list", OutputFormat: "myformat", MediaType: "text/html"}, + TemplateDescriptor{Kind: kinds.KindHome, LayoutFromTemplate: "list", OutputFormat: "html", MediaType: "text/html"}, + TemplateDescriptor{Kind: kinds.KindHome, LayoutFromTemplate: "list", OutputFormat: "myformat", MediaType: "text/html"}, false, ) } @@ -78,27 +78,27 @@ func BenchmarkCompareDescriptors(b *testing.B) { d1, d2 TemplateDescriptor }{ { - TemplateDescriptor{Kind: "", Layout: "", OutputFormat: "404", MediaType: "text/html", Lang: "en", Variant1: "", Variant2: "", LayoutMustMatch: false, IsPlainText: false}, - TemplateDescriptor{Kind: "", Layout: "", OutputFormat: "rss", MediaType: "application/rss+xml", Lang: "", Variant1: "", Variant2: "", LayoutMustMatch: false, IsPlainText: false}, + TemplateDescriptor{Kind: "", LayoutFromTemplate: "", OutputFormat: "404", MediaType: "text/html", Lang: "en", Variant1: "", Variant2: "", LayoutFromUserMustMatch: false, IsPlainText: false}, + TemplateDescriptor{Kind: "", LayoutFromTemplate: "", OutputFormat: "rss", MediaType: "application/rss+xml", Lang: "", Variant1: "", Variant2: "", LayoutFromUserMustMatch: false, IsPlainText: false}, }, { - TemplateDescriptor{Kind: "page", Layout: "single", OutputFormat: "html", MediaType: "text/html", Lang: "en", Variant1: "", Variant2: "", LayoutMustMatch: false, IsPlainText: false}, - TemplateDescriptor{Kind: "", Layout: "list", OutputFormat: "", MediaType: "application/rss+xml", Lang: "", Variant1: "", Variant2: "", LayoutMustMatch: false, IsPlainText: false}, + TemplateDescriptor{Kind: "page", LayoutFromTemplate: "single", OutputFormat: "html", MediaType: "text/html", Lang: "en", Variant1: "", Variant2: "", LayoutFromUserMustMatch: false, IsPlainText: false}, + TemplateDescriptor{Kind: "", LayoutFromTemplate: "list", OutputFormat: "", MediaType: "application/rss+xml", Lang: "", Variant1: "", Variant2: "", LayoutFromUserMustMatch: false, IsPlainText: false}, }, { - TemplateDescriptor{Kind: "page", Layout: "single", OutputFormat: "html", MediaType: "text/html", Lang: "en", Variant1: "", Variant2: "", LayoutMustMatch: false, IsPlainText: false}, - TemplateDescriptor{Kind: "", Layout: "", OutputFormat: "alias", MediaType: "text/html", Lang: "", Variant1: "", Variant2: "", LayoutMustMatch: false, IsPlainText: false}, + TemplateDescriptor{Kind: "page", LayoutFromTemplate: "single", OutputFormat: "html", MediaType: "text/html", Lang: "en", Variant1: "", Variant2: "", LayoutFromUserMustMatch: false, IsPlainText: false}, + TemplateDescriptor{Kind: "", LayoutFromTemplate: "", OutputFormat: "alias", MediaType: "text/html", Lang: "", Variant1: "", Variant2: "", LayoutFromUserMustMatch: false, IsPlainText: false}, }, { - TemplateDescriptor{Kind: "page", Layout: "single", OutputFormat: "rss", MediaType: "application/rss+xml", Lang: "en", Variant1: "", Variant2: "", LayoutMustMatch: false, IsPlainText: false}, - TemplateDescriptor{Kind: "", Layout: "single", OutputFormat: "rss", MediaType: "application/rss+xml", Lang: "nn", Variant1: "", Variant2: "", LayoutMustMatch: false, IsPlainText: false}, + TemplateDescriptor{Kind: "page", LayoutFromTemplate: "single", OutputFormat: "rss", MediaType: "application/rss+xml", Lang: "en", Variant1: "", Variant2: "", LayoutFromUserMustMatch: false, IsPlainText: false}, + TemplateDescriptor{Kind: "", LayoutFromTemplate: "single", OutputFormat: "rss", MediaType: "application/rss+xml", Lang: "nn", Variant1: "", Variant2: "", LayoutFromUserMustMatch: false, IsPlainText: false}, }, } b.ResetTimer() for i := 0; i < b.N; i++ { for _, pair := range pairs { - _ = dh.compareDescriptors(CategoryLayout, pair.d1, pair.d2) + _ = dh.compareDescriptors(CategoryLayout, false, pair.d1, pair.d2) } } } diff --git a/tpl/tplimpl/templates.go b/tpl/tplimpl/templates.go index 1f911b9a5..19de48e38 100644 --- a/tpl/tplimpl/templates.go +++ b/tpl/tplimpl/templates.go @@ -3,7 +3,9 @@ package tplimpl import ( "io" "regexp" + "strconv" "strings" + "sync/atomic" "unicode" "unicode/utf8" @@ -42,18 +44,22 @@ var embeddedTemplatesAliases = map[string][]string{ "_shortcodes/twitter.html": {"_shortcodes/tweet.html"}, } -func (t *templateNamespace) parseTemplate(ti *TemplInfo) error { +func (s *TemplateStore) parseTemplate(ti *TemplInfo) error { + err := s.tns.doParseTemplate(ti) + if err != nil { + return s.addFileContext(ti, "parse of template failed", err) + } + + return err +} + +func (t *templateNamespace) doParseTemplate(ti *TemplInfo) error { if !ti.noBaseOf || ti.category == CategoryBaseof { // Delay parsing until we have the base template. return nil } pi := ti.PathInfo name := pi.PathNoLeadingSlash() - if ti.isLegacyMapped { - // When mapping the old taxonomy structure to the new one, we may map the same path to multiple templates per kind. - // Append the kind here to make the name unique. - name += ("-" + ti.D.Kind) - } var ( templ tpl.Template @@ -62,12 +68,18 @@ func (t *templateNamespace) parseTemplate(ti *TemplInfo) error { if ti.D.IsPlainText { prototype := t.parseText + if prototype.Lookup(name) != nil { + name += "-" + strconv.FormatUint(t.nameCounter.Add(1), 10) + } templ, err = prototype.New(name).Parse(ti.content) if err != nil { return err } } else { prototype := t.parseHTML + if prototype.Lookup(name) != nil { + name += "-" + strconv.FormatUint(t.nameCounter.Add(1), 10) + } templ, err = prototype.New(name).Parse(ti.content) if err != nil { return err @@ -96,7 +108,14 @@ func (t *templateNamespace) parseTemplate(ti *TemplInfo) error { return err } } + } + // Issue #13599. + if ti.category == CategoryPartial && ti.Fi != nil && ti.Fi.Meta().PathInfo.Section() == "partials" { + aliasName := strings.TrimPrefix(name, "_") + if _, err := prototype.AddParseTree(aliasName, templ.(*htmltemplate.Template).Tree); err != nil { + return err + } } } @@ -296,6 +315,8 @@ type templateNamespace struct { prototypeText *texttemplate.Template prototypeHTML *htmltemplate.Template + nameCounter atomic.Uint64 + standaloneText *texttemplate.Template baseofTextClones []*texttemplate.Template diff --git a/tpl/tplimpl/templatestore.go b/tpl/tplimpl/templatestore.go index eee962053..e6cd49c66 100644 --- a/tpl/tplimpl/templatestore.go +++ b/tpl/tplimpl/templatestore.go @@ -1,3 +1,18 @@ +// Copyright 2025 The Hugo Authors. All rights reserved. +// +// Portions Copyright The Go Authors. + +// 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 tplimpl import ( @@ -107,6 +122,7 @@ func NewStore(opts StoreOptions, siteOpts SiteOptions) (*TemplateStore, error) { treeMain: doctree.NewSimpleTree[map[nodeKey]*TemplInfo](), treeShortcodes: doctree.NewSimpleTree[map[string]map[TemplateDescriptor]*TemplInfo](), templatesByPath: maps.NewCache[string, *TemplInfo](), + shortcodesByName: maps.NewCache[string, *TemplInfo](), cacheLookupPartials: maps.NewCache[string, *TemplInfo](), // Note that the funcs passed below is just for name validation. @@ -301,7 +317,7 @@ func (ti *TemplInfo) String() string { return ti.PathInfo.String() } -func (ti *TemplInfo) findBestMatchBaseof(s *TemplateStore, k1 string, slashCountK1 int, best *bestMatch) { +func (ti *TemplInfo) findBestMatchBaseof(s *TemplateStore, d1 TemplateDescriptor, k1 string, slashCountK1 int, best *bestMatch) { if ti.baseVariants == nil { return } @@ -310,11 +326,11 @@ func (ti *TemplInfo) findBestMatchBaseof(s *TemplateStore, k1 string, slashCount slashCountK2 := strings.Count(k2, "/") distance := slashCountK1 - slashCountK2 - for d, vv := range v { - weight := s.dh.compareDescriptors(CategoryBaseof, ti.D, d) + for d2, vv := range v { + weight := s.dh.compareDescriptors(CategoryBaseof, false, d1, d2) weight.distance = distance if best.isBetter(weight, vv.Template) { - best.updateValues(weight, k2, d, vv.Template) + best.updateValues(weight, k2, d2, vv.Template) } } return false, nil @@ -378,11 +394,11 @@ func (q *TemplateQuery) init() { } else if kinds.GetKindMain(q.Desc.Kind) == "" { q.Desc.Kind = "" } - if q.Desc.Layout == "" && q.Desc.Kind != "" { + if q.Desc.LayoutFromTemplate == "" && q.Desc.Kind != "" { if q.Desc.Kind == kinds.KindPage { - q.Desc.Layout = layoutSingle + q.Desc.LayoutFromTemplate = layoutSingle } else { - q.Desc.Layout = layoutList + q.Desc.LayoutFromTemplate = layoutList } } @@ -404,9 +420,10 @@ type TemplateStore struct { siteOpts SiteOptions htmlFormat output.Format - treeMain *doctree.SimpleTree[map[nodeKey]*TemplInfo] - treeShortcodes *doctree.SimpleTree[map[string]map[TemplateDescriptor]*TemplInfo] - templatesByPath *maps.Cache[string, *TemplInfo] + treeMain *doctree.SimpleTree[map[nodeKey]*TemplInfo] + treeShortcodes *doctree.SimpleTree[map[string]map[TemplateDescriptor]*TemplInfo] + templatesByPath *maps.Cache[string, *TemplInfo] + shortcodesByName *maps.Cache[string, *TemplInfo] dh descriptorHandler @@ -447,7 +464,7 @@ func (s *TemplateStore) FindAllBaseTemplateCandidates(overlayKey string, desc Te continue } - if vv.D.isKindInLayout(desc.Layout) && s.dh.compareDescriptors(CategoryBaseof, descBaseof, vv.D).w1 > 0 { + if vv.D.isKindInLayout(desc.LayoutFromTemplate) && s.dh.compareDescriptors(CategoryBaseof, false, descBaseof, vv.D).w1 > 0 { result = append(result, keyTemplateInfo{Key: k, Info: vv}) } } @@ -467,20 +484,31 @@ func (t *TemplateStore) ExecuteWithContext(ctx context.Context, ti *TemplInfo, w templ := ti.Template + parent := tpl.Context.CurrentTemplate.Get(ctx) + var level int + if parent != nil { + level = parent.Level + 1 + } currentTi := &tpl.CurrentTemplateInfo{ - Parent: tpl.Context.CurrentTemplate.Get(ctx), + Parent: parent, + Level: level, CurrentTemplateInfoOps: ti, } ctx = tpl.Context.CurrentTemplate.Set(ctx, currentTi) + const levelThreshold = 999 + if level > levelThreshold { + return fmt.Errorf("maximum template call stack size exceeded in %q", ti.Filename()) + } + if t.opts.Metrics != nil { defer t.opts.Metrics.MeasureSince(templ.Name(), time.Now()) } execErr := t.storeSite.executer.ExecuteWithContext(ctx, ti, wr, data) if execErr != nil { - return t.addFileContext(ti, execErr) + return t.addFileContext(ti, "execute of template failed", execErr) } return nil } @@ -538,7 +566,7 @@ func (s *TemplateStore) LookupPagesLayout(q TemplateQuery) *TemplInfo { return m } best1.reset() - m.findBestMatchBaseof(s, key, slashCountKey, best1) + m.findBestMatchBaseof(s, q.Desc, key, slashCountKey, best1) if best1.w.w1 <= 0 { return nil } @@ -547,20 +575,36 @@ func (s *TemplateStore) LookupPagesLayout(q TemplateQuery) *TemplInfo { func (s *TemplateStore) LookupPartial(pth string) *TemplInfo { ti, _ := s.cacheLookupPartials.GetOrCreate(pth, func() (*TemplInfo, error) { - d := s.templateDescriptorFromPath(pth) - desc := d.Desc - if desc.Layout != "" { - panic("shortcode template descriptor must not have a layout") + pi := s.opts.PathParser.Parse(files.ComponentFolderLayouts, pth).ForType(paths.TypePartial) + k1, _, _, desc, err := s.toKeyCategoryAndDescriptor(pi) + if err != nil { + return nil, err } + if desc.OutputFormat == "" && desc.MediaType == "" { + // Assume HTML. + desc.OutputFormat = s.htmlFormat.Name + desc.MediaType = s.htmlFormat.MediaType.Type + desc.IsPlainText = s.htmlFormat.IsPlainText + } + best := s.getBest() defer s.putBest(best) - s.findBestMatchGet(s.key(path.Join(containerPartials, d.Path)), CategoryPartial, nil, desc, best) + s.findBestMatchGet(s.key(path.Join(containerPartials, k1)), CategoryPartial, nil, desc, best) return best.templ, nil }) return ti } +func (s *TemplateStore) LookupShortcodeByName(name string) *TemplInfo { + name = strings.ToLower(name) + ti, _ := s.shortcodesByName.Get(name) + if ti == nil { + return nil + } + return ti +} + func (s *TemplateStore) LookupShortcode(q TemplateQuery) *TemplInfo { q.init() k1 := s.key(q.Path) @@ -584,7 +628,7 @@ func (s *TemplateStore) LookupShortcode(q TemplateQuery) *TemplInfo { continue } - weight := s.dh.compareDescriptors(q.Category, q.Desc, k) + weight := s.dh.compareDescriptors(q.Category, vv.subCategory == SubCategoryEmbedded, q.Desc, k) weight.distance = distance if best.isBetter(weight, vv) { best.updateValues(weight, k2, k, vv) @@ -610,7 +654,7 @@ func (s *TemplateStore) PrintDebug(prefix string, category Category, w io.Writer return } s := strings.ReplaceAll(strings.TrimSpace(vv.content), "\n", " ") - ts := fmt.Sprintf("kind: %q layout: %q content: %.30s", vv.D.Kind, vv.D.Layout, s) + ts := fmt.Sprintf("kind: %q layout: %q lang: %q content: %.30s", vv.D.Kind, vv.D.LayoutFromTemplate, vv.D.Lang, s) fmt.Fprintf(w, "%s%s %s\n", strings.Repeat(" ", level), key, ts) } s.treeMain.WalkPrefix(prefix, func(key string, v map[nodeKey]*TemplInfo) (bool, error) { @@ -690,20 +734,12 @@ func (t *TemplateStore) UnusedTemplates() []*TemplInfo { var unused []*TemplInfo for vv := range t.templates() { - if vv.subCategory != SubCategoryMain { + if vv.subCategory != SubCategoryMain || vv.isLegacyMapped { // Skip inline partials and internal templates. continue } - if vv.noBaseOf { - if vv.executionCounter.Load() == 0 { - unused = append(unused, vv) - } - } else { - for vvv := range vv.BaseVariantsSeq() { - if vvv.Template.executionCounter.Load() == 0 { - unused = append(unused, vvv.Template) - } - } + if vv.executionCounter.Load() == 0 { + unused = append(unused, vv) } } @@ -737,7 +773,7 @@ func (s *TemplateStore) findBestMatchGet(key string, category Category, consider continue } - weight := s.dh.compareDescriptors(category, desc, k.d) + weight := s.dh.compareDescriptors(category, vv.subCategory == SubCategoryEmbedded, desc, k.d) if best.isBetter(weight, vv) { best.updateValues(weight, key, k.d, vv) } @@ -758,7 +794,7 @@ func (s *TemplateStore) findBestMatchWalkPath(q TemplateQuery, k1 string, slashC continue } - weight := s.dh.compareDescriptors(q.Category, q.Desc, k.d) + weight := s.dh.compareDescriptors(q.Category, vv.subCategory == SubCategoryEmbedded, q.Desc, k.d) weight.distance = distance isBetter := best.isBetter(weight, vv) @@ -807,7 +843,7 @@ func (t *TemplateStore) addDeferredTemplate(owner *TemplInfo, name string, n *pa return nil } -func (s *TemplateStore) addFileContext(ti *TemplInfo, inerr error) error { +func (s *TemplateStore) addFileContext(ti *TemplInfo, what string, inerr error) error { if ti.Fi == nil { return inerr } @@ -839,25 +875,27 @@ func (s *TemplateStore) addFileContext(ti *TemplInfo, inerr error) error { fe := herrors.NewFileErrorFromName(inErr, fi.Meta().Filename) fe.UpdateContent(f, lineMatcher) - if !fe.ErrorContext().Position.IsValid() { - return inErr, false - } - return fe, true + return fe, fe.ErrorContext().Position.IsValid() } - inerr = fmt.Errorf("execute of template failed: %w", inerr) + inerr = fmt.Errorf("%s: %w", what, inerr) - if err, ok := checkFilename(ti.Fi, inerr); ok { - return err + var ( + currentErr error + ok bool + ) + + if currentErr, ok = checkFilename(ti.Fi, inerr); ok { + return currentErr } if ti.base != nil { - if err, ok := checkFilename(ti.base.Fi, inerr); ok { - return err + if currentErr, ok = checkFilename(ti.base.Fi, inerr); ok { + return currentErr } } - return inerr + return currentErr } func (s *TemplateStore) extractIdentifiers(line string) []string { @@ -885,7 +923,7 @@ func (s *TemplateStore) extractInlinePartials() error { name := templ.Name() if !paths.HasExt(name) { // Assume HTML. This in line with how the lookup works. - name = name + ".html" + name = name + s.htmlFormat.MediaType.FirstSuffix.FullSuffix } if !strings.HasPrefix(name, "_") { name = "_" + name @@ -1022,6 +1060,7 @@ func (s *TemplateStore) insertShortcode(pi *paths.Path, fi hugofs.FileMetaInfo, m1[d] = ti + s.shortcodesByName.Set(k2, ti) s.setTemplateByPath(pi.Path(), ti) if fi != nil { @@ -1063,6 +1102,12 @@ func (s *TemplateStore) insertTemplate2( panic("category not set") } + if category == CategoryPartial && d.OutputFormat == "" && d.MediaType == "" { + // See issue #13601. + d.OutputFormat = s.htmlFormat.Name + d.MediaType = s.htmlFormat.MediaType.Type + } + m := tree.Get(key) nk := nodeKey{c: category, d: d} @@ -1073,7 +1118,7 @@ func (s *TemplateStore) insertTemplate2( if !replace { if v, found := m[nk]; found { - if len(pi.IdentifiersUnknown()) >= len(v.PathInfo.IdentifiersUnknown()) { + if len(pi.Identifiers()) >= len(v.PathInfo.Identifiers()) { // e.g. /pages/home.foo.html and /pages/home.html where foo may be a valid language name in another site. return nil, nil } @@ -1169,8 +1214,8 @@ func (s *TemplateStore) insertTemplates(include func(fi hugofs.FileMetaInfo) boo case containerPartials, containerShortcodes, containerMarkup: // OK. default: - applyLegacyMapping = true pi = fromLegacyPath(pi) + applyLegacyMapping = strings.Count(pi.Path(), "/") <= 2 } if applyLegacyMapping { @@ -1183,6 +1228,7 @@ func (s *TemplateStore) insertTemplates(include func(fi hugofs.FileMetaInfo) boo ext: pi.Ext(), outputFormat: pi.OutputFormat(), } + if m2, ok := legacyOrdinalMappings[key]; ok { if m1.ordinal < m2.m.ordinal { // Higher up == better match. @@ -1207,27 +1253,78 @@ func (s *TemplateStore) insertTemplates(include func(fi hugofs.FileMetaInfo) boo ) base := piOrig.PathBeforeLangAndOutputFormatAndExt() - identifiers := pi.IdentifiersUnknown() - - // Tokens on e.g. form /SECTIONKIND/THESECTION - insertSectionTokens := func(section string, kindOnly bool) string { - s := base - if !kindOnly { - s = strings.Replace(s, section, sectionToken, 1) - } - s = strings.Replace(s, kinds.KindSection, sectionKindToken, 1) - return s + identifiers := []string{} + if pi.Layout() != "" { + identifiers = append(identifiers, pi.Layout()) + } + if pi.Kind() != "" { + identifiers = append(identifiers, pi.Kind()) } - for _, section := range identifiers { - if section == baseNameBaseof { + shouldIncludeSection := func(section string) bool { + switch section { + case containerShortcodes, containerPartials, containerMarkup: + return false + case "taxonomy", "": + return false + default: + for k, v := range s.opts.TaxonomySingularPlural { + if k == section || v == section { + return false + } + } + return true + } + } + if shouldIncludeSection(pi.Section()) { + identifiers = append(identifiers, pi.Section()) + } + + identifiers = helpers.UniqueStrings(identifiers) + + // Tokens on e.g. form /SECTIONKIND/THESECTION + insertSectionTokens := func(section string) []string { + kindOnly := isLayoutStandard(section) + var ss []string + s1 := base + if !kindOnly { + s1 = strings.ReplaceAll(s1, section, sectionToken) + } + s1 = strings.ReplaceAll(s1, kinds.KindSection, sectionKindToken) + if s1 != base { + ss = append(ss, s1) + } + s1 = strings.ReplaceAll(base, kinds.KindSection, sectionKindToken) + if !kindOnly { + s1 = strings.ReplaceAll(s1, section, sectionToken) + } + if s1 != base { + ss = append(ss, s1) + } + + helpers.UniqueStringsReuse(ss) + + return ss + } + + for _, id := range identifiers { + if id == "" { continue } - kindOnly := isLayoutStandard(section) - p := insertSectionTokens(section, kindOnly) - if m1, ok := s.opts.legacyMappingSection[p]; ok { - m1.mapping.targetPath = strings.Replace(m1.mapping.targetPath, sectionToken, section, 1) - handleMapping(m1) + + p := insertSectionTokens(id) + for _, ss := range p { + if m1, ok := s.opts.legacyMappingSection[ss]; ok { + targetPath := m1.mapping.targetPath + + if targetPath != "" { + targetPath = strings.ReplaceAll(targetPath, sectionToken, id) + targetPath = strings.ReplaceAll(targetPath, sectionKindToken, id) + targetPath = strings.ReplaceAll(targetPath, "//", "/") + } + m1.mapping.targetPath = targetPath + handleMapping(m1) + } } } @@ -1325,7 +1422,7 @@ func (s *TemplateStore) parseTemplates() error { if vv.state == processingStateTransformed { continue } - if err := s.tns.parseTemplate(vv); err != nil { + if err := s.parseTemplate(vv); err != nil { return err } } @@ -1345,7 +1442,7 @@ func (s *TemplateStore) parseTemplates() error { // The regular expression used to detect if a template needs a base template has some // rare false positives. Assume we don't need one. vv.noBaseOf = true - if err := s.tns.parseTemplate(vv); err != nil { + if err := s.parseTemplate(vv); err != nil { return err } continue @@ -1374,7 +1471,7 @@ func (s *TemplateStore) parseTemplates() error { if vvv.state == processingStateTransformed { continue } - if err := s.tns.parseTemplate(vvv); err != nil { + if err := s.parseTemplate(vvv); err != nil { return err } } @@ -1402,43 +1499,6 @@ type PathTemplateDescriptor struct { Desc TemplateDescriptor } -// templateDescriptorFromPath returns a template descriptor from the given path. -// This is currently used in partial lookups only. -func (s *TemplateStore) templateDescriptorFromPath(pth string) PathTemplateDescriptor { - var ( - mt media.Type - of output.Format - ) - - // Common cases. - dotCount := strings.Count(pth, ".") - if dotCount <= 1 { - if dotCount == 0 { - // Asume HTML. - of, mt = s.resolveOutputFormatAndOrMediaType("html", "") - } else { - pth = strings.TrimPrefix(pth, "/") - ext := path.Ext(pth) - pth = strings.TrimSuffix(pth, ext) - ext = ext[1:] - of, mt = s.resolveOutputFormatAndOrMediaType("", ext) - } - } else { - path := s.opts.PathParser.Parse(files.ComponentFolderLayouts, pth) - pth = path.PathNoIdentifier() - of, mt = s.resolveOutputFormatAndOrMediaType(path.OutputFormat(), path.Ext()) - } - - return PathTemplateDescriptor{ - Path: pth, - Desc: TemplateDescriptor{ - OutputFormat: of.Name, - MediaType: mt.Type, - IsPlainText: of.IsPlainText, - }, - } -} - // resolveOutputFormatAndOrMediaType resolves the output format and/or media type // based on the given output format suffix and media type suffix. // Either of the suffixes can be empty, and the function will try to find a match @@ -1475,6 +1535,9 @@ func (s *TemplateStore) resolveOutputFormatAndOrMediaType(ofs, mns string) (outp return outputFormat, mediaType } +// templates iterates over all templates in the store. +// Note that for templates with one or more base templates applied, +// we will yield the variants, e.g. the templates that's actually in use. func (s *TemplateStore) templates() iter.Seq[*TemplInfo] { return func(yield func(*TemplInfo) bool) { for _, v := range s.treeMain.All() { @@ -1511,25 +1574,13 @@ func (s *TemplateStore) toKeyCategoryAndDescriptor(p *paths.Path) (string, strin outputFormat, mediaType := s.resolveOutputFormatAndOrMediaType(p.OutputFormat(), p.Ext()) nameNoIdentifier := p.NameNoIdentifier() - var layout string - unknownids := p.IdentifiersUnknown() - if p.Type() == paths.TypeShortcode { - if len(unknownids) > 1 { - // The name is the last identifier. - layout = unknownids[len(unknownids)-2] - } - } else if len(unknownids) > 0 { - // Pick the last, closest to the base name. - layout = unknownids[len(unknownids)-1] - } - d := TemplateDescriptor{ - Lang: p.Lang(), - OutputFormat: p.OutputFormat(), - MediaType: mediaType.Type, - Kind: p.Kind(), - Layout: layout, - IsPlainText: outputFormat.IsPlainText, + Lang: p.Lang(), + OutputFormat: p.OutputFormat(), + MediaType: mediaType.Type, + Kind: p.Kind(), + LayoutFromTemplate: p.Layout(), + IsPlainText: outputFormat.IsPlainText, } d.normalizeFromFile() @@ -1562,12 +1613,13 @@ func (s *TemplateStore) toKeyCategoryAndDescriptor(p *paths.Path) (string, strin } if category == CategoryPartial { - d.Layout = "" + d.LayoutFromTemplate = "" k1 = p.PathNoIdentifier() } if category == CategoryShortcode { k1 = p.PathNoIdentifier() + parts := strings.Split(k1, "/"+containerShortcodes+"/") k1 = parts[0] if len(parts) > 1 { @@ -1577,15 +1629,15 @@ func (s *TemplateStore) toKeyCategoryAndDescriptor(p *paths.Path) (string, strin } // Legacy layout for home page. - if d.Layout == "index" { + if d.LayoutFromTemplate == "index" { if d.Kind == "" { d.Kind = kinds.KindHome } - d.Layout = "" + d.LayoutFromTemplate = "" } - if d.Layout == d.Kind { - d.Layout = "" + if d.LayoutFromTemplate == d.Kind { + d.LayoutFromTemplate = "" } k1 = strings.TrimPrefix(k1, "/_default") @@ -1596,7 +1648,7 @@ func (s *TemplateStore) toKeyCategoryAndDescriptor(p *paths.Path) (string, strin if category == CategoryMarkup { // We store all template nodes for a given directory on the same level. k1 = strings.TrimSuffix(k1, "/_markup") - parts := strings.Split(d.Layout, "-") + parts := strings.Split(d.LayoutFromTemplate, "-") if len(parts) < 2 { return "", "", 0, TemplateDescriptor{}, fmt.Errorf("unrecognized render hook template") } @@ -1605,7 +1657,7 @@ func (s *TemplateStore) toKeyCategoryAndDescriptor(p *paths.Path) (string, strin if len(parts) > 2 { d.Variant2 = parts[2] } - d.Layout = "" // This allows using page layout as part of the key for lookups. + d.LayoutFromTemplate = "" // This allows using page layout as part of the key for lookups. } return k1, k2, category, d, nil @@ -1640,30 +1692,14 @@ func (s *TemplateStore) transformTemplates() error { if vv.category == CategoryBaseof { continue } - if !vv.noBaseOf { - for vvv := range vv.BaseVariantsSeq() { - tctx, err := applyTemplateTransformers(vvv.Template, lookup) - if err != nil { - return err - } - - for name, node := range tctx.deferNodes { - if err := s.addDeferredTemplate(vvv.Overlay, name, node); err != nil { - return err - } - } - } - } else { - tctx, err := applyTemplateTransformers(vv, lookup) - if err != nil { + tctx, err := applyTemplateTransformers(vv, lookup) + if err != nil { + return err + } + for name, node := range tctx.deferNodes { + if err := s.addDeferredTemplate(vv, name, node); err != nil { return err } - - for name, node := range tctx.deferNodes { - if err := s.addDeferredTemplate(vv, name, node); err != nil { - return err - } - } } } @@ -1776,14 +1812,19 @@ func (best *bestMatch) isBetter(w weight, ti *TemplInfo) bool { // Anything is better than nothing. return true } + if w.w1 <= 0 { + if best.w.w1 <= 0 { return ti.PathInfo.Path() < best.templ.PathInfo.Path() } return false } - if best.w.w1 > 0 { + // Note that for render hook templates, we need to make + // the embedded render hook template wih if they're a better match, + // e.g. render-codeblock-goat.html. + if best.templ.category != CategoryMarkup && best.w.w1 > 0 { currentBestIsEmbedded := best.templ.subCategory == SubCategoryEmbedded if currentBestIsEmbedded { if ti.subCategory != SubCategoryEmbedded { @@ -1816,11 +1857,7 @@ func (best *bestMatch) isBetter(w weight, ti *TemplInfo) bool { return true } - if ti.D.Layout != "" && best.desc.Layout != "" { - return ti.D.Layout != layoutAll - } - - return w.distance < best.w.distance || ti.PathInfo.Path() < best.templ.PathInfo.Path() + return ti.PathInfo.Path() < best.templ.PathInfo.Path() } return true @@ -1868,17 +1905,6 @@ type weight struct { distance int } -func (w weight) isEqualWeights(other weight) bool { - return w.w1 == other.w1 && w.w2 == other.w2 && w.w3 == other.w3 -} - -func isLayoutCustom(s string) bool { - if s == "" || isLayoutStandard(s) { - return false - } - return true -} - func isLayoutStandard(s string) bool { switch s { case layoutAll, layoutList, layoutSingle: @@ -1888,6 +1914,10 @@ func isLayoutStandard(s string) bool { } } +func (w weight) isEqualWeights(other weight) bool { + return w.w1 == other.w1 && w.w2 == other.w2 && w.w3 == other.w3 +} + func configureSiteStorage(opts SiteOptions, watching bool) *storeSite { funcsv := make(map[string]reflect.Value) diff --git a/tpl/tplimpl/templatestore_integration_test.go b/tpl/tplimpl/templatestore_integration_test.go index 4f76626ad..638341581 100644 --- a/tpl/tplimpl/templatestore_integration_test.go +++ b/tpl/tplimpl/templatestore_integration_test.go @@ -3,6 +3,7 @@ package tplimpl_test import ( "context" "io" + "strings" "testing" qt "github.com/frankban/quicktest" @@ -441,7 +442,7 @@ title: "P1" {{ define "main" }}FOO{{ end }} -- layouts/_default/single.json -- -- layouts/_default/single.html -- -{{ define "main" }}MAIN{{ end }} +{{ define "main" }}MAIN /_default/single.html{{ end }} -- layouts/post/single.html -- {{ define "main" }}MAIN{{ end }} -- layouts/_partials/usedpartial.html -- @@ -461,6 +462,8 @@ title: "P1" ) b.Build() + b.AssertFileContent("public/p1/index.html", "MAIN /_default/single.html") + unused := b.H.GetTemplateStore().UnusedTemplates() var names []string for _, tmpl := range unused { @@ -468,8 +471,9 @@ title: "P1" names = append(names, fi.Meta().PathInfo.PathNoLeadingSlash()) } } - b.Assert(len(unused), qt.Equals, 5, qt.Commentf("%#v", names)) + b.Assert(names, qt.DeepEquals, []string{"_partials/unusedpartial.html", "shortcodes/unusedshortcode.html", "baseof.json", "post/single.html", "_default/single.json"}) + b.Assert(len(unused), qt.Equals, 5, qt.Commentf("%#v", names)) } func TestCreateManyTemplateStores(t *testing.T) { @@ -507,7 +511,7 @@ baseof: {{ block "main" . }}{{ end }} q := tplimpl.TemplateQuery{ Path: "/baz", Category: tplimpl.CategoryLayout, - Desc: tplimpl.TemplateDescriptor{Kind: kinds.KindPage, Layout: "single", OutputFormat: "html"}, + Desc: tplimpl.TemplateDescriptor{Kind: kinds.KindPage, LayoutFromTemplate: "single", OutputFormat: "html"}, } for i := 0; i < b.N; i++ { store.LookupPagesLayout(q) @@ -518,7 +522,7 @@ baseof: {{ block "main" . }}{{ end }} q := tplimpl.TemplateQuery{ Path: "/foo/bar", Category: tplimpl.CategoryLayout, - Desc: tplimpl.TemplateDescriptor{Kind: kinds.KindPage, Layout: "single", OutputFormat: "html"}, + Desc: tplimpl.TemplateDescriptor{Kind: kinds.KindPage, LayoutFromTemplate: "single", OutputFormat: "html"}, } for i := 0; i < b.N; i++ { store.LookupPagesLayout(q) @@ -645,9 +649,6 @@ layout: mylayout b := hugolib.Test(t, files, hugolib.TestOptWarn()) - // s := b.H.Sites[0].TemplateStore - // s.PrintDebug("", tplimpl.CategoryLayout, os.Stdout) - b.AssertLogContains("! WARN") // Single pages. @@ -684,6 +685,102 @@ layout: mylayout b.AssertFileContent("public/en/foo/index.xml", "layouts/list.xml") } +func TestLookupOrderIssue13636(t *testing.T) { + t.Parallel() + + filesTemplate := ` +-- hugo.toml -- +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +[languages.nn] +weight = 2 +-- content/s1/p1.en.md -- +--- +outputs: ["html", "amp", "json"] +--- +-- content/s1/p1.nn.md -- +--- +outputs: ["html", "amp", "json"] +--- +-- layouts/L1 -- +L1 +-- layouts/L2 -- +L2 +-- layouts/L3 -- +L3 + +` + + tests := []struct { + Lang string + L1 string + L2 string + L3 string + ExpectHTML string + ExpectAmp string + ExpectJSON string + }{ + {"en", "all.en.html", "all.html", "single.html", "single.html", "single.html", ""}, + {"en", "all.amp.html", "all.html", "page.html", "page.html", "all.amp.html", ""}, + {"en", "all.amp.html", "all.html", "list.html", "all.html", "all.amp.html", ""}, + {"en", "all.en.html", "all.json", "single.html", "single.html", "single.html", "all.json"}, + {"en", "all.en.html", "single.json", "single.html", "single.html", "single.html", "single.json"}, + {"en", "all.en.html", "all.html", "list.html", "all.en.html", "all.en.html", ""}, + {"en", "list.en.html", "list.html", "list.en.html", "", "", ""}, + {"nn", "all.en.html", "all.html", "single.html", "single.html", "single.html", ""}, + {"nn", "all.en.html", "all.nn.html", "single.html", "single.html", "single.html", ""}, + {"nn", "all.en.html", "all.nn.html", "single.nn.html", "single.nn.html", "single.nn.html", ""}, + {"nn", "single.json", "single.nn.json", "all.json", "", "", "single.nn.json"}, + {"nn", "single.json", "single.en.json", "all.nn.json", "", "", "single.json"}, + } + + for i, test := range tests { + if i != 8 { + // continue + } + files := strings.ReplaceAll(filesTemplate, "L1", test.L1) + files = strings.ReplaceAll(files, "L2", test.L2) + files = strings.ReplaceAll(files, "L3", test.L3) + t.Logf("Test %d: %s %s %s %s", i, test.Lang, test.L1, test.L2, test.L3) + + for range 3 { + b := hugolib.Test(t, files) + b.Assert(len(b.H.Sites), qt.Equals, 2) + + var ( + pubhHTML = "public/LANG/s1/p1/index.html" + pubhAmp = "public/LANG/amp/s1/p1/index.html" + pubhJSON = "public/LANG/s1/p1/index.json" + ) + + pubhHTML = strings.ReplaceAll(pubhHTML, "LANG", test.Lang) + pubhAmp = strings.ReplaceAll(pubhAmp, "LANG", test.Lang) + pubhJSON = strings.ReplaceAll(pubhJSON, "LANG", test.Lang) + + if test.ExpectHTML != "" { + b.AssertFileContent(pubhHTML, test.ExpectHTML) + } else { + b.AssertFileExists(pubhHTML, false) + } + + if test.ExpectAmp != "" { + b.AssertFileContent(pubhAmp, test.ExpectAmp) + } else { + b.AssertFileExists(pubhAmp, false) + } + + if test.ExpectJSON != "" { + b.AssertFileContent(pubhJSON, test.ExpectJSON) + } else { + b.AssertFileExists(pubhJSON, false) + } + } + } +} + func TestLookupShortcodeDepth(t *testing.T) { t.Parallel() @@ -823,6 +920,153 @@ func TestPartialHTML(t *testing.T) { b.AssertFileContent("public/index.html", "") } +// Issue #13593. +func TestGoatAndNoGoat(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- content/_index.md -- +--- +title: "Home" +--- + + +§§§ +printf "Hello, world!" +§§§ + + +§§§ goat +.---. .-. .-. .-. .---. +| A +--->| 1 |<--->| 2 |<--->| 3 |<---+ B | +'---' '-' '+' '+' '---' +§§§ + + + +-- layouts/all.html -- +{{ .Content }} + +` + + b := hugolib.Test(t, files) + + // Basic code block. + b.AssertFileContent("public/index.html", "printf "Hello, world!"\n
")
+
+ // Goat code block.
+ b.AssertFileContent("public/index.html", "Menlo,Lucida")
+}
+
+// Issue #13595.
+func TestGoatAndNoGoatCustomTemplate(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+-- content/_index.md --
+---
+title: "Home"
+---
+
+§§§
+printf "Hello, world!"
+§§§
+
+§§§ goat
+.---. .-. .-. .-. .---.
+| A +--->| 1 |<--->| 2 |<--->| 3 |<---+ B |
+'---' '-' '+' '+' '---'
+§§§
+
+
+
+-- layouts/_markup/render-codeblock.html --
+_markup/render-codeblock.html
+-- layouts/all.html --
+{{ .Content }}
+
+`
+
+ b := hugolib.Test(t, files)
+
+ // Basic code block.
+ b.AssertFileContent("public/index.html", "_markup/render-codeblock.html")
+
+ // Goat code block.
+ b.AssertFileContent("public/index.html", "Menlo,Lucida")
+}
+
+func TestGoatcustom(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+-- content/_index.md --
+---
+title: "Home"
+---
+
+§§§
+printf "Hello, world!"
+§§§
+
+§§§ goat
+.---. .-. .-. .-. .---.
+| A +--->| 1 |<--->| 2 |<--->| 3 |<---+ B |
+'---' '-' '+' '+' '---'
+§§§
+
+
+
+-- layouts/_markup/render-codeblock.html --
+_markup/render-codeblock.html
+-- layouts/_markup/render-codeblock-goat.html --
+_markup/render-codeblock-goat.html
+-- layouts/all.html --
+{{ .Content }}
+
+`
+
+ b := hugolib.Test(t, files)
+
+ // Basic code block.
+ b.AssertFileContent("public/index.html", "_markup/render-codeblock.html")
+
+ // Custom Goat code block.
+ b.AssertFileContent("public/index.html", "_markup/render-codeblock.html_markup/render-codeblock-goat.html")
+}
+
+func TestLookupCodeblockIssue13651(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+-- layouts/all.html --
+{{ .Content }}|
+-- layouts/_markup/render-codeblock-foo.html --
+render-codeblock-foo.html
+-- content/_index.md --
+---
+---
+
+§§§
+printf "Hello, world!"
+§§§
+
+§§§ foo
+printf "Hello, world again!"
+§§§
+`
+
+ b := hugolib.Test(t, files)
+
+ content := b.FileContent("public/index.html")
+ fooCount := strings.Count(content, "render-codeblock-foo.html")
+ b.Assert(fooCount, qt.Equals, 1)
+}
+
// Issue #13515
func TestPrintPathWarningOnDotRemoval(t *testing.T) {
t.Parallel()
@@ -841,7 +1085,7 @@ All.
b := hugolib.Test(t, files, hugolib.TestOptWarn())
- b.AssertLogContains("Duplicate content path")
+ b.AssertLogContains("! Duplicate content path")
}
// Issue #13577.
@@ -974,6 +1218,63 @@ s2.
})
}
+func TestStandardLayoutInFrontMatter13588(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+disableKinds = ['home','page','rss','sitemap','taxonomy','term']
+-- content/s1/_index.md --
+---
+title: s1
+---
+-- content/s2/_index.md --
+---
+title: s2
+layout: list
+---
+-- content/s3/_index.md --
+---
+title: s3
+layout: single
+---
+-- layouts/list.html --
+list.html
+-- layouts/section.html --
+section.html
+-- layouts/single.html --
+single.html
+`
+
+ b := hugolib.Test(t, files)
+
+ b.AssertFileContent("public/s1/index.html", "section.html")
+ b.AssertFileContent("public/s2/index.html", "list.html") // fail
+ b.AssertFileContent("public/s3/index.html", "single.html") // fail
+}
+
+func TestIssue13605(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+disableKinds = ['home','rss','section','sitemap','taxonomy','term']
+-- content/s1/p1.md --
+---
+title: p1
+---
+{{< sc >}}
+-- layouts/s1/_shortcodes/sc.html --
+layouts/s1/_shortcodes/sc.html
+-- layouts/single.html --
+{{ .Content }}
+`
+
+ b := hugolib.Test(t, files)
+
+ b.AssertFileContent("public/s1/p1/index.html", "layouts/s1/_shortcodes/sc.html")
+}
+
func TestSkipDotFiles(t *testing.T) {
t.Parallel()
@@ -988,3 +1289,110 @@ All.
// Just make sure it doesn't fail.
hugolib.Test(t, files)
}
+
+func TestPartialsLangIssue13612(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+disableKinds = ['page','section','sitemap','taxonomy','term']
+
+defaultContentLanguage = 'ru'
+defaultContentLanguageInSubdir = true
+
+[languages.ru]
+weight = 1
+
+[languages.en]
+weight = 2
+
+[outputs]
+home = ['html','rss']
+
+-- layouts/_partials/comment.en.html --
+layouts/_partials/comment.en.html
+-- layouts/_partials/comment.en.xml --
+layouts/_partials/comment.en.xml
+-- layouts/_partials/comment.ru.html --
+layouts/_partials/comment.ru.html
+-- layouts/_partials/comment.ru.xml --
+layouts/_partials/comment.ru.xml
+-- layouts/home.html --
+{{ partial (print "comment." (default "ru" .Lang) ".html") . }}
+-- layouts/home.rss.xml --
+{{ partial (print "comment." (default "ru" .Lang) ".xml") . }}
+`
+
+ b := hugolib.Test(t, files)
+
+ b.AssertFileContent("public/en/index.html", "layouts/_partials/comment.en.html")
+ b.AssertFileContent("public/en/index.xml", "layouts/_partials/comment.en.xml") // fail
+ b.AssertFileContent("public/ru/index.html", "layouts/_partials/comment.ru.html") // fail
+ b.AssertFileContent("public/ru/index.xml", "layouts/_partials/comment.ru.xml") // fail
+}
+
+func TestLayoutIssue13628(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+disableKinds = ['home','rss','sitemap','taxonomy','term']
+-- content/p1.md --
+---
+title: p1
+layout: foo
+---
+-- layouts/single.html --
+layouts/single.html
+-- layouts/list.html --
+layouts/list.html
+`
+
+ for range 5 {
+
+ b := hugolib.Test(t, files)
+
+ b.AssertFileContent("public/p1/index.html", "layouts/single.html")
+ }
+}
+
+func TestTemplateLoop(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+-- layouts/_partials/p.html --
+p: {{ partial "p.html" . }}
+-- layouts/all.html --
+{{ partial "p.html" . }}
+
+`
+ b, err := hugolib.TestE(t, files)
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err.Error(), qt.Contains, "error calling partial: maximum template call stack size exceeded")
+}
+
+func TestIssue13630(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+disableKinds = ['rss','sitemap']
+-- content/p1.md --
+---
+title: p1
+layout: foo
+---
+-- layouts/list.html --
+layouts/list.html
+-- layouts/taxononmy.html.html --
+layouts/taxononmy.html.html
+`
+
+ var b *hugolib.IntegrationTestBuilder
+
+ for range 3 {
+ b = hugolib.Test(t, files)
+ b.AssertFileExists("public/p1/index.html", false)
+ }
+}
diff --git a/tpl/tplimpl/templatetransform.go b/tpl/tplimpl/templatetransform.go
index cba4c6584..eca9fdad1 100644
--- a/tpl/tplimpl/templatetransform.go
+++ b/tpl/tplimpl/templatetransform.go
@@ -175,6 +175,9 @@ func (c *templateTransformContext) applyTransformations(n parse.Node) (bool, err
}
case *parse.CommandNode:
+ if x == nil {
+ return true, nil
+ }
c.collectInner(x)
keep := c.collectReturnNode(x)
diff --git a/tpl/tplimpl/tplimpl_integration_test.go b/tpl/tplimpl/tplimpl_integration_test.go
index 8b80d5b60..b62898923 100644
--- a/tpl/tplimpl/tplimpl_integration_test.go
+++ b/tpl/tplimpl/tplimpl_integration_test.go
@@ -1,3 +1,18 @@
+// Copyright 2025 The Hugo Authors. All rights reserved.
+//
+// Portions Copyright The Go Authors.
+
+// 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 tplimpl_test
import (
diff --git a/tpl/transform/transform_integration_test.go b/tpl/transform/transform_integration_test.go
index ceb80309b..2b3c7d40e 100644
--- a/tpl/transform/transform_integration_test.go
+++ b/tpl/transform/transform_integration_test.go
@@ -379,3 +379,119 @@ Markdown: {{ $markdown }}|
b.AssertFileContent("public/index.html", "Markdown: ## Heading 2\n|")
}
+
+func TestUnmarshalCSV(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+disableKinds = ['page','rss','section','sitemap','taxonomy','term']
+-- layouts/all.html --
+{{ $opts := OPTS }}
+{{ with resources.Get "pets.csv" | transform.Unmarshal $opts }}
+ {{ jsonify . }}
+{{ end }}
+-- assets/pets.csv --
+DATA
+`
+
+ // targetType = map
+ f := strings.ReplaceAll(files, "OPTS", `dict "targetType" "map"`)
+ f = strings.ReplaceAll(f, "DATA",
+ "name,type,breed,age\nSpot,dog,Collie,3\nFelix,cat,Malicious,7",
+ )
+ b := hugolib.Test(t, f)
+ b.AssertFileContent("public/index.html",
+ `[{"age":"3","breed":"Collie","name":"Spot","type":"dog"},{"age":"7","breed":"Malicious","name":"Felix","type":"cat"}]`,
+ )
+
+ // targetType = map (no data)
+ f = strings.ReplaceAll(files, "OPTS", `dict "targetType" "map"`)
+ f = strings.ReplaceAll(f, "DATA", "")
+ b = hugolib.Test(t, f)
+ b.AssertFileContent("public/index.html", "")
+
+ // targetType = slice
+ f = strings.ReplaceAll(files, "OPTS", `dict "targetType" "slice"`)
+ f = strings.ReplaceAll(f, "DATA",
+ "name,type,breed,age\nSpot,dog,Collie,3\nFelix,cat,Malicious,7",
+ )
+ b = hugolib.Test(t, f)
+ b.AssertFileContent("public/index.html",
+ `[["name","type","breed","age"],["Spot","dog","Collie","3"],["Felix","cat","Malicious","7"]]`,
+ )
+
+ // targetType = slice (no data)
+ f = strings.ReplaceAll(files, "OPTS", `dict "targetType" "slice"`)
+ f = strings.ReplaceAll(f, "DATA", "")
+ b = hugolib.Test(t, f)
+ b.AssertFileContent("public/index.html", "")
+
+ // targetType not specified
+ f = strings.ReplaceAll(files, "OPTS", "dict")
+ f = strings.ReplaceAll(f, "DATA",
+ "name,type,breed,age\nSpot,dog,Collie,3\nFelix,cat,Malicious,7",
+ )
+ b = hugolib.Test(t, f)
+ b.AssertFileContent("public/index.html",
+ `[["name","type","breed","age"],["Spot","dog","Collie","3"],["Felix","cat","Malicious","7"]]`,
+ )
+
+ // targetType not specified (no data)
+ f = strings.ReplaceAll(files, "OPTS", "dict")
+ f = strings.ReplaceAll(f, "DATA", "")
+ b = hugolib.Test(t, f)
+ b.AssertFileContent("public/index.html", "")
+
+ // targetType = foo
+ f = strings.ReplaceAll(files, "OPTS", `dict "targetType" "foo"`)
+ _, err := hugolib.TestE(t, f)
+ if err == nil {
+ t.Errorf("expected error")
+ } else {
+ if !strings.Contains(err.Error(), `invalid targetType: expected either slice or map, received foo`) {
+ t.Log(err.Error())
+ t.Errorf("error message does not match expected error message")
+ }
+ }
+
+ // targetType = foo (no data)
+ f = strings.ReplaceAll(files, "OPTS", `dict "targetType" "foo"`)
+ f = strings.ReplaceAll(f, "DATA", "")
+ _, err = hugolib.TestE(t, f)
+ if err == nil {
+ t.Errorf("expected error")
+ } else {
+ if !strings.Contains(err.Error(), `invalid targetType: expected either slice or map, received foo`) {
+ t.Log(err.Error())
+ t.Errorf("error message does not match expected error message")
+ }
+ }
+
+ // targetType = map (error: expected at least a header row and one data row)
+ f = strings.ReplaceAll(files, "OPTS", `dict "targetType" "map"`)
+ _, err = hugolib.TestE(t, f)
+ if err == nil {
+ t.Errorf("expected error")
+ } else {
+ if !strings.Contains(err.Error(), `expected at least a header row and one data row`) {
+ t.Log(err.Error())
+ t.Errorf("error message does not match expected error message")
+ }
+ }
+
+ // targetType = map (error: header row contains duplicate field names)
+ f = strings.ReplaceAll(files, "OPTS", `dict "targetType" "map"`)
+ f = strings.ReplaceAll(f, "DATA",
+ "name,name,breed,age\nSpot,dog,Collie,3\nFelix,cat,Malicious,7",
+ )
+ _, err = hugolib.TestE(t, f)
+ if err == nil {
+ t.Errorf("expected error")
+ } else {
+ if !strings.Contains(err.Error(), `header row contains duplicate field names`) {
+ t.Log(err.Error())
+ t.Errorf("error message does not match expected error message")
+ }
+ }
+}
diff --git a/watchtestscripts.sh b/watchtestscripts.sh
index bf61d0cc3..5c6f90009 100755
--- a/watchtestscripts.sh
+++ b/watchtestscripts.sh
@@ -3,5 +3,5 @@
trap exit SIGINT
# I use "run tests on save" in my editor.
-# Unfortunately, changes to text files does not trigger this. Hence this workaround.
-while true; do find testscripts -type f -name "*.txt" | entr -pd touch main_test.go; done
\ No newline at end of file
+# Unfortunately, changes to text files do not trigger this. Hence this workaround.
+while true; do find testscripts -type f -name "*.txt" | entr -pd touch main_test.go; done