Repository: gohugoio/hugo (language: go)

Issue:
Reimplement and simplify Hugo's template system
Some key differences from `v0.145.0`:

* We now consider the complete [Page.Path](https://gohugo.io/methods/page/path/), not just the top level (`section`/`type`) when resolving templates.
* All template lookup follows the same code path. This means that you can use the same set of identifiers in the filenames for page layouts (including base, e.g. `baseof.list.html`, templates), shortcode templates, and render hook templates. 
* `_shortcodes` and `_markup` directories can be added to any level in the file tree.
* The directories `shortcodes` and `partials` are renamed `_shortcodes` and `_partials`.
* The (confusing) concept of `_internal/` templates are removed (but will still work, will be deprecated a later Hugo version), so e.g. the Twitter card template needs to be loaded via `{{ partial "twitter_cards.html" . }}`.
* Add a new catch-all layout named `all`, see #13545.

> [!note] 
>I have gone to great lengths to make this as backwards compatible as possible. All existing test passes and I have added lots more. But with the amount of Hugo sites in the wild, I would surprised if we end up with no "my site is broken" reports after releasing this, but we really need to do this.

## Identifiers and their weights

| Name  | Description | W1 | W2 | W3|
| ------------ | ------- |:-------:|:------:|:-------:|
| Distance  | To find the best matching template, we walk from the root down to the page's `Path`. The `distance` is measured in directory levels from the page `Path` up to the template path.  | |  |  |
| Kind  | The `Page` `Kind`, one of `home`, `page`, `section`, `term` or `taxonomy`.   |3| 1|  |
| Layout Standard |`all`, `list` or `single`.|2| 1|   |
| Layout Custom   |Custom layout set in the `layout` front matter key, e.g. `mylayout`.|4| 2|   |
|Type|Set in `type` front matter key. For template lookups, this effectively changes the root directory of `Page.Path`.  ||||
| OutputFormat     |Output format name, e.g. `html`.| 2| |   1|
| MediaType           |Media type, e.g. `text/html`.| 1| | 1|
| Lang  | Language, e.g. `en`.| 1|  | 1|
| Variant1 |Contextual variant, currently used to identify a render hook, e.g. `link`.|4| |    |
| Variant2|Contextual variant, currently used to identify e.g. the code language in codeblock render hooks.| 2| |  |

## Template Resolving

This section covers template resolving for  page layouts, shortcodes and render hook templates. Partials has currently a much simpler logic.

To resolve a template, we

1. Create a Page descriptor with the identifiers listed above.
1. Walk the template tree down to the Page path and compare the Page descriptor with the current template descriptor.
    1. If the template descriptor is a subset of the Page descriptor, we calculate the weights `w1`, `w2` and `w3`.
    1. If this is the first match, we store it away.
    1. Else we compare it to the previous best match:
         1. If the new match's path is closer to the Page path than the  previous best match's path, we compare their values for `w2` and `w3`, in that order. For example, given a Page path `/foo/bar`, a layout in `/layouts/foo/single.html` would win over `/layouts/page.html` even if the latter has a higher `w1` weight.
         1. Else we compare their values for `w1`.
         1. All things equal, these are the tie breakers:
             1. Shortest distance.
             1. If `layout` is set, we prefer any value before `all`.
             1. The lesser layout Path (lexicographically).   

There are some special cases to the above:

* We prefer user provided templates over embedded templates.
* TODO(bep) complete this.

## Example layouts directory

```
layouts
├── baseof.html
├── home.html
├── list.html
├── single.html
├── taxonomy.html
├── term.html
├── term.mylayout.section.en.rss.xml
├── _markup
│   ├── render-codeblock-go.term.mylayout.no.rss.xml
│   └── render-link.html
├── _partials
│   └── mypartial.html
├── _shortcodes
│   ├── myshortcode.html
│   └── myshortcode.section.mylayout.en.rss.xml
├── docs
│   ├── baseof.html
│   ├── _shortcodes
│   │   └── myshortcode.html
│   └── api
│       ├── mylayout.html
│       ├── single.html
│       └── _markup
│           └── render-link.html
└── tags
    ├── taxonomy.html
    ├── term.html
    └── blue
        └── list.html
```

Fixes #13515
Updates #7964
Updates #13365
Updates #12988
Updates #4891



## Fix/adjusment tasks

- [x] Fix output format resolve issue in `head/css.html` and similar partials.
- [x] --printWarnings vs https://github.com/gohugoio/hugo/issues/13515.
- [x] TestLayoutAllRevised



Discussion / hints:
This is really, really good.
>This is really, really good.

Yea, I'm pretty happy with the end result, but I have changed my mind a few times. Note that this is still a draft; it's mostly working, but there's some TODOs sprinkled around, some commented out tests, and everything I say about shortcodes above isn't yet entirely true, but it will be.

> still a draft
> it's mostly working
> isn't yet entirely true

I've gone-to-market with less, but in most cases under protest.
I am not trying to increase the scope of this PR, but am curious if either of these are affected:

- <https://github.com/gohugoio/hugo/issues/8998>
- <https://github.com/gohugoio/hugo/issues/3996>
@jmooring none of those issues is somehow fixed by this (whatever that would mean). But the order of things is certainly easier to reason about in the new setup:

```go
if err := s.insertTemplates(nil, false); err != nil {
  return nil, err
}
if err := s.insertEmbedded(); err != nil {
  return nil, err
}
if err := s.parseTemplates(); err != nil {
  return nil, err
}
if err := s.extractInlinePartials(); err != nil {
  return nil, err
}
if err := s.transformTemplates(); err != nil {
  return nil, err
}
if err := s.tns.createPrototypes(true); err != nil {
  return nil, err
}
if err := s.prepareTemplates(); err != nil {
  return nil, err
}
```

So, while we `extractInlinePartials` later, we don't overwrite partials with the same path[^1]. If we did, that would for one break how we use the file system overlay to override templates (project partial wins over theme partial). That could possibly be handled, but that is certainly not in scope here.

[^1]: ... but if you should be able to override by using e.g. `mypartial.html.html` (e.g. both output format and media type)
@jmooring All tests are green and I have completed my TODO list. I will do some more manual testing of this myself, but I would appreciate if you could take it for a spin, and especially see if you agree with my choices in the "lookup department"; the most opinionated is probably the path/depth logic. I have tried to explain it above, I'm not sure how well I succeeded. 
@bep 

```text
git clone https://github.com/jmooring/test-site-new-template-system
cd test-site-new-template-system
hugo server
```

At the top of the page you'll see this:

![image](https://github.com/user-attachments/assets/92e0b807-6e31-4a25-a9b8-aeecb7fc7dc3)

@jmooring good catch, odd that none of my tests caught this. Probably a confusion between text and html parsing for the partial func. If you do this, it "works":

```handlebars
{{ partialCached "head/css.html" . | safeHTML }}
```

Which is obviously not what we want. I will add a test for this and fix it.
Here's what happens:

```
head/css.html => outputformat = css output format => plain text template.
```

The isn't entirely incorrect and comes from us now handling all templates more or less the same in this department, but I see that we need to add an additional check to make sure that the suffix matches the output format.




@jmooring I have pushed a fix for the output format issue in fd073d256e745d2f214ec55c9f6b2086d499cd79 -- I have also created a "task list" in the intro comment to indicate status of any similar future issue.
Found while verifying behavior of https://github.com/gohugoio/hugo/issues/13515.

```text
$ hugo new content content/v0.123.0.md
Content "/home/jmooring/temp/test-site-new-template-system/content/v0.123.0.md" created

$ hugo new content content/v0.123.1.md
panic: [BUG] no Page found for "/home/jmooring/temp/test-site-new-template-system/content/v0.123.1.md"
```

I don't think this is anything new. See:

- https://github.com/gohugoio/hugo/issues/12786
- https://github.com/gohugoio/hugo/issues/12602
- https://github.com/gohugoio/hugo/issues/12194



Also found while verifying behavior of https://github.com/gohugoio/hugo/issues/13515.

```text
content/
├── _index.md
├── v0.123.0.md
└── v0.123.1.md
```

As expected, both are published to `public/v0/index.html`. But `--printPathWarnings` doesn't report the collision. I think that the release notes should encourage users to run `hugo --printPathWarnings` while testing the upgrade to detect pages that are affected by #13515. 


This was unexpected, perhaps a specificity problem with the "all" layout.

```text
./
├── content/
│   ├── s1/
│   │   └── p1.md
│   └── s2/
│       └── p2.md
├── layouts/
│   ├── s1/
│   │   └── all.html
│   ├── list.html
│   └── single.html
├── public/
│   ├── s1/
│   │   ├── p1/
│   │   │   └── index.html <-- rendered by layouts/single.html (unexpected)
│   │   └── index.html     <-- rendered by layouts/list.html (unexpected)
│   ├── s2/
│   │   ├── p2/
│   │   │   └── index.html <-- rendered by layouts/single.html 
│   │   └── index.html     <-- rendered by layouts/list.html
│   └── index.html         <-- rendered by layouts/list.html
└── hugo.toml
``` 

<details>
<summary>test case</summary>

```go
func TestLayoutAllRevised(t *testing.T) {
	t.Parallel()

	files := `
-- hugo.toml --
disableKinds = ['rss','sitemap','taxonomy','term']
-- content/s1/p1.md --
---
title: p1
---
-- content/s2/p2.md --
---
title: p2
---
-- layouts/single.html --
layouts/single.html
-- layouts/list.html --
layouts/list.html
-- layouts/s1/all.html --
layouts/s1/all.html
`

	b := hugolib.Test(t, files)

	b.AssertFileContent("public/index.html", "layouts/list.html")
	b.AssertFileContent("public/s1/index.html", "layouts/s1/all.html")    // fails
	b.AssertFileContent("public/s1/p1/index.html", "layouts/s1/all.html") // fails
	b.AssertFileContent("public/s2/index.html", "layouts/list.html")
	b.AssertFileContent("public/s2/p2/index.html", "layouts/single.html")
}
```
</details>

>This was unexpected, perhaps a specificity problem with the "all" layout.

`index` is not a layout anymore (to my recollection, that was only used for the "home page" before, and that still works). I'm not saying that that is the "problem" in the above, but I find it curious that you use that as a use case.
I am not using index as a layout; including the public directory in the tree above might have made that unclear. I just added a test case.
 OK, sorry, I read that a little fast. I have put it on my list.
@jmooring I have pushed fixes to 2 issues reported above, the path warnings and the all layout behavior.
This is also related to https://github.com/gohugoio/hugo/issues/13515. The existing behavior may be correct, but it caught my attention.

When given a content path such as p2.x, content adapters do not remove the extraneous portions of the file name.

<details>
<summary>integration test</summary>

```go
func TestContentTemplateDotSegments(t *testing.T) {
	t.Parallel()

	files := `
-- hugo.toml --
disableKinds = ['home','rss','section','sitemap','taxonomy','term']
-- content/s2/_content.gotmpl --
{{ $page := dict "path" "p2.x" "title" "p2 x"}}
{{ .AddPage $page }}
-- content/s1/p1.x.md --
---
title: p1 x
---
-- layouts/single.html --
{{ .Title }}
`

	b := hugolib.Test(t, files)

	b.AssertFileExists("public/s1/p1/index.html", true)
	b.AssertFileExists("public/s2/p2/index.html", true) // fail, got public/s2/p2.x/index.html instead
}
```
</details>


>The existing behavior may be correct, but it caught my attention.

I see your point, but I think I'm going to leave this as-is. The "dot" issue is about delmiters in base filenames used to create a path. In your example, the path is pre-defined. There's no filename in play, no delimiters, no ambigouity,.

I can't break this.

I've tried some stupid, convoluted, unrealistic setups and they all work. And that includes the often-forgotten [content view](https://gohugo.io/templates/content-view/) templates.

> see if you agree with my choices in the "lookup department"; the most opinionated is probably the path/depth logic. 

Maybe this was lazy on my part, but I didn't spend any time digging into the lookup mechanics because the behavior is intuitive. Perhaps some of your choices were opinionated, but none of them were surprising.

> I have tried to explain it above, I'm not sure how well I succeeded.

Given how intuitive this is, I am inclined to keep the documentation simple, perhaps using examples more than textual descriptions. Your detailed explanation belongs somewhere, but not front-and-center. To be honest, I didn't spend any time reading your explanation while testing this... everything just worked.

Ship it.
How much modification should end up being required for this? I bisected my Paper-Mod based site breaking to this commit, and I'm not sure whether it's a bug in hugo, or something that just needs to change in the theme.

```
Total in 38 ms
Error: error building site: html/template:_partials/head.html:156:13: no such template "partials/templates/schema_json.html"
```
> How much modification should end up being required for this? I bisected my Paper-Mod based site breaking to this commit, and I'm not sure whether it's a bug in hugo, or something that just needs to change in the theme.
> 
> ```
> Total in 38 ms
> Error: error building site: html/template:_partials/head.html:156:13: no such template "partials/templates/schema_json.html"
> ```

I have this exact error as well. You should update the 'head.html' file to include _partials as well. 
See attached on how I did that.

<img width="1335" alt="Screenshot 2025-04-11 at 10 13 55" src="https://github.com/user-attachments/assets/34150604-438c-4967-92ca-b315b40668cd" />

@jakebailey I don't follow closed PRs (or: almost never). If you think it's a bug, create a new issue, for questions, see https://discourse.gohugo.io/ 

We have tested many Hugo sites in the wild without any issues and need for any upgrade and the entire Hugo test suite was green without adjustments, but this replaces 10 years of accumulated and confusing logic, so some breakage were expected. 


Sorry, I should know better :smile: 

I filed #13592. https://github.com/adityatelange/hugo-PaperMod/issues/1719 is also talking about this, though I am unsure if the theme is what should change (the partial appears to exist).

> I have this exact error as well. You should update the 'head.html' file to include _partials as well.

That's a bit of a challenge given it's not my theme, but something I depend on.

Fix patch:
diff --git a/hugolib/alias.go b/hugolib/alias.go
index 3beee44dbb7..0bb3165c733 100644
--- a/hugolib/alias.go
+++ b/hugolib/alias.go
@@ -53,7 +53,7 @@ func (a aliasHandler) renderAlias(permalink string, p page.Page) (io.Reader, err
 	if ps, ok := p.(*pageState); ok {
 		base, templateDesc = ps.GetInternalTemplateBasePathAndDescriptor()
 	}
-	templateDesc.Layout = ""
+	templateDesc.LayoutFromUser = ""
 	templateDesc.Kind = ""
 	templateDesc.OutputFormat = output.AliasHTMLFormat.Name
 
diff --git a/hugolib/page.go b/hugolib/page.go
index b36b9712ca9..84d4d1ea883 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/tpl/tplimpl/templatedescriptor.go b/tpl/tplimpl/templatedescriptor.go
index f8cf0287090..8e4390faebd 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 = ""
 	}
 }
 
@@ -61,7 +60,7 @@ 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, isEmbedded bool, this, other TemplateDescriptor) weight {
-	if this.LayoutMustMatch && this.Layout != other.Layout {
+	if this.LayoutFromUserMustMatch && this.LayoutFromUser != other.LayoutFromTemplate {
 		return weightNoMatch
 	}
 
@@ -94,20 +93,15 @@ func (this TemplateDescriptor) doCompare(category Category, isEmbedded bool, oth
 		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 == "" {
+			if other.LayoutFromTemplate != this.LayoutFromTemplate {
+				return w
+			}
+		} else if isLayoutStandard(this.LayoutFromUser) {
+			if other.LayoutFromTemplate != this.LayoutFromUser {
+				return w
 			}
-		}
-
-		// Test again.
-		if other.Layout != this.Layout {
-			return w
 		}
 	}
 
@@ -123,7 +117,11 @@ func (this TemplateDescriptor) doCompare(category Category, isEmbedded bool, oth
 		// 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 && other.Layout != layoutAll))) {
+		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
 		}
 
@@ -148,14 +146,14 @@ func (this TemplateDescriptor) doCompare(category Category, isEmbedded bool, oth
 	}
 
 	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           = 3 // page, home, section, taxonomy, term (and only those)
+		weightcustomLayout   = 4 // custom layout (mylayout, set in e.g. front matter)
+		weightLayoutStandard = 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.
 
 		// We will use the values for group 2 and 3
 		// if the distance up to the template is shorter than
@@ -179,14 +177,16 @@ func (this TemplateDescriptor) doCompare(category Category, isEmbedded bool, oth
 		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 this.LayoutFromUser == "" && other.LayoutFromTemplate != "" && (other.LayoutFromTemplate == this.LayoutFromTemplate || other.LayoutFromTemplate == layoutAll) {
+		w.w1 += weightLayoutStandard
+		w.w2 = weight2Group1
+
+	}
+
+	// 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 {
@@ -208,12 +208,9 @@ func (this TemplateDescriptor) doCompare(category Category, isEmbedded bool, oth
 		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
 }


Test patch:
diff --git a/hugolib/content_map_test.go b/hugolib/content_map_test.go
index aed2a7f1398..c24790495ea 100644
--- a/hugolib/content_map_test.go
+++ b/hugolib/content_map_test.go
@@ -538,6 +538,7 @@ title: p1
 -- content/p1/c.html --
 <p>c</p>
 -- 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/tpl/tplimpl/templatedescriptor_test.go b/tpl/tplimpl/templatedescriptor_test.go
index 6fb8c219515..20ab47fba94 100644
--- a/tpl/tplimpl/templatedescriptor_test.go
+++ b/tpl/tplimpl/templatedescriptor_test.go
@@ -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,20 +78,20 @@ 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},
 		},
 	}
 
diff --git a/tpl/tplimpl/templatestore.go b/tpl/tplimpl/templatestore.go
index 6ef03053178..8483b7df033 100644
--- a/tpl/tplimpl/templatestore.go
+++ b/tpl/tplimpl/templatestore.go
@@ -378,11 +378,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
 		}
 	}
 
@@ -447,7 +447,7 @@ func (s *TemplateStore) FindAllBaseTemplateCandidates(overlayKey string, desc Te
 				continue
 			}
 
-			if vv.D.isKindInLayout(desc.Layout) && s.dh.compareDescriptors(CategoryBaseof, false, 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})
 			}
 		}
@@ -549,7 +549,7 @@ 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 != "" {
+		if desc.LayoutFromTemplate != "" {
 			panic("shortcode template descriptor must not have a layout")
 		}
 		best := s.getBest()
@@ -610,7 +610,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 content: %.30s", vv.D.Kind, vv.D.LayoutFromTemplate, 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) {
@@ -1573,12 +1573,12 @@ func (s *TemplateStore) toKeyCategoryAndDescriptor(p *paths.Path) (string, strin
 	}
 
 	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: layout,
+		IsPlainText:        outputFormat.IsPlainText,
 	}
 
 	d.normalizeFromFile()
@@ -1611,7 +1611,7 @@ func (s *TemplateStore) toKeyCategoryAndDescriptor(p *paths.Path) (string, strin
 	}
 
 	if category == CategoryPartial {
-		d.Layout = ""
+		d.LayoutFromTemplate = ""
 		k1 = p.PathNoIdentifier()
 	}
 
@@ -1626,15 +1626,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")
@@ -1645,7 +1645,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")
 		}
@@ -1654,7 +1654,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
@@ -1832,7 +1832,10 @@ func (best *bestMatch) isBetter(w weight, ti *TemplInfo) bool {
 		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 {
@@ -1865,8 +1868,8 @@ func (best *bestMatch) isBetter(w weight, ti *TemplInfo) bool {
 			return true
 		}
 
-		if ti.D.Layout != "" && best.desc.Layout != "" {
-			return ti.D.Layout != layoutAll
+		if ti.D.LayoutFromTemplate != "" && best.desc.LayoutFromTemplate != "" {
+			return ti.D.LayoutFromTemplate != layoutAll
 		}
 
 		return w.distance < best.w.distance || ti.PathInfo.Path() < best.templ.PathInfo.Path()
@@ -1917,17 +1920,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:
@@ -1937,6 +1929,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 618c173fc76..4644c963958 100644
--- a/tpl/tplimpl/templatestore_integration_test.go
+++ b/tpl/tplimpl/templatestore_integration_test.go
@@ -510,7 +510,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)
@@ -521,7 +521,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)
@@ -648,9 +648,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.
@@ -827,7 +824,46 @@ func TestPartialHTML(t *testing.T) {
 }
 
 // Issue #13593.
-func TestNoGoat(t *testing.T) {
+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", "<code>printf &#34;Hello, world!&#34;\n</code>")
+
+	// Goat code block.
+	b.AssertFileContent("public/index.html", "Menlo,Lucida")
+}
+
+// Issue #13595.
+func TestGoatAndNoGoatCustomTemplate(t *testing.T) {
 	t.Parallel()
 
 	files := `
@@ -841,6 +877,16 @@ title: "Home"
 printf "Hello, world!"
 §§§
 
+§§§ goat
+.---.     .-.       .-.       .-.     .---.
+| A +--->| 1 |<--->| 2 |<--->| 3 |<---+ B |
+'---'     '-'       '+'       '+'     '---'
+§§§
+
+
+
+-- layouts/_markup/render-codeblock.html --
+_markup/render-codeblock.html
 -- layouts/all.html --
 {{ .Content }}
 
@@ -848,7 +894,51 @@ printf "Hello, world!"
 
 	b := hugolib.Test(t, files)
 
-	b.AssertFileContent("public/index.html", "Hello, world!")
+	// 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")
 }
 
 // Issue #13515
@@ -1002,6 +1092,41 @@ 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 TestSkipDotFiles(t *testing.T) {
 	t.Parallel()