From 83cfdd78ca6469e6d7265323d9fad1448880e559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Sun, 6 Apr 2025 19:55:35 +0200 Subject: [PATCH] Reimplement and simplify Hugo's template system See #13541 for details. Fixes #13545 Fixes #13515 Closes #7964 Closes #13365 Closes #12988 Closes #4891 --- commands/server.go | 16 +- common/constants/constants.go | 1 + common/hstrings/strings.go | 10 +- common/maps/cache.go | 19 + common/maps/ordered.go | 12 +- common/paths/pathparser.go | 352 +++- common/paths/pathparser_test.go | 168 +- common/paths/pathtype_string.go | 27 - common/paths/type_string.go | 32 + common/types/types.go | 10 + config/allconfig/allconfig.go | 19 +- create/content.go | 4 +- deps/deps.go | 47 +- hugolib/alias.go | 37 +- hugolib/alias_test.go | 21 +- hugolib/content_factory.go | 4 +- hugolib/content_map.go | 9 +- hugolib/content_map_page.go | 18 +- hugolib/content_map_test.go | 23 +- hugolib/content_render_hooks_test.go | 93 + hugolib/doctree/simpletree.go | 182 +- hugolib/doctree/support.go | 10 +- hugolib/doctree/treeshifttree.go | 18 +- hugolib/hugo_sites.go | 2 - hugolib/hugo_sites_build.go | 56 +- hugolib/hugo_smoke_test.go | 13 +- hugolib/integrationtest_builder.go | 18 +- hugolib/page.go | 81 +- hugolib/page__common.go | 4 - hugolib/page__content.go | 25 +- hugolib/page__meta.go | 11 +- hugolib/page__per_output.go | 159 +- hugolib/pages_capture.go | 17 +- hugolib/pagesfromdata/pagesfromgotmpl.go | 8 +- .../pagesfromgotmpl_integration_test.go | 3 +- hugolib/paginator_test.go | 21 +- hugolib/rebuild_test.go | 147 +- hugolib/shortcode.go | 94 +- hugolib/shortcode_test.go | 28 +- hugolib/site.go | 76 +- hugolib/site_output.go | 3 +- hugolib/site_output_test.go | 3 +- hugolib/site_render.go | 8 +- hugolib/site_test.go | 3 +- hugolib/taxonomy_test.go | 2 + hugolib/template_test.go | 57 +- identity/identity.go | 4 + internal/js/esbuild/batch.go | 8 +- langs/i18n/i18n_test.go | 3 - .../codeblocks/codeblocks_integration_test.go | 6 +- media/builtin.go | 3 + media/config.go | 5 +- media/config_test.go | 2 +- media/mediaType.go | 10 +- output/docshelper.go | 86 +- output/layouts/layout.go | 336 --- output/layouts/layout_test.go | 982 --------- output/outputFormat.go | 26 +- output/outputFormat_test.go | 4 +- resources/kinds/kinds.go | 1 + resources/page/page.go | 4 +- resources/page/page_paths.go | 2 +- resources/page/pages_related.go | 2 +- resources/page/testhelpers_test.go | 2 +- resources/resource_spec.go | 3 - .../templates/execute_as_template.go | 15 +- .../commands/hugo_printunusedtemplates.txt | 2 +- tpl/collections/apply.go | 4 +- tpl/collections/apply_test.go | 104 - .../htmltemplate/hugo_template.go | 27 + .../go_templates/htmltemplate/template.go | 2 +- .../go_templates/texttemplate/example_test.go | 2 +- tpl/math/init.go | 2 +- tpl/math/math.go | 16 +- tpl/math/math_test.go | 48 +- tpl/partials/partials.go | 50 +- tpl/partials/partials_integration_test.go | 10 +- tpl/template.go | 134 +- tpl/template_test.go | 14 +- tpl/templates/defer_integration_test.go | 75 + tpl/templates/templates.go | 4 +- tpl/tplimpl/category_string.go | 30 + .../_markup/render-codeblock-goat.html | 0 .../{_default => }/_markup/render-image.html | 0 .../{_default => }/_markup/render-link.html | 0 .../{_default => }/_markup/render-table.html | 0 .../_funcs/get-page-images.html | 0 .../templates/{ => _partials}/disqus.html | 2 +- .../{ => _partials}/google_analytics.html | 0 .../templates/{ => _partials}/opengraph.html | 0 .../templates/{ => _partials}/pagination.html | 2 +- .../templates/{ => _partials}/schema.html | 0 .../{ => _partials}/twitter_cards.html | 0 .../1__h_simple_assets.html | 0 .../{shortcodes => _shortcodes}/comment.html | 0 .../{shortcodes => _shortcodes}/details.html | 0 .../{shortcodes => _shortcodes}/figure.html | 0 .../{shortcodes => _shortcodes}/gist.html | 0 .../highlight.html | 0 .../instagram.html | 0 .../instagram_simple.html | 0 .../{shortcodes => _shortcodes}/param.html | 0 .../{shortcodes => _shortcodes}/qr.html | 0 .../{shortcodes => _shortcodes}/ref.html | 0 .../{shortcodes => _shortcodes}/relref.html | 0 .../{shortcodes => _shortcodes}/twitter.html | 0 .../twitter_simple.html | 0 .../{shortcodes => _shortcodes}/vimeo.html | 0 .../vimeo_simple.html | 0 .../{shortcodes => _shortcodes}/x.html | 0 .../{shortcodes => _shortcodes}/x_simple.html | 0 .../{shortcodes => _shortcodes}/youtube.html | 0 .../templates/{_default => }/robots.txt | 0 .../embedded/templates/{_default => }/rss.xml | 0 .../templates/{_default => }/sitemap.xml | 0 .../templates/{_default => }/sitemapindex.xml | 0 tpl/tplimpl/legacy.go | 130 ++ tpl/tplimpl/render_hook_integration_test.go | 2 +- tpl/tplimpl/shortcodes.go | 153 -- tpl/tplimpl/shortcodes_test.go | 91 - tpl/tplimpl/subcategory_string.go | 25 + tpl/tplimpl/template.go | 1235 ----------- tpl/tplimpl/templateFuncster.go | 14 - tpl/tplimpl/templateProvider.go | 51 - tpl/tplimpl/template_ast_transformers_test.go | 161 -- tpl/tplimpl/template_errors.go | 64 - tpl/tplimpl/template_funcs.go | 127 +- tpl/tplimpl/template_funcs_test.go | 2 +- tpl/{ => tplimpl}/template_info.go | 25 +- tpl/tplimpl/template_test.go | 40 - tpl/tplimpl/templatedescriptor.go | 225 ++ tpl/tplimpl/templatedescriptor_test.go | 104 + tpl/tplimpl/templates.go | 331 +++ tpl/tplimpl/templatestore.go | 1854 +++++++++++++++++ tpl/tplimpl/templatestore_integration_test.go | 842 ++++++++ ...t_transformers.go => templatetransform.go} | 104 +- tpl/tplimpl/tplimpl_integration_test.go | 59 +- tpl/tplimplinit/tplimplinit.go | 96 + 138 files changed, 5342 insertions(+), 4396 deletions(-) delete mode 100644 common/paths/pathtype_string.go create mode 100644 common/paths/type_string.go delete mode 100644 output/layouts/layout.go delete mode 100644 output/layouts/layout_test.go delete mode 100644 tpl/collections/apply_test.go create mode 100644 tpl/tplimpl/category_string.go rename tpl/tplimpl/embedded/templates/{_default => }/_markup/render-codeblock-goat.html (100%) rename tpl/tplimpl/embedded/templates/{_default => }/_markup/render-image.html (100%) rename tpl/tplimpl/embedded/templates/{_default => }/_markup/render-link.html (100%) rename tpl/tplimpl/embedded/templates/{_default => }/_markup/render-table.html (100%) rename tpl/tplimpl/embedded/templates/{partials => _partials}/_funcs/get-page-images.html (100%) rename tpl/tplimpl/embedded/templates/{ => _partials}/disqus.html (92%) rename tpl/tplimpl/embedded/templates/{ => _partials}/google_analytics.html (100%) rename tpl/tplimpl/embedded/templates/{ => _partials}/opengraph.html (100%) rename tpl/tplimpl/embedded/templates/{ => _partials}/pagination.html (98%) rename tpl/tplimpl/embedded/templates/{ => _partials}/schema.html (100%) rename tpl/tplimpl/embedded/templates/{ => _partials}/twitter_cards.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/1__h_simple_assets.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/comment.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/details.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/figure.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/gist.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/highlight.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/instagram.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/instagram_simple.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/param.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/qr.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/ref.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/relref.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/twitter.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/twitter_simple.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/vimeo.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/vimeo_simple.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/x.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/x_simple.html (100%) rename tpl/tplimpl/embedded/templates/{shortcodes => _shortcodes}/youtube.html (100%) rename tpl/tplimpl/embedded/templates/{_default => }/robots.txt (100%) rename tpl/tplimpl/embedded/templates/{_default => }/rss.xml (100%) rename tpl/tplimpl/embedded/templates/{_default => }/sitemap.xml (100%) rename tpl/tplimpl/embedded/templates/{_default => }/sitemapindex.xml (100%) create mode 100644 tpl/tplimpl/legacy.go delete mode 100644 tpl/tplimpl/shortcodes.go delete mode 100644 tpl/tplimpl/shortcodes_test.go create mode 100644 tpl/tplimpl/subcategory_string.go delete mode 100644 tpl/tplimpl/template.go delete mode 100644 tpl/tplimpl/templateFuncster.go delete mode 100644 tpl/tplimpl/templateProvider.go delete mode 100644 tpl/tplimpl/template_ast_transformers_test.go delete mode 100644 tpl/tplimpl/template_errors.go rename tpl/{ => tplimpl}/template_info.go (73%) delete mode 100644 tpl/tplimpl/template_test.go create mode 100644 tpl/tplimpl/templatedescriptor.go create mode 100644 tpl/tplimpl/templatedescriptor_test.go create mode 100644 tpl/tplimpl/templates.go create mode 100644 tpl/tplimpl/templatestore.go create mode 100644 tpl/tplimpl/templatestore_integration_test.go rename tpl/tplimpl/{template_ast_transformers.go => templatetransform.go} (74%) create mode 100644 tpl/tplimplinit/tplimplinit.go diff --git a/commands/server.go b/commands/server.go index d3a72ec9a..11b43a80f 100644 --- a/commands/server.go +++ b/commands/server.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "io" + "maps" "net" "net/http" _ "net/http/pprof" @@ -48,6 +49,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/tpl/tplimpl" "github.com/gohugoio/hugo/common/types" "github.com/gohugoio/hugo/common/urls" @@ -57,7 +59,6 @@ import ( "github.com/gohugoio/hugo/hugolib" "github.com/gohugoio/hugo/hugolib/filesystems" "github.com/gohugoio/hugo/livereload" - "github.com/gohugoio/hugo/tpl" "github.com/gohugoio/hugo/transform" "github.com/gohugoio/hugo/transform/livereloadinject" "github.com/spf13/afero" @@ -65,7 +66,6 @@ import ( "github.com/spf13/fsync" "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" - "maps" ) var ( @@ -897,16 +897,16 @@ func (c *serverCommand) serve() error { // To allow the en user to change the error template while the server is running, we use // the freshest template we can provide. var ( - errTempl tpl.Template - templHandler tpl.TemplateHandler + errTempl *tplimpl.TemplInfo + templHandler *tplimpl.TemplateStore ) - getErrorTemplateAndHandler := func(h *hugolib.HugoSites) (tpl.Template, tpl.TemplateHandler) { + getErrorTemplateAndHandler := func(h *hugolib.HugoSites) (*tplimpl.TemplInfo, *tplimpl.TemplateStore) { if h == nil { return errTempl, templHandler } - templHandler := h.Tmpl() - errTempl, found := templHandler.Lookup("_server/error.html") - if !found { + templHandler := h.GetTemplateStore() + errTempl := templHandler.LookupByPath("/_server/error.html") + if errTempl == nil { panic("template server/error.html not found") } return errTempl, templHandler diff --git a/common/constants/constants.go b/common/constants/constants.go index b2cb4fb73..3f48f3e4a 100644 --- a/common/constants/constants.go +++ b/common/constants/constants.go @@ -23,6 +23,7 @@ const ( WarnFrontMatterParamsOverrides = "warning-frontmatter-params-overrides" WarnRenderShortcodesInHTML = "warning-rendershortcodes-in-html" WarnGoldmarkRawHTML = "warning-goldmark-raw-html" + WarnPartialSuperfluousPrefix = "warning-partial-superfluous-prefix" ) // Field/method names with special meaning. diff --git a/common/hstrings/strings.go b/common/hstrings/strings.go index 2df3486fc..1de38678f 100644 --- a/common/hstrings/strings.go +++ b/common/hstrings/strings.go @@ -16,11 +16,11 @@ package hstrings import ( "fmt" "regexp" + "slices" "strings" "sync" "github.com/gohugoio/hugo/compare" - "slices" ) var _ compare.Eqer = StringEqualFold("") @@ -128,7 +128,7 @@ func ToString(v any) (string, bool) { return "", false } -type Tuple struct { - First string - Second string -} +type ( + Strings2 [2]string + Strings3 [3]string +) diff --git a/common/maps/cache.go b/common/maps/cache.go index 72c07e7ca..7304a7a6f 100644 --- a/common/maps/cache.go +++ b/common/maps/cache.go @@ -69,6 +69,14 @@ func (c *Cache[K, T]) GetOrCreate(key K, create func() (T, error)) (T, error) { return v, nil } +// Contains returns whether the given key exists in the cache. +func (c *Cache[K, T]) Contains(key K) bool { + c.RLock() + _, found := c.m[key] + c.RUnlock() + return found +} + // InitAndGet initializes the cache if not already done and returns the value for the given key. // The init state will be reset on Reset or Drain. func (c *Cache[K, T]) InitAndGet(key K, init func(get func(key K) (T, bool), set func(key K, value T)) error) (T, error) { @@ -108,6 +116,17 @@ func (c *Cache[K, T]) Set(key K, value T) { c.Unlock() } +// SetIfAbsent sets the given key to the given value if the key does not already exist in the cache. +func (c *Cache[K, T]) SetIfAbsent(key K, value T) { + c.RLock() + if _, found := c.get(key); !found { + c.RUnlock() + c.Set(key, value) + } else { + c.RUnlock() + } +} + func (c *Cache[K, T]) set(key K, value T) { c.m[key] = value } diff --git a/common/maps/ordered.go b/common/maps/ordered.go index 08dd77919..0da9d239d 100644 --- a/common/maps/ordered.go +++ b/common/maps/ordered.go @@ -14,8 +14,9 @@ package maps import ( - "github.com/gohugoio/hugo/common/hashing" "slices" + + "github.com/gohugoio/hugo/common/hashing" ) // Ordered is a map that can be iterated in the order of insertion. @@ -57,6 +58,15 @@ func (m *Ordered[K, T]) Get(key K) (T, bool) { return value, found } +// Has returns whether the given key exists in the map. +func (m *Ordered[K, T]) Has(key K) bool { + if m == nil { + return false + } + _, found := m.values[key] + return found +} + // Delete deletes the value for the given key. func (m *Ordered[K, T]) Delete(key K) { if m == nil { diff --git a/common/paths/pathparser.go b/common/paths/pathparser.go index 94329fe7a..4b3feaa14 100644 --- a/common/paths/pathparser.go +++ b/common/paths/pathparser.go @@ -23,6 +23,11 @@ import ( "github.com/gohugoio/hugo/common/types" "github.com/gohugoio/hugo/hugofs/files" "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/resources/kinds" +) + +const ( + identifierBaseof = "baseof" ) // PathParser parses a path into a Path. @@ -33,6 +38,10 @@ type PathParser struct { // Reports whether the given language is disabled. IsLangDisabled func(string) bool + // IsOutputFormat reports whether the given name is a valid output format. + // The second argument is optional. + IsOutputFormat func(name, ext string) bool + // Reports whether the given ext is a content file. IsContentExt func(string) bool } @@ -83,13 +92,10 @@ func (pp *PathParser) Parse(c, s string) *Path { } func (pp *PathParser) newPath(component string) *Path { - return &Path{ - component: component, - posContainerLow: -1, - posContainerHigh: -1, - posSectionHigh: -1, - posIdentifierLanguage: -1, - } + p := &Path{} + p.reset() + p.component = component + return p } func (pp *PathParser) parse(component, s string) (*Path, error) { @@ -114,10 +120,91 @@ func (pp *PathParser) parse(component, s string) (*Path, error) { return p, nil } -func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) { - hasLang := pp.LanguageIndex != nil - hasLang = hasLang && (component == files.ComponentFolderContent || component == files.ComponentFolderLayouts) +func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot int) { + if p.posContainerHigh != -1 { + return + } + mayHaveLang := pp.LanguageIndex != nil + mayHaveLang = mayHaveLang && (component == files.ComponentFolderContent || component == files.ComponentFolderLayouts) + mayHaveOutputFormat := component == files.ComponentFolderLayouts + mayHaveKind := mayHaveOutputFormat + var found bool + var high int + if len(p.identifiers) > 0 { + high = lastDot + } else { + high = len(p.s) + } + id := types.LowHigh[string]{Low: i + 1, High: high} + sid := p.s[id.Low:id.High] + + if len(p.identifiers) == 0 { + // The first is always the extension. + p.identifiers = append(p.identifiers, id) + found = true + + // May also be the output format. + if mayHaveOutputFormat && pp.IsOutputFormat(sid, "") { + p.posIdentifierOutputFormat = 0 + } + } else { + + var langFound bool + + if mayHaveLang { + var disabled bool + _, langFound = pp.LanguageIndex[sid] + if !langFound { + disabled = pp.IsLangDisabled != nil && pp.IsLangDisabled(sid) + if disabled { + p.disabled = true + langFound = true + } + } + found = langFound + if langFound { + p.identifiers = append(p.identifiers, id) + p.posIdentifierLanguage = len(p.identifiers) - 1 + + } + } + + if !found && mayHaveOutputFormat { + // At this point we may already have resolved an output format, + // but we need to keep looking for a more specific one, e.g. amp before html. + // Use both name and extension to prevent + // 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 + } + } + + if !found && mayHaveKind { + if kinds.GetKindMain(sid) != "" { + found = true + p.identifiers = append(p.identifiers, id) + p.posIdentifierKind = len(p.identifiers) - 1 + } + } + + if !found && sid == identifierBaseof { + found = true + p.identifiers = append(p.identifiers, id) + p.posIdentifierBaseof = len(p.identifiers) - 1 + } + + if !found { + p.identifiers = append(p.identifiers, id) + p.identifiersUnknown = append(p.identifiersUnknown, len(p.identifiers)-1) + } + + } +} + +func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) { if runtime.GOOS == "windows" { s = path.Clean(filepath.ToSlash(s)) if s == "." { @@ -140,46 +227,21 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) { p.s = s slashCount := 0 + lastDot := 0 for i := len(s) - 1; i >= 0; i-- { c := s[i] switch c { case '.': - if p.posContainerHigh == -1 { - var high int - if len(p.identifiers) > 0 { - high = p.identifiers[len(p.identifiers)-1].Low - 1 - } else { - high = len(p.s) - } - id := types.LowHigh[string]{Low: i + 1, High: high} - if len(p.identifiers) == 0 { - p.identifiers = append(p.identifiers, id) - } else if len(p.identifiers) == 1 { - // Check for a valid language. - s := p.s[id.Low:id.High] - - if hasLang { - var disabled bool - _, langFound := pp.LanguageIndex[s] - if !langFound { - disabled = pp.IsLangDisabled != nil && pp.IsLangDisabled(s) - if disabled { - p.disabled = true - langFound = true - } - } - if langFound { - p.posIdentifierLanguage = 1 - p.identifiers = append(p.identifiers, id) - } - } - } - } + pp.parseIdentifier(component, s, p, i, lastDot) + lastDot = i case '/': slashCount++ if p.posContainerHigh == -1 { + if lastDot > 0 { + pp.parseIdentifier(component, s, p, i, lastDot) + } p.posContainerHigh = i + 1 } else if p.posContainerLow == -1 { p.posContainerLow = i + 1 @@ -194,22 +256,41 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) { isContentComponent := p.component == files.ComponentFolderContent || p.component == files.ComponentFolderArchetypes isContent := isContentComponent && pp.IsContentExt(p.Ext()) id := p.identifiers[len(p.identifiers)-1] - b := p.s[p.posContainerHigh : id.Low-1] - if isContent { - switch b { - case "index": - p.bundleType = PathTypeLeaf - case "_index": - p.bundleType = PathTypeBranch - default: - p.bundleType = PathTypeContentSingle - } - if slashCount == 2 && p.IsLeafBundle() { - p.posSectionHigh = 0 + if id.High > p.posContainerHigh { + b := p.s[p.posContainerHigh:id.High] + if isContent { + switch b { + case "index": + p.pathType = TypeLeaf + case "_index": + p.pathType = TypeBranch + default: + p.pathType = TypeContentSingle + } + + if slashCount == 2 && p.IsLeafBundle() { + p.posSectionHigh = 0 + } + } else if b == files.NameContentData && files.IsContentDataExt(p.Ext()) { + p.pathType = TypeContentData + } + } + + } + + if component == files.ComponentFolderLayouts { + if p.posIdentifierBaseof != -1 { + p.pathType = TypeBaseof + } else { + pth := p.Path() + if strings.Contains(pth, "/_shortcodes/") { + p.pathType = TypeShortcode + } else if strings.Contains(pth, "/_markup/") { + p.pathType = TypeMarkup + } else if strings.HasPrefix(pth, "/_partials/") { + p.pathType = TypePartial } - } else if b == files.NameContentData && files.IsContentDataExt(p.Ext()) { - p.bundleType = PathTypeContentData } } @@ -218,35 +299,44 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) { func ModifyPathBundleTypeResource(p *Path) { if p.IsContent() { - p.bundleType = PathTypeContentResource + p.pathType = TypeContentResource } else { - p.bundleType = PathTypeFile + p.pathType = TypeFile } } -type PathType int +//go:generate stringer -type Type + +type Type int const ( + // A generic resource, e.g. a JSON file. - PathTypeFile PathType = iota + TypeFile Type = iota // All below are content files. // A resource of a content type with front matter. - PathTypeContentResource + TypeContentResource // E.g. /blog/my-post.md - PathTypeContentSingle + TypeContentSingle // All below are bundled content files. // Leaf bundles, e.g. /blog/my-post/index.md - PathTypeLeaf + TypeLeaf // Branch bundles, e.g. /blog/_index.md - PathTypeBranch + TypeBranch // Content data file, _content.gotmpl. - PathTypeContentData + TypeContentData + + // Layout types. + TypeMarkup + TypeShortcode + TypePartial + TypeBaseof ) type Path struct { @@ -257,13 +347,17 @@ type Path struct { posContainerHigh int posSectionHigh int - component string - bundleType PathType + component string + pathType Type identifiers []types.LowHigh[string] - posIdentifierLanguage int - disabled bool + posIdentifierLanguage int + posIdentifierOutputFormat int + posIdentifierKind int + posIdentifierBaseof int + identifiersUnknown []int + disabled bool trimLeadingSlash bool @@ -293,9 +387,12 @@ func (p *Path) reset() { p.posContainerHigh = -1 p.posSectionHigh = -1 p.component = "" - p.bundleType = 0 + p.pathType = 0 p.identifiers = p.identifiers[:0] p.posIdentifierLanguage = -1 + p.posIdentifierOutputFormat = -1 + p.posIdentifierKind = -1 + p.posIdentifierBaseof = -1 p.disabled = false p.trimLeadingSlash = false p.unnormalized = nil @@ -316,6 +413,9 @@ func (p *Path) norm(s string) string { // IdentifierBase satisfies identity.Identity. func (p *Path) IdentifierBase() string { + if p.Component() == files.ComponentFolderLayouts { + return p.Path() + } return p.Base() } @@ -332,6 +432,13 @@ func (p *Path) Container() string { return p.norm(p.s[p.posContainerLow : p.posContainerHigh-1]) } +func (p *Path) String() string { + if p == nil { + return "" + } + return p.Path() +} + // ContainerDir returns the container directory for this path. // For content bundles this will be the parent directory. func (p *Path) ContainerDir() string { @@ -352,13 +459,13 @@ func (p *Path) Section() string { // IsContent returns true if the path is a content file (e.g. mypost.md). // Note that this will also return true for content files in a bundle. func (p *Path) IsContent() bool { - return p.BundleType() >= PathTypeContentResource + return p.Type() >= TypeContentResource && p.Type() <= TypeContentData } // isContentPage returns true if the path is a content file (e.g. mypost.md), // but nof if inside a leaf bundle. func (p *Path) isContentPage() bool { - return p.BundleType() >= PathTypeContentSingle + return p.Type() >= TypeContentSingle && p.Type() <= TypeContentData } // Name returns the last element of path. @@ -398,10 +505,26 @@ func (p *Path) BaseNameNoIdentifier() string { // NameNoIdentifier returns the last element of path without any identifier (e.g. no extension). func (p *Path) NameNoIdentifier() string { + lowHigh := p.nameLowHigh() + return p.s[lowHigh.Low:lowHigh.High] +} + +func (p *Path) nameLowHigh() types.LowHigh[string] { if len(p.identifiers) > 0 { - return p.s[p.posContainerHigh : p.identifiers[len(p.identifiers)-1].Low-1] + lastID := p.identifiers[len(p.identifiers)-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, + } + } + return types.LowHigh[string]{ + Low: p.posContainerHigh, + High: len(p.s), } - return p.s[p.posContainerHigh:] } // Dir returns all but the last element of path, typically the path's directory. @@ -421,6 +544,11 @@ func (p *Path) Path() (d string) { return p.norm(p.s) } +// PathNoLeadingSlash returns the full path without the leading slash. +func (p *Path) PathNoLeadingSlash() string { + return p.Path()[1:] +} + // Unnormalized returns the Path with the original case preserved. func (p *Path) Unnormalized() *Path { return p.unnormalized @@ -436,6 +564,28 @@ func (p *Path) PathNoIdentifier() string { return p.base(false, false) } +// 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 { + return p.norm(p.s) + } + i := p.identifierIndex(0) + + if j := p.posIdentifierOutputFormat; i == -1 || (j != -1 && j < i) { + i = j + } + if j := p.posIdentifierLanguage; i == -1 || (j != -1 && j < i) { + i = j + } + + if i == -1 { + return p.norm(p.s) + } + + id := p.identifiers[i] + return p.norm(p.s[:id.Low-1]) +} + // PathRel returns the path relative to the given owner. func (p *Path) PathRel(owner *Path) string { ob := owner.Base() @@ -462,6 +612,21 @@ func (p *Path) Base() string { return p.base(!p.isContentPage(), p.IsBundle()) } +// Used in template lookups. +// For pages with Type set, we treat that as the section. +func (p *Path) BaseReTyped(typ string) (d string) { + base := p.Base() + if typ == "" || p.Section() == typ { + return base + } + d = "/" + typ + if p.posSectionHigh != -1 { + d += base[p.posSectionHigh:] + } + d = p.norm(d) + return +} + // BaseNoLeadingSlash returns the base path without the leading slash. func (p *Path) BaseNoLeadingSlash() string { return p.Base()[1:] @@ -477,11 +642,12 @@ func (p *Path) base(preserveExt, isBundle bool) string { return p.norm(p.s) } - id := p.identifiers[len(p.identifiers)-1] - high := id.Low - 1 + var high int if isBundle { high = p.posContainerHigh - 1 + } else { + high = p.nameLowHigh().High } if high == 0 { @@ -493,7 +659,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.identifiers[0] return p.norm(p.s[:high] + p.s[id.Low-1:id.High]) } @@ -502,8 +668,16 @@ func (p *Path) Ext() string { return p.identifierAsString(0) } +func (p *Path) OutputFormat() string { + return p.identifierAsString(p.posIdentifierOutputFormat) +} + +func (p *Path) Kind() string { + return p.identifierAsString(p.posIdentifierKind) +} + func (p *Path) Lang() string { - return p.identifierAsString(1) + return p.identifierAsString(p.posIdentifierLanguage) } func (p *Path) Identifier(i int) string { @@ -522,28 +696,36 @@ func (p *Path) Identifiers() []string { return ids } -func (p *Path) BundleType() PathType { - return p.bundleType +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] + } + return ids +} + +func (p *Path) Type() Type { + return p.pathType } func (p *Path) IsBundle() bool { - return p.bundleType >= PathTypeLeaf + return p.pathType >= TypeLeaf && p.pathType <= TypeContentData } func (p *Path) IsBranchBundle() bool { - return p.bundleType == PathTypeBranch + return p.pathType == TypeBranch } func (p *Path) IsLeafBundle() bool { - return p.bundleType == PathTypeLeaf + return p.pathType == TypeLeaf } func (p *Path) IsContentData() bool { - return p.bundleType == PathTypeContentData + return p.pathType == TypeContentData } -func (p Path) ForBundleType(t PathType) *Path { - p.bundleType = t +func (p Path) ForBundleType(t Type) *Path { + p.pathType = t return &p } diff --git a/common/paths/pathparser_test.go b/common/paths/pathparser_test.go index 584b1a78a..fd1590c73 100644 --- a/common/paths/pathparser_test.go +++ b/common/paths/pathparser_test.go @@ -18,6 +18,7 @@ import ( "testing" "github.com/gohugoio/hugo/hugofs/files" + "github.com/gohugoio/hugo/resources/kinds" qt "github.com/frankban/quicktest" ) @@ -26,10 +27,18 @@ var testParser = &PathParser{ LanguageIndex: map[string]int{ "no": 0, "en": 1, + "fr": 2, }, IsContentExt: func(ext string) bool { return ext == "md" }, + IsOutputFormat: func(name, ext string) bool { + switch name { + case "html", "amp", "csv", "rss": + return true + } + return false + }, } func TestParse(t *testing.T) { @@ -105,17 +114,19 @@ func TestParse(t *testing.T) { "Basic Markdown file", "/a/b/c.md", func(c *qt.C, p *Path) { + c.Assert(p.Ext(), qt.Equals, "md") + c.Assert(p.Type(), qt.Equals, TypeContentSingle) c.Assert(p.IsContent(), qt.IsTrue) c.Assert(p.IsLeafBundle(), qt.IsFalse) c.Assert(p.Name(), qt.Equals, "c.md") c.Assert(p.Base(), qt.Equals, "/a/b/c") + c.Assert(p.BaseReTyped("foo"), qt.Equals, "/foo/b/c") c.Assert(p.Section(), qt.Equals, "a") c.Assert(p.BaseNameNoIdentifier(), qt.Equals, "c") c.Assert(p.Path(), qt.Equals, "/a/b/c.md") c.Assert(p.Dir(), qt.Equals, "/a/b") c.Assert(p.Container(), qt.Equals, "b") c.Assert(p.ContainerDir(), qt.Equals, "/a/b") - c.Assert(p.Ext(), qt.Equals, "md") }, }, { @@ -130,7 +141,7 @@ func TestParse(t *testing.T) { // Reclassify it as a content resource. ModifyPathBundleTypeResource(p) - c.Assert(p.BundleType(), qt.Equals, PathTypeContentResource) + c.Assert(p.Type(), qt.Equals, TypeContentResource) c.Assert(p.IsContent(), qt.IsTrue) c.Assert(p.Name(), qt.Equals, "b.md") c.Assert(p.Base(), qt.Equals, "/a/b.md") @@ -160,15 +171,16 @@ 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.a.b") + c.Assert(p.NameNoIdentifier(), qt.Equals, "b") c.Assert(p.NameNoLang(), qt.Equals, "b.a.b.txt") - c.Assert(p.Identifiers(), qt.DeepEquals, []string{"txt", "no"}) - 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.Identifiers(), qt.DeepEquals, []string{"txt", "no", "b", "a", "b"}) + 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.Path(), qt.Equals, "/a/b.a.b.no.txt") - c.Assert(p.PathNoLang(), qt.Equals, "/a/b.a.b.txt") + c.Assert(p.PathNoLang(), qt.Equals, "/a/b.txt") c.Assert(p.Ext(), qt.Equals, "txt") - c.Assert(p.PathNoIdentifier(), qt.Equals, "/a/b.a.b") + c.Assert(p.PathNoIdentifier(), qt.Equals, "/a/b") }, }, { @@ -176,6 +188,7 @@ func TestParse(t *testing.T) { "/_index.md", func(c *qt.C, p *Path) { c.Assert(p.Base(), qt.Equals, "/") + c.Assert(p.BaseReTyped("foo"), qt.Equals, "/foo") c.Assert(p.Path(), qt.Equals, "/_index.md") c.Assert(p.Container(), qt.Equals, "") c.Assert(p.ContainerDir(), qt.Equals, "/") @@ -186,13 +199,14 @@ func TestParse(t *testing.T) { "/a/index.md", func(c *qt.C, p *Path) { c.Assert(p.Base(), qt.Equals, "/a") + c.Assert(p.BaseReTyped("foo"), qt.Equals, "/foo/a") c.Assert(p.BaseNameNoIdentifier(), qt.Equals, "a") c.Assert(p.Container(), qt.Equals, "a") c.Assert(p.Container(), qt.Equals, "a") 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"}) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "index"}) c.Assert(p.IsBranchBundle(), qt.IsFalse) c.Assert(p.IsBundle(), qt.IsTrue) c.Assert(p.IsLeafBundle(), qt.IsTrue) @@ -209,11 +223,12 @@ func TestParse(t *testing.T) { func(c *qt.C, p *Path) { c.Assert(p.Base(), qt.Equals, "/a/b") c.Assert(p.BaseNameNoIdentifier(), qt.Equals, "b") + c.Assert(p.BaseReTyped("foo"), qt.Equals, "/foo/b") c.Assert(p.Container(), qt.Equals, "b") 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"}) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "no", "index"}) c.Assert(p.IsBranchBundle(), qt.IsFalse) c.Assert(p.IsBundle(), qt.IsTrue) c.Assert(p.IsLeafBundle(), qt.IsTrue) @@ -235,7 +250,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"}) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "no", "_index"}) c.Assert(p.IsBranchBundle(), qt.IsTrue) c.Assert(p.IsBundle(), qt.IsTrue) c.Assert(p.IsLeafBundle(), qt.IsFalse) @@ -274,7 +289,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"}) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"txt", "no", "index"}) c.Assert(p.IsLeafBundle(), qt.IsFalse) c.Assert(p.PathNoIdentifier(), qt.Equals, "/a/b/index") }, @@ -357,11 +372,140 @@ func TestParse(t *testing.T) { } for _, test := range tests { c.Run(test.name, func(c *qt.C) { + if test.name != "Basic Markdown file" { + // return + } test.assert(c, testParser.Parse(files.ComponentFolderContent, test.path)) }) } } +func TestParseLayouts(t *testing.T) { + c := qt.New(t) + + tests := []struct { + name string + path string + assert func(c *qt.C, p *Path) + }{ + { + "Basic", + "/list.html", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/list.html") + c.Assert(p.OutputFormat(), qt.Equals, "html") + }, + }, + { + "Lang", + "/list.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "no", "list"}) + c.Assert(p.Base(), qt.Equals, "/list.html") + c.Assert(p.Lang(), qt.Equals, "no") + }, + }, + { + "Lang and output format", + "/list.no.amp.not.html", + func(c *qt.C, p *Path) { + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "not", "amp", "no", "list"}) + c.Assert(p.OutputFormat(), qt.Equals, "amp") + c.Assert(p.Ext(), qt.Equals, "html") + c.Assert(p.Lang(), qt.Equals, "no") + c.Assert(p.Base(), qt.Equals, "/list.html") + }, + }, + { + "Term", + "/term.html", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/term.html") + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "term"}) + c.Assert(p.PathNoIdentifier(), qt.Equals, "/term") + c.Assert(p.PathBeforeLangAndOutputFormatAndExt(), qt.Equals, "/term") + c.Assert(p.Lang(), qt.Equals, "") + c.Assert(p.Kind(), qt.Equals, "term") + c.Assert(p.OutputFormat(), qt.Equals, "html") + }, + }, + { + "Sub dir", + "/pages/home.html", + func(c *qt.C, p *Path) { + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "home"}) + c.Assert(p.Lang(), qt.Equals, "") + c.Assert(p.Kind(), qt.Equals, "home") + c.Assert(p.OutputFormat(), qt.Equals, "html") + c.Assert(p.Dir(), qt.Equals, "/pages") + }, + }, + { + "Baseof", + "/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.Kind(), qt.Equals, kinds.KindSection) + c.Assert(p.Lang(), qt.Equals, "fr") + c.Assert(p.OutputFormat(), qt.Equals, "amp") + c.Assert(p.Dir(), qt.Equals, "/pages") + c.Assert(p.NameNoIdentifier(), qt.Equals, "baseof") + c.Assert(p.Type(), qt.Equals, TypeBaseof) + c.Assert(p.IdentifierBase(), qt.Equals, "/pages/baseof.list.section.fr.amp.html") + }, + }, + { + "Markup", + "/_markup/render-link.html", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypeMarkup) + }, + }, + { + "Markup nested", + "/foo/_markup/render-link.html", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypeMarkup) + }, + }, + { + "Shortcode", + "/_shortcodes/myshortcode.html", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypeShortcode) + }, + }, + { + "Shortcode nested", + "/foo/_shortcodes/myshortcode.html", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypeShortcode) + }, + }, + { + "Shortcode nested sub", + "/foo/_shortcodes/foo/myshortcode.html", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypeShortcode) + }, + }, + { + "Partials", + "/_partials/foo.bar", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypePartial) + }, + }, + } + + for _, test := range tests { + c.Run(test.name, func(c *qt.C) { + test.assert(c, testParser.Parse(files.ComponentFolderLayouts, test.path)) + }) + } +} + func TestHasExt(t *testing.T) { c := qt.New(t) diff --git a/common/paths/pathtype_string.go b/common/paths/pathtype_string.go deleted file mode 100644 index 7a99f8a03..000000000 --- a/common/paths/pathtype_string.go +++ /dev/null @@ -1,27 +0,0 @@ -// Code generated by "stringer -type=PathType"; DO NOT EDIT. - -package paths - -import "strconv" - -func _() { - // An "invalid array index" compiler error signifies that the constant values have changed. - // Re-run the stringer command to generate them again. - var x [1]struct{} - _ = x[PathTypeFile-0] - _ = x[PathTypeContentResource-1] - _ = x[PathTypeContentSingle-2] - _ = x[PathTypeLeaf-3] - _ = x[PathTypeBranch-4] -} - -const _PathType_name = "PathTypeFilePathTypeContentResourcePathTypeContentSinglePathTypeLeafPathTypeBranch" - -var _PathType_index = [...]uint8{0, 12, 35, 56, 68, 82} - -func (i PathType) String() string { - if i < 0 || i >= PathType(len(_PathType_index)-1) { - return "PathType(" + strconv.FormatInt(int64(i), 10) + ")" - } - return _PathType_name[_PathType_index[i]:_PathType_index[i+1]] -} diff --git a/common/paths/type_string.go b/common/paths/type_string.go new file mode 100644 index 000000000..08fbcc835 --- /dev/null +++ b/common/paths/type_string.go @@ -0,0 +1,32 @@ +// Code generated by "stringer -type Type"; DO NOT EDIT. + +package paths + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[TypeFile-0] + _ = x[TypeContentResource-1] + _ = x[TypeContentSingle-2] + _ = x[TypeLeaf-3] + _ = x[TypeBranch-4] + _ = x[TypeContentData-5] + _ = x[TypeMarkup-6] + _ = x[TypeShortcode-7] + _ = x[TypePartial-8] + _ = x[TypeBaseof-9] +} + +const _Type_name = "TypeFileTypeContentResourceTypeContentSingleTypeLeafTypeBranchTypeContentDataTypeMarkupTypeShortcodeTypePartialTypeBaseof" + +var _Type_index = [...]uint8{0, 8, 27, 44, 52, 62, 77, 87, 100, 111, 121} + +func (i Type) String() string { + if i < 0 || i >= Type(len(_Type_index)-1) { + return "Type(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Type_name[_Type_index[i]:_Type_index[i+1]] +} diff --git a/common/types/types.go b/common/types/types.go index 082c058ff..7e94c1eea 100644 --- a/common/types/types.go +++ b/common/types/types.go @@ -28,6 +28,16 @@ type RLocker interface { RUnlock() } +type Locker interface { + Lock() + Unlock() +} + +type RWLocker interface { + RLocker + Locker +} + // KeyValue is a interface{} tuple. type KeyValue struct { Key any diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go index 758d7d986..e73153a94 100644 --- a/config/allconfig/allconfig.go +++ b/config/allconfig/allconfig.go @@ -849,7 +849,24 @@ func (c *Configs) Init() error { c.Languages = languages c.LanguagesDefaultFirst = languagesDefaultFirst - c.ContentPathParser = &paths.PathParser{LanguageIndex: languagesDefaultFirst.AsIndexSet(), IsLangDisabled: c.Base.IsLangDisabled, IsContentExt: c.Base.ContentTypes.Config.IsContentSuffix} + c.ContentPathParser = &paths.PathParser{ + LanguageIndex: languagesDefaultFirst.AsIndexSet(), + IsLangDisabled: c.Base.IsLangDisabled, + IsContentExt: c.Base.ContentTypes.Config.IsContentSuffix, + IsOutputFormat: func(name, ext string) bool { + if name == "" { + return false + } + + if of, ok := c.Base.OutputFormats.Config.GetByName(name); ok { + if ext != "" && !of.MediaType.HasSuffix(ext) { + return false + } + return true + } + return false + }, + } c.configLangs = make([]config.AllProvider, len(c.Languages)) for i, l := range c.LanguagesDefaultFirst { diff --git a/create/content.go b/create/content.go index db07720ad..a4661c1ba 100644 --- a/create/content.go +++ b/create/content.go @@ -291,7 +291,7 @@ func (b *contentBuilder) applyArcheType(contentFilename string, archetypeFi hugo func (b *contentBuilder) mapArcheTypeDir() error { var m archetypeMap - seen := map[hstrings.Tuple]bool{} + seen := map[hstrings.Strings2]bool{} walkFn := func(path string, fim hugofs.FileMetaInfo) error { if fim.IsDir() { @@ -301,7 +301,7 @@ func (b *contentBuilder) mapArcheTypeDir() error { pi := fim.Meta().PathInfo if pi.IsContent() { - pathLang := hstrings.Tuple{First: pi.PathNoIdentifier(), Second: fim.Meta().Lang} + pathLang := hstrings.Strings2{pi.PathBeforeLangAndOutputFormatAndExt(), fim.Meta().Lang} if seen[pathLang] { // Duplicate content file, e.g. page.md and page.html. // In the regular build, we will filter out the duplicates, but diff --git a/deps/deps.go b/deps/deps.go index 34c41012c..d0d6d95fc 100644 --- a/deps/deps.go +++ b/deps/deps.go @@ -29,6 +29,7 @@ import ( "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/resources/page" "github.com/gohugoio/hugo/resources/postpub" + "github.com/gohugoio/hugo/tpl/tplimpl" "github.com/gohugoio/hugo/metrics" "github.com/gohugoio/hugo/resources" @@ -46,12 +47,6 @@ type Deps struct { ExecHelper *hexec.Exec - // The templates to use. This will usually implement the full tpl.TemplateManager. - tmplHandlers *tpl.TemplateHandlers - - // The template funcs. - TmplFuncMap map[string]any - // The file systems to use. Fs *hugofs.Fs `json:"-"` @@ -79,7 +74,8 @@ type Deps struct { // The site building. Site page.Site - TemplateProvider ResourceProvider + TemplateStore *tplimpl.TemplateStore + // Used in tests OverloadedTemplateFuncs map[string]any @@ -102,6 +98,9 @@ type Deps struct { // This is common/global for all sites. BuildState *BuildState + // Misc counters. + Counters *Counters + // Holds RPC dispatchers for Katex etc. // TODO(bep) rethink this re. a plugin setup, but this will have to do for now. WasmDispatchers *warpc.Dispatchers @@ -109,9 +108,6 @@ type Deps struct { // The JS batcher client. JSBatcherClient js.BatcherClient - // The JS batcher client. - // JSBatcherClient *esbuild.BatcherClient - isClosed bool *globalErrHandler @@ -130,8 +126,8 @@ func (d Deps) Clone(s page.Site, conf config.AllProvider) (*Deps, error) { return &d, nil } -func (d *Deps) SetTempl(t *tpl.TemplateHandlers) { - d.tmplHandlers = t +func (d *Deps) GetTemplateStore() *tplimpl.TemplateStore { + return d.TemplateStore } func (d *Deps) Init() error { @@ -153,10 +149,12 @@ func (d *Deps) Init() error { logger: d.Log, } } - if d.BuildState == nil { d.BuildState = &BuildState{} } + if d.Counters == nil { + d.Counters = &Counters{} + } if d.BuildState.DeferredExecutions == nil { if d.BuildState.DeferredExecutionsGroupedByRenderingContext == nil { d.BuildState.DeferredExecutionsGroupedByRenderingContext = make(map[tpl.RenderingContext]*DeferredExecutions) @@ -263,22 +261,17 @@ func (d *Deps) Init() error { return nil } +// TODO(bep) rework this to get it in line with how we manage templates. func (d *Deps) Compile(prototype *Deps) error { var err error if prototype == nil { - if err = d.TemplateProvider.NewResource(d); err != nil { - return err - } + if err = d.TranslationProvider.NewResource(d); err != nil { return err } return nil } - if err = d.TemplateProvider.CloneResource(d, prototype); err != nil { - return err - } - if err = d.TranslationProvider.CloneResource(d, prototype); err != nil { return err } @@ -378,14 +371,6 @@ type ResourceProvider interface { CloneResource(dst, src *Deps) error } -func (d *Deps) Tmpl() tpl.TemplateHandler { - return d.tmplHandlers.Tmpl -} - -func (d *Deps) TextTmpl() tpl.TemplateParseFinder { - return d.tmplHandlers.TxtTmpl -} - func (d *Deps) Close() error { if d.isClosed { return nil @@ -454,6 +439,12 @@ type BuildState struct { DeferredExecutionsGroupedByRenderingContext map[tpl.RenderingContext]*DeferredExecutions } +// Misc counters. +type Counters struct { + // Counter for the math.Counter function. + MathCounter atomic.Uint64 +} + type DeferredExecutions struct { // A set of filenames in /public that // contains a post-processing prefix. diff --git a/hugolib/alias.go b/hugolib/alias.go index 08d57a8bc..0d182042a 100644 --- a/hugolib/alias.go +++ b/hugolib/alias.go @@ -29,16 +29,17 @@ import ( "github.com/gohugoio/hugo/publisher" "github.com/gohugoio/hugo/resources/page" "github.com/gohugoio/hugo/tpl" + "github.com/gohugoio/hugo/tpl/tplimpl" ) type aliasHandler struct { - t tpl.TemplateHandler + ts *tplimpl.TemplateStore log loggers.Logger allowRoot bool } -func newAliasHandler(t tpl.TemplateHandler, l loggers.Logger, allowRoot bool) aliasHandler { - return aliasHandler{t, l, allowRoot} +func newAliasHandler(ts *tplimpl.TemplateStore, l loggers.Logger, allowRoot bool) aliasHandler { + return aliasHandler{ts, l, allowRoot} } type aliasPage struct { @@ -47,16 +48,24 @@ type aliasPage struct { } func (a aliasHandler) renderAlias(permalink string, p page.Page) (io.Reader, error) { - var templ tpl.Template - var found bool + var templateDesc tplimpl.TemplateDescriptor + var base string = "" + if ps, ok := p.(*pageState); ok { + base, templateDesc = ps.getTemplateBasePathAndDescriptor() + } + templateDesc.Layout = "" + templateDesc.Kind = "" + templateDesc.OutputFormat = output.AliasHTMLFormat.Name - templ, found = a.t.Lookup("alias.html") - if !found { - // TODO(bep) consolidate - templ, found = a.t.Lookup("_internal/alias.html") - if !found { - return nil, errors.New("no alias template found") - } + q := tplimpl.TemplateQuery{ + Path: base, + Category: tplimpl.CategoryLayout, + Desc: templateDesc, + } + + t := a.ts.LookupPagesLayout(q) + if t == nil { + return nil, errors.New("no alias template found") } data := aliasPage{ @@ -67,7 +76,7 @@ func (a aliasHandler) renderAlias(permalink string, p page.Page) (io.Reader, err ctx := tpl.Context.Page.Set(context.Background(), p) buffer := new(bytes.Buffer) - err := a.t.ExecuteWithContext(ctx, templ, buffer, data) + err := a.ts.ExecuteWithContext(ctx, t, buffer, data) if err != nil { return nil, err } @@ -79,7 +88,7 @@ func (s *Site) writeDestAlias(path, permalink string, outputFormat output.Format } func (s *Site) publishDestAlias(allowRoot bool, path, permalink string, outputFormat output.Format, p page.Page) (err error) { - handler := newAliasHandler(s.Tmpl(), s.Log, allowRoot) + handler := newAliasHandler(s.GetTemplateStore(), s.Log, allowRoot) targetPath, err := handler.targetPathAlias(path) if err != nil { diff --git a/hugolib/alias_test.go b/hugolib/alias_test.go index c017050c6..1ba7bb16c 100644 --- a/hugolib/alias_test.go +++ b/hugolib/alias_test.go @@ -107,13 +107,26 @@ func TestAliasMultipleOutputFormats(t *testing.T) { func TestAliasTemplate(t *testing.T) { t.Parallel() - b := newTestSitesBuilder(t) - b.WithSimpleConfigFile().WithContent("page.md", pageWithAlias).WithTemplatesAdded("alias.html", aliasTemplate) + files := ` +-- hugo.toml -- +baseURL = "http://example.com" +-- layouts/single.html -- +Single. +-- layouts/home.html -- +Home. +-- layouts/alias.html -- +ALIASTEMPLATE +-- content/page.md -- +--- +title: "Page" +aliases: ["/foo/bar/"] +--- +` - b.CreateSites().Build(BuildCfg{}) + b := Test(t, files) // the real page - b.AssertFileContent("public/page/index.html", "For some moments the old man") + b.AssertFileContent("public/page/index.html", "Single.") // the alias redirector b.AssertFileContent("public/foo/bar/index.html", "ALIASTEMPLATE") } diff --git a/hugolib/content_factory.go b/hugolib/content_factory.go index e6b0fb506..109e988b1 100644 --- a/hugolib/content_factory.go +++ b/hugolib/content_factory.go @@ -72,12 +72,12 @@ func (f ContentFactory) ApplyArchetypeTemplate(w io.Writer, p page.Page, archety templateSource = f.shortcodeReplacerPre.Replace(templateSource) - templ, err := ps.s.TextTmpl().Parse("archetype.md", string(templateSource)) + templ, err := ps.s.TemplateStore.TextParse("archetype.md", templateSource) if err != nil { return fmt.Errorf("failed to parse archetype template: %s: %w", err, err) } - result, err := executeToString(context.Background(), ps.s.Tmpl(), templ, d) + result, err := executeToString(context.Background(), ps.s.GetTemplateStore(), templ, d) if err != nil { return fmt.Errorf("failed to execute archetype template: %s: %w", err, err) } diff --git a/hugolib/content_map.go b/hugolib/content_map.go index 55c96c9a0..56a602f54 100644 --- a/hugolib/content_map.go +++ b/hugolib/content_map.go @@ -264,8 +264,8 @@ func (m *pageMap) AddFi(fi hugofs.FileMetaInfo, buildConfig *BuildCfg) (pageCoun meta := fi.Meta() pi := meta.PathInfo - switch pi.BundleType() { - case paths.PathTypeFile, paths.PathTypeContentResource: + switch pi.Type() { + case paths.TypeFile, paths.TypeContentResource: m.s.Log.Trace(logg.StringFunc( func() string { return fmt.Sprintf("insert resource: %q", fi.Meta().Filename) @@ -275,7 +275,7 @@ func (m *pageMap) AddFi(fi hugofs.FileMetaInfo, buildConfig *BuildCfg) (pageCoun addErr = err return } - case paths.PathTypeContentData: + case paths.TypeContentData: pc, rc, err := m.addPagesFromGoTmplFi(fi, buildConfig) pageCount += pc resourceCount += rc @@ -349,8 +349,7 @@ func (m *pageMap) addPagesFromGoTmplFi(fi hugofs.FileMetaInfo, buildConfig *Buil DepsFromSite: func(s page.Site) pagesfromdata.PagesFromTemplateDeps { ss := s.(*Site) return pagesfromdata.PagesFromTemplateDeps{ - TmplFinder: ss.TextTmpl(), - TmplExec: ss.Tmpl(), + TemplateStore: ss.GetTemplateStore(), } }, DependencyManager: s.Conf.NewIdentityManager("pagesfromdata"), diff --git a/hugolib/content_map_page.go b/hugolib/content_map_page.go index fcc650c39..b501cd9ea 100644 --- a/hugolib/content_map_page.go +++ b/hugolib/content_map_page.go @@ -180,7 +180,7 @@ func (t *pageTrees) collectAndMarkStaleIdentities(p *paths.Path) []identity.Iden if p.Component() == files.ComponentFolderContent { // It may also be a bundled content resource. - key := p.ForBundleType(paths.PathTypeContentResource).Base() + key := p.ForBundleType(paths.TypeContentResource).Base() tree = t.treeResources nCount = 0 tree.ForEeachInDimension(key, doctree.DimensionLanguage.Index(), @@ -1304,14 +1304,14 @@ func (h *HugoSites) resolveAndResetDependententPageOutputs(ctx context.Context, checkedCounter atomic.Int64 ) - resetPo := func(po *pageOutput, r identity.FinderResult) { - if po.pco != nil { + resetPo := func(po *pageOutput, rebuildContent bool, r identity.FinderResult) { + if rebuildContent && po.pco != nil { po.pco.Reset() // Will invalidate content cache. } po.renderState = 0 po.p.resourcesPublishInit = &sync.Once{} - if r == identity.FinderFoundOneOfMany || po.f.Name == output.HTTPStatusHTMLFormat.Name { + if r == identity.FinderFoundOneOfMany || po.f.Name == output.HTTPStatus404HTMLFormat.Name { // Will force a re-render even in fast render mode. po.renderOnce = false } @@ -1323,7 +1323,7 @@ func (h *HugoSites) resolveAndResetDependententPageOutputs(ctx context.Context, } // This can be a relativeley expensive operations, so we do it in parallel. - g := rungroup.Run[*pageState](ctx, rungroup.Config[*pageState]{ + g := rungroup.Run(ctx, rungroup.Config[*pageState]{ NumWorkers: h.numWorkers, Handle: func(ctx context.Context, p *pageState) error { if !p.isRenderedAny() { @@ -1335,7 +1335,8 @@ func (h *HugoSites) resolveAndResetDependententPageOutputs(ctx context.Context, checkedCounter.Add(1) if r := depsFinder.Contains(id, p.dependencyManager, 2); r > identity.FinderFoundOneOfManyRepetition { for _, po := range p.pageOutputs { - resetPo(po, r) + // Note that p.dependencyManager is used when rendering content, so reset that. + resetPo(po, true, r) } // Done. return nil @@ -1351,7 +1352,8 @@ func (h *HugoSites) resolveAndResetDependententPageOutputs(ctx context.Context, for _, id := range changes { checkedCounter.Add(1) if r := depsFinder.Contains(id, po.dependencyManagerOutput, 50); r > identity.FinderFoundOneOfManyRepetition { - resetPo(po, r) + // Note that dependencyManagerOutput is not used when rendering content, so don't reset that. + resetPo(po, false, r) continue OUTPUTS } } @@ -1954,7 +1956,7 @@ func (sa *sitePagesAssembler) addStandalonePages() error { tree.InsertIntoValuesDimension(key, p) } - addStandalone("/404", kinds.KindStatus404, output.HTTPStatusHTMLFormat) + addStandalone("/404", kinds.KindStatus404, output.HTTPStatus404HTMLFormat) if s.conf.EnableRobotsTXT { if m.i == 0 || s.Conf.IsMultihost() { diff --git a/hugolib/content_map_test.go b/hugolib/content_map_test.go index 7202840f3..aed2a7f13 100644 --- a/hugolib/content_map_test.go +++ b/hugolib/content_map_test.go @@ -242,8 +242,13 @@ Data en } func TestBundleMultipleContentPageWithSamePath(t *testing.T) { + t.Parallel() + files := ` -- hugo.toml -- +printPathWarnings = true +-- layouts/all.html -- +All. -- content/bundle/index.md -- --- title: "Bundle md" @@ -273,14 +278,18 @@ Bundle: {{ $bundle.Title }}|{{ $bundle.Params.foo }}|{{ $bundle.File.Filename }} P1: {{ $p1.Title }}|{{ $p1.Params.foo }}|{{ $p1.File.Filename }}| ` - b := Test(t, files) + for range 3 { + b := Test(t, files, TestOptWarn()) - // There's multiple content files sharing the same logical path and language. - // This is a little arbitrary, but we have to pick one and prefer the Markdown version. - b.AssertFileContent("public/index.html", - filepath.FromSlash("Bundle: Bundle md|md|/content/bundle/index.md|"), - filepath.FromSlash("P1: P1 md|md|/content/p1.md|"), - ) + b.AssertLogContains("WARN Duplicate content path: \"/p1\"") + + // There's multiple content files sharing the same logical path and language. + // This is a little arbitrary, but we have to pick one and prefer the Markdown version. + b.AssertFileContent("public/index.html", + filepath.FromSlash("Bundle: Bundle md|md|/content/bundle/index.md|"), + filepath.FromSlash("P1: P1 md|md|/content/p1.md|"), + ) + } } // Issue #11944 diff --git a/hugolib/content_render_hooks_test.go b/hugolib/content_render_hooks_test.go index 9df8d2e2e..56ae0a052 100644 --- a/hugolib/content_render_hooks_test.go +++ b/hugolib/content_render_hooks_test.go @@ -17,6 +17,8 @@ import ( "fmt" "strings" "testing" + + qt "github.com/frankban/quicktest" ) func TestRenderHooksRSS(t *testing.T) { @@ -129,6 +131,7 @@ P1:

P1. I’m an inline-style link

Heading in p1

Heading in p2

`) + b.AssertFileContent("public/index.xml", ` P2:

P2. xml-link: https://www.bep.is|

P3:

P3. xml-link: https://www.example.org|

@@ -378,3 +381,93 @@ Content: {{ .Content}}| "|Text: First line.\nSecond line.||\n", ) } + +func TestContentOutputReuseRenderHooksAndShortcodesHTMLOnly(t *testing.T) { + files := ` +-- hugo.toml -- +-- layouts/index.html -- +HTML: {{ .Title }}|{{ .Content }}| +-- layouts/index.xml -- +XML: {{ .Title }}|{{ .Content }}| +-- layouts/_markup/render-heading.html -- +Render heading. +-- layouts/shortcodes/myshortcode.html -- +My shortcode. +-- content/_index.md -- +--- +title: "Home" +--- +## Heading + +{{< myshortcode >}} +` + b := Test(t, files) + + s := b.H.Sites[0] + b.Assert(s.home.pageOutputTemplateVariationsState.Load(), qt.Equals, uint32(1)) + b.AssertFileContent("public/index.html", "HTML: Home|Render heading.\nMy shortcode.\n|") + b.AssertFileContent("public/index.xml", "XML: Home|Render heading.\nMy shortcode.\n|") +} + +func TestContentOutputNoReuseRenderHooksInBothHTMLAnXML(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +-- layouts/index.html -- +HTML: {{ .Title }}|{{ .Content }}| +-- layouts/index.xml -- +XML: {{ .Title }}|{{ .Content }}| +-- layouts/_markup/render-heading.html -- +Render heading. +-- layouts/_markup/render-heading.xml -- +Render heading XML. +-- content/_index.md -- +--- +title: "Home" +--- +## Heading + + +` + b := Test(t, files) + + s := b.H.Sites[0] + b.Assert(s.home.pageOutputTemplateVariationsState.Load() > 1, qt.IsTrue) + b.AssertFileContentExact("public/index.xml", "XML: Home|Render heading XML.|") + b.AssertFileContentExact("public/index.html", "HTML: Home|Render heading.|") +} + +func TestContentOutputNoReuseShortcodesInBothHTMLAnXML(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +-- layouts/index.html -- +HTML: {{ .Title }}|{{ .Content }}| +-- layouts/index.xml -- +XML: {{ .Title }}|{{ .Content }}| +-- layouts/_markup/render-heading.html -- +Render heading. + +-- layouts/shortcodes/myshortcode.html -- +My shortcode HTML. +-- layouts/shortcodes/myshortcode.xml -- +My shortcode XML. +-- content/_index.md -- +--- +title: "Home" +--- +## Heading + +{{< myshortcode >}} + + +` + 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] + b.Assert(s.home.pageOutputTemplateVariationsState.Load() > 1, qt.IsTrue) +} diff --git a/hugolib/doctree/simpletree.go b/hugolib/doctree/simpletree.go index 2193c08f6..b79ef3f2f 100644 --- a/hugolib/doctree/simpletree.go +++ b/hugolib/doctree/simpletree.go @@ -14,35 +14,46 @@ package doctree import ( + "iter" "sync" radix "github.com/armon/go-radix" ) -// Tree is a radix tree that holds T. +// Tree is a non thread safe radix tree that holds T. type Tree[T any] interface { + TreeCommon[T] + WalkPrefix(s string, f func(s string, v T) (bool, error)) error + WalkPath(s string, f func(s string, v T) (bool, error)) error + All() iter.Seq2[string, T] +} + +// TreeThreadSafe is a thread safe radix tree that holds T. +type TreeThreadSafe[T any] interface { + TreeCommon[T] + WalkPrefix(lockType LockType, s string, f func(s string, v T) (bool, error)) error + WalkPath(lockType LockType, s string, f func(s string, v T) (bool, error)) error + All(lockType LockType) iter.Seq2[string, T] +} + +type TreeCommon[T any] interface { Get(s string) T LongestPrefix(s string) (string, T) Insert(s string, v T) T - WalkPrefix(lockType LockType, s string, f func(s string, v T) (bool, error)) error } -// NewSimpleTree creates a new SimpleTree. -func NewSimpleTree[T comparable]() *SimpleTree[T] { +func NewSimpleTree[T any]() *SimpleTree[T] { return &SimpleTree[T]{tree: radix.New()} } -// SimpleTree is a thread safe radix tree that holds T. -type SimpleTree[T comparable] struct { - mu sync.RWMutex +// SimpleTree is a radix tree that holds T. +// This tree is not thread safe. +type SimpleTree[T any] struct { tree *radix.Tree zero T } func (tree *SimpleTree[T]) Get(s string) T { - tree.mu.RLock() - defer tree.mu.RUnlock() - if v, ok := tree.tree.Get(s); ok { return v.(T) } @@ -50,9 +61,6 @@ func (tree *SimpleTree[T]) Get(s string) T { } func (tree *SimpleTree[T]) LongestPrefix(s string) (string, T) { - tree.mu.RLock() - defer tree.mu.RUnlock() - if s, v, ok := tree.tree.LongestPrefix(s); ok { return s, v.(T) } @@ -60,17 +68,121 @@ func (tree *SimpleTree[T]) LongestPrefix(s string) (string, T) { } func (tree *SimpleTree[T]) Insert(s string, v T) T { + tree.tree.Insert(s, v) + return v +} + +func (tree *SimpleTree[T]) Walk(f func(s string, v T) (bool, error)) error { + var err error + tree.tree.Walk(func(s string, v any) bool { + var b bool + b, err = f(s, v.(T)) + if err != nil { + return true + } + return b + }) + return err +} + +func (tree *SimpleTree[T]) WalkPrefix(s string, f func(s string, v T) (bool, error)) error { + var err error + tree.tree.WalkPrefix(s, func(s string, v any) bool { + var b bool + b, err = f(s, v.(T)) + if err != nil { + return true + } + return b + }) + + return err +} + +func (tree *SimpleTree[T]) WalkPath(s string, f func(s string, v T) (bool, error)) error { + var err error + tree.tree.WalkPath(s, func(s string, v any) bool { + var b bool + b, err = f(s, v.(T)) + if err != nil { + return true + } + return b + }) + return err +} + +func (tree *SimpleTree[T]) All() iter.Seq2[string, T] { + return func(yield func(s string, v T) bool) { + tree.tree.Walk(func(s string, v any) bool { + return !yield(s, v.(T)) + }) + } +} + +// NewSimpleThreadSafeTree creates a new SimpleTree. +func NewSimpleThreadSafeTree[T any]() *SimpleThreadSafeTree[T] { + return &SimpleThreadSafeTree[T]{tree: radix.New(), mu: new(sync.RWMutex)} +} + +// SimpleThreadSafeTree is a thread safe radix tree that holds T. +type SimpleThreadSafeTree[T any] struct { + mu *sync.RWMutex + noLock bool + tree *radix.Tree + zero T +} + +var noopFunc = func() {} + +func (tree *SimpleThreadSafeTree[T]) readLock() func() { + if tree.noLock { + return noopFunc + } + tree.mu.RLock() + return tree.mu.RUnlock +} + +func (tree *SimpleThreadSafeTree[T]) writeLock() func() { + if tree.noLock { + return noopFunc + } tree.mu.Lock() - defer tree.mu.Unlock() + return tree.mu.Unlock +} + +func (tree *SimpleThreadSafeTree[T]) Get(s string) T { + unlock := tree.readLock() + defer unlock() + + if v, ok := tree.tree.Get(s); ok { + return v.(T) + } + return tree.zero +} + +func (tree *SimpleThreadSafeTree[T]) LongestPrefix(s string) (string, T) { + unlock := tree.readLock() + defer unlock() + + if s, v, ok := tree.tree.LongestPrefix(s); ok { + return s, v.(T) + } + return "", tree.zero +} + +func (tree *SimpleThreadSafeTree[T]) Insert(s string, v T) T { + unlock := tree.writeLock() + defer unlock() tree.tree.Insert(s, v) return v } -func (tree *SimpleTree[T]) Lock(lockType LockType) func() { +func (tree *SimpleThreadSafeTree[T]) Lock(lockType LockType) func() { switch lockType { case LockTypeNone: - return func() {} + return noopFunc case LockTypeRead: tree.mu.RLock() return tree.mu.RUnlock @@ -78,10 +190,16 @@ func (tree *SimpleTree[T]) Lock(lockType LockType) func() { tree.mu.Lock() return tree.mu.Unlock } - return func() {} + return noopFunc } -func (tree *SimpleTree[T]) WalkPrefix(lockType LockType, s string, f func(s string, v T) (bool, error)) error { +func (tree SimpleThreadSafeTree[T]) LockTree(lockType LockType) (TreeThreadSafe[T], func()) { + unlock := tree.Lock(lockType) + tree.noLock = true + return &tree, unlock // create a copy of tree with the noLock flag set to true. +} + +func (tree *SimpleThreadSafeTree[T]) WalkPrefix(lockType LockType, s string, f func(s string, v T) (bool, error)) error { commit := tree.Lock(lockType) defer commit() var err error @@ -96,3 +214,31 @@ func (tree *SimpleTree[T]) WalkPrefix(lockType LockType, s string, f func(s stri return err } + +func (tree *SimpleThreadSafeTree[T]) WalkPath(lockType LockType, s string, f func(s string, v T) (bool, error)) error { + commit := tree.Lock(lockType) + defer commit() + var err error + tree.tree.WalkPath(s, func(s string, v any) bool { + var b bool + b, err = f(s, v.(T)) + if err != nil { + return true + } + return b + }) + + return err +} + +func (tree *SimpleThreadSafeTree[T]) All(lockType LockType) iter.Seq2[string, T] { + commit := tree.Lock(lockType) + defer commit() + return func(yield func(s string, v T) bool) { + tree.tree.Walk(func(s string, v any) bool { + return !yield(s, v.(T)) + }) + } +} + +// iter.Seq[*TemplWithBaseApplied] diff --git a/hugolib/doctree/support.go b/hugolib/doctree/support.go index adcc3b06c..f1b713b31 100644 --- a/hugolib/doctree/support.go +++ b/hugolib/doctree/support.go @@ -17,8 +17,6 @@ import ( "fmt" "strings" "sync" - - radix "github.com/armon/go-radix" ) var _ MutableTrees = MutableTrees{} @@ -60,11 +58,9 @@ func (ctx *WalkContext[T]) AddPostHook(handler func() error) { ctx.HooksPost = append(ctx.HooksPost, handler) } -func (ctx *WalkContext[T]) Data() *SimpleTree[any] { +func (ctx *WalkContext[T]) Data() *SimpleThreadSafeTree[any] { ctx.dataInit.Do(func() { - ctx.data = &SimpleTree[any]{ - tree: radix.New(), - } + ctx.data = NewSimpleThreadSafeTree[any]() }) return ctx.data } @@ -191,7 +187,7 @@ func (t MutableTrees) CanLock() bool { // WalkContext is passed to the Walk callback. type WalkContext[T any] struct { - data *SimpleTree[any] + data *SimpleThreadSafeTree[any] dataInit sync.Once eventHandlers eventHandlers[T] events []*Event[T] diff --git a/hugolib/doctree/treeshifttree.go b/hugolib/doctree/treeshifttree.go index 059eaaf88..8f958d828 100644 --- a/hugolib/doctree/treeshifttree.go +++ b/hugolib/doctree/treeshifttree.go @@ -13,7 +13,9 @@ package doctree -var _ Tree[string] = (*TreeShiftTree[string])(nil) +import "iter" + +var _ TreeThreadSafe[string] = (*TreeShiftTree[string])(nil) type TreeShiftTree[T comparable] struct { // This tree is shiftable in one dimension. @@ -26,16 +28,16 @@ type TreeShiftTree[T comparable] struct { zero T // Will be of length equal to the length of the dimension. - trees []*SimpleTree[T] + trees []*SimpleThreadSafeTree[T] } func NewTreeShiftTree[T comparable](d, length int) *TreeShiftTree[T] { if length <= 0 { panic("length must be > 0") } - trees := make([]*SimpleTree[T], length) + trees := make([]*SimpleThreadSafeTree[T], length) for i := range length { - trees[i] = NewSimpleTree[T]() + trees[i] = NewSimpleThreadSafeTree[T]() } return &TreeShiftTree[T]{d: d, trees: trees} } @@ -91,6 +93,14 @@ func (t *TreeShiftTree[T]) WalkPrefixRaw(lockType LockType, s string, f func(s s return nil } +func (t *TreeShiftTree[T]) WalkPath(lockType LockType, s string, f func(s string, v T) (bool, error)) error { + return t.trees[t.v].WalkPath(lockType, s, f) +} + +func (t *TreeShiftTree[T]) All(lockType LockType) iter.Seq2[string, T] { + return t.trees[t.v].All(lockType) +} + func (t *TreeShiftTree[T]) LenRaw() int { var count int for _, tt := range t.trees { diff --git a/hugolib/hugo_sites.go b/hugolib/hugo_sites.go index 5373f9832..0b68af2ec 100644 --- a/hugolib/hugo_sites.go +++ b/hugolib/hugo_sites.go @@ -134,7 +134,6 @@ func (h *HugoSites) resolveSite(lang string) *Site { return nil } -// Only used in tests. type buildCounters struct { contentRenderCounter atomic.Uint64 pageRenderCounter atomic.Uint64 @@ -557,7 +556,6 @@ func (h *HugoSites) handleDataFile(r *source.File) error { higherPrecedentData := current[r.BaseFileName()] switch data.(type) { - case nil: case map[string]any: switch higherPrecedentData.(type) { diff --git a/hugolib/hugo_sites_build.go b/hugolib/hugo_sites_build.go index 729c17c75..ce4ca370c 100644 --- a/hugolib/hugo_sites_build.go +++ b/hugolib/hugo_sites_build.go @@ -494,17 +494,17 @@ func (s *Site) executeDeferredTemplates(de *deps.DeferredExecutions) error { defer deferred.Mu.Unlock() if !deferred.Executed { - tmpl := s.Deps.Tmpl() - templ, found := tmpl.Lookup(deferred.TemplateName) - if !found { - panic(fmt.Sprintf("template %q not found", deferred.TemplateName)) + tmpl := s.Deps.GetTemplateStore() + ti := s.TemplateStore.LookupByPath(deferred.TemplatePath) + if ti == nil { + panic(fmt.Sprintf("template %q not found", deferred.TemplatePath)) } if err := func() error { buf := bufferpool.GetBuffer() defer bufferpool.PutBuffer(buf) - err = tmpl.ExecuteWithContext(deferred.Ctx, templ, buf, deferred.Data) + err = tmpl.ExecuteWithContext(deferred.Ctx, ti, buf, deferred.Data) if err != nil { return err } @@ -577,9 +577,13 @@ func (h *HugoSites) printUnusedTemplatesOnce() error { h.printUnusedTemplatesInit.Do(func() { conf := h.Configs.Base if conf.PrintUnusedTemplates { - unusedTemplates := h.Tmpl().(tpl.UnusedTemplatesProvider).UnusedTemplates() + unusedTemplates := h.GetTemplateStore().UnusedTemplates() for _, unusedTemplate := range unusedTemplates { - h.Log.Warnf("Template %s is unused, source file %s", unusedTemplate.Name(), unusedTemplate.Filename()) + if unusedTemplate.Fi != nil { + h.Log.Warnf("Template %s is unused, source %q", unusedTemplate.PathInfo.Path(), unusedTemplate.Fi.Meta().Filename) + } else { + h.Log.Warnf("Template %s is unused", unusedTemplate.PathInfo.Path()) + } } } }) @@ -954,7 +958,7 @@ func (h *HugoSites) processPartialFileEvents(ctx context.Context, l logg.LevelLo case files.ComponentFolderLayouts: tmplChanged = true templatePath := pathInfo.Unnormalized().TrimLeadingSlash().PathNoLang() - if !h.Tmpl().HasTemplate(templatePath) { + if !h.GetTemplateStore().HasTemplate(templatePath) { tmplAdded = true } @@ -974,8 +978,9 @@ func (h *HugoSites) processPartialFileEvents(ctx context.Context, l logg.LevelLo } } else { logger.Println("Template changed", pathInfo.Path()) - if templ, found := h.Tmpl().GetIdentity(templatePath); found { - changes = append(changes, templ) + id := h.GetTemplateStore().GetIdentity(pathInfo.Path()) + if id != nil { + changes = append(changes, id) } else { changes = append(changes, pathInfo) } @@ -1084,7 +1089,6 @@ func (h *HugoSites) processPartialFileEvents(ctx context.Context, l logg.LevelLo changed := &WhatChanged{ needsPagesAssembly: needsPagesAssemble, - identitySet: make(identity.Identities), } changed.Add(changes...) @@ -1106,17 +1110,39 @@ func (h *HugoSites) processPartialFileEvents(ctx context.Context, l logg.LevelLo } } - h.Deps.OnChangeListeners.Notify(changed.Changes()...) + changes2 := changed.Changes() + h.Deps.OnChangeListeners.Notify(changes2...) if err := h.resolveAndClearStateForIdentities(ctx, l, cacheBusterOr, changed.Drain()); err != nil { return err } - if tmplChanged || i18nChanged { + if tmplChanged { if err := loggers.TimeTrackfn(func() (logg.LevelLogger, error) { - // TODO(bep) this could probably be optimized to somehow - // only load the changed templates and its dependencies, but that is non-trivial. + depsFinder := identity.NewFinder(identity.FinderConfig{}) ll := l.WithField("substep", "rebuild templates") + s := h.Sites[0] + if err := s.Deps.TemplateStore.RefreshFiles(func(fi hugofs.FileMetaInfo) bool { + pi := fi.Meta().PathInfo + for _, id := range changes2 { + if depsFinder.Contains(pi, id, -1) > 0 { + return true + } + } + return false + }); err != nil { + return ll, err + } + + return ll, nil + }); err != nil { + return err + } + } + + if i18nChanged { + if err := loggers.TimeTrackfn(func() (logg.LevelLogger, error) { + ll := l.WithField("substep", "rebuild i18n") var prototype *deps.Deps for i, s := range h.Sites { if err := s.Deps.Compile(prototype); err != nil { diff --git a/hugolib/hugo_smoke_test.go b/hugolib/hugo_smoke_test.go index c43ba7293..09d57bbff 100644 --- a/hugolib/hugo_smoke_test.go +++ b/hugolib/hugo_smoke_test.go @@ -76,12 +76,13 @@ Single: {{ .Title }}|{{ .RelPermalink}}|{{ range .OutputFormats }}{{ .Name }}: { ` - b := Test(t, files) - - b.AssertFileContent("public/index.html", `List: |/|html: /|rss: /index.xml|$`) - b.AssertFileContent("public/index.xml", `List xml: |/|html: /|rss: /index.xml|$`) - b.AssertFileContent("public/p1/index.html", `Single: Page|/p1/|html: /p1/|$`) - b.AssertFileExists("public/p1/index.xml", false) + for i := 0; i < 2; i++ { + b := Test(t, files) + b.AssertFileContent("public/index.html", `List: |/|html: /|rss: /index.xml|$`) + b.AssertFileContent("public/index.xml", `List xml: |/|html: /|rss: /index.xml|$`) + b.AssertFileContent("public/p1/index.html", `Single: Page|/p1/|html: /p1/|$`) + b.AssertFileExists("public/p1/index.xml", false) + } } func TestSmoke(t *testing.T) { diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go index ff45ec275..4ea6f420d 100644 --- a/hugolib/integrationtest_builder.go +++ b/hugolib/integrationtest_builder.go @@ -219,19 +219,31 @@ type IntegrationTestBuilder struct { type lockingBuffer struct { sync.Mutex - bytes.Buffer + buf bytes.Buffer +} + +func (b *lockingBuffer) String() string { + b.Lock() + defer b.Unlock() + return b.buf.String() +} + +func (b *lockingBuffer) Reset() { + b.Lock() + defer b.Unlock() + b.buf.Reset() } func (b *lockingBuffer) ReadFrom(r io.Reader) (n int64, err error) { b.Lock() - n, err = b.Buffer.ReadFrom(r) + n, err = b.buf.ReadFrom(r) b.Unlock() return } func (b *lockingBuffer) Write(p []byte) (n int, err error) { b.Lock() - n, err = b.Buffer.Write(p) + n, err = b.buf.Write(p) b.Unlock() return } diff --git a/hugolib/page.go b/hugolib/page.go index de64767df..41519909b 100644 --- a/hugolib/page.go +++ b/hugolib/page.go @@ -28,15 +28,13 @@ import ( "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/output" - "github.com/gohugoio/hugo/output/layouts" "github.com/gohugoio/hugo/related" + "github.com/gohugoio/hugo/tpl/tplimpl" "github.com/spf13/afero" "github.com/gohugoio/hugo/markup/converter" "github.com/gohugoio/hugo/markup/tableofcontents" - "github.com/gohugoio/hugo/tpl" - "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/types" @@ -116,6 +114,14 @@ type pageState struct { resourcesPublishInit *sync.Once } +func (p *pageState) incrPageOutputTemplateVariation() { + p.pageOutputTemplateVariationsState.Add(1) +} + +func (p *pageState) canReusePageOutputContent() bool { + return p.pageOutputTemplateVariationsState.Load() == 1 +} + func (p *pageState) IdentifierBase() string { return p.Path() } @@ -169,10 +175,6 @@ func (p *pageState) resetBuildState() { // Nothing to do for now. } -func (p *pageState) reusePageOutputContent() bool { - return p.pageOutputTemplateVariationsState.Load() == 1 -} - func (p *pageState) skipRender() bool { b := p.s.conf.C.SegmentFilter.ShouldExcludeFine( segments.SegmentMatcherFields{ @@ -474,49 +476,40 @@ func (ps *pageState) initCommonProviders(pp pagePaths) error { return nil } -func (p *pageState) getLayoutDescriptor() layouts.LayoutDescriptor { - p.layoutDescriptorInit.Do(func() { - var section string - sections := p.SectionsEntries() - - switch p.Kind() { - case kinds.KindSection: - if len(sections) > 0 { - section = sections[0] - } - case kinds.KindTaxonomy, kinds.KindTerm: - - if p.m.singular != "" { - section = p.m.singular - } else if len(sections) > 0 { - section = sections[0] - } - default: - } - - p.layoutDescriptor = layouts.LayoutDescriptor{ - Kind: p.Kind(), - Type: p.Type(), - Lang: p.Language().Lang, - Layout: p.Layout(), - Section: section, - } - }) - - return p.layoutDescriptor +func (po *pageOutput) getTemplateBasePathAndDescriptor() (string, tplimpl.TemplateDescriptor) { + p := po.p + 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, + } } -func (p *pageState) resolveTemplate(layouts ...string) (tpl.Template, bool, error) { - f := p.outputFormat() - - d := p.getLayoutDescriptor() +func (p *pageState) resolveTemplate(layouts ...string) (*tplimpl.TemplInfo, bool, error) { + dir, d := p.getTemplateBasePathAndDescriptor() if len(layouts) > 0 { d.Layout = layouts[0] - d.LayoutOverride = true + d.LayoutMustMatch = true } - return p.s.Tmpl().LookupLayout(d, f) + q := tplimpl.TemplateQuery{ + Path: dir, + Category: tplimpl.CategoryLayout, + Desc: d, + } + + tinfo := p.s.TemplateStore.LookupPagesLayout(q) + if tinfo == nil { + return nil, false, nil + } + + return tinfo, true, nil } // Must be run after the site section tree etc. is built and ready. @@ -705,7 +698,7 @@ func (p *pageState) shiftToOutputFormat(isRenderingSite bool, idx int) error { if isRenderingSite { cp := p.pageOutput.pco - if cp == nil && p.reusePageOutputContent() { + if cp == nil && p.canReusePageOutputContent() { // Look for content to reuse. for i := range p.pageOutputs { if i == idx { diff --git a/hugolib/page__common.go b/hugolib/page__common.go index 7407f7140..27d2c3089 100644 --- a/hugolib/page__common.go +++ b/hugolib/page__common.go @@ -21,7 +21,6 @@ import ( "github.com/gohugoio/hugo/lazy" "github.com/gohugoio/hugo/markup/converter" "github.com/gohugoio/hugo/navigation" - "github.com/gohugoio/hugo/output/layouts" "github.com/gohugoio/hugo/resources/page" "github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/source" @@ -86,9 +85,6 @@ type pageCommon struct { // should look like. targetPathDescriptor page.TargetPathDescriptor - layoutDescriptor layouts.LayoutDescriptor - layoutDescriptorInit sync.Once - // Set if feature enabled and this is in a Git repo. gitInfo source.GitInfo codeowners []string diff --git a/hugolib/page__content.go b/hugolib/page__content.go index 3cfea1727..b42c2b419 100644 --- a/hugolib/page__content.go +++ b/hugolib/page__content.go @@ -24,6 +24,8 @@ import ( "strings" "unicode/utf8" + maps0 "maps" + "github.com/bep/logg" "github.com/gohugoio/hugo/common/hcontext" "github.com/gohugoio/hugo/common/herrors" @@ -32,7 +34,6 @@ import ( "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/common/types/hstring" "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/markup" "github.com/gohugoio/hugo/markup/converter" "github.com/gohugoio/hugo/markup/goldmark/hugocontext" @@ -45,7 +46,6 @@ import ( "github.com/gohugoio/hugo/tpl" "github.com/mitchellh/mapstructure" "github.com/spf13/cast" - maps0 "maps" ) const ( @@ -600,7 +600,7 @@ func (c *cachedContentScope) contentRendered(ctx context.Context) (contentSummar return nil, err } if hasShortcodeVariants { - cp.po.p.pageOutputTemplateVariationsState.Add(1) + cp.po.p.incrPageOutputTemplateVariation() } var result contentSummary @@ -684,10 +684,9 @@ func (c *cachedContentScope) contentToC(ctx context.Context) (contentTableOfCont if err := cp.initRenderHooks(); err != nil { return nil, err } - f := cp.po.f po := cp.po p := po.p - ct.contentPlaceholders, err = c.shortcodeState.prepareShortcodesForPage(ctx, p, f, false) + ct.contentPlaceholders, err = c.shortcodeState.prepareShortcodesForPage(ctx, po, false) if err != nil { return nil, err } @@ -701,16 +700,14 @@ func (c *cachedContentScope) contentToC(ctx context.Context) (contentTableOfCont if p.s.conf.Internal.Watch { for _, s := range cp2.po.p.m.content.shortcodeState.shortcodes { - for _, templ := range s.templs { - cp.trackDependency(templ.(identity.IdentityProvider)) - } + cp.trackDependency(s.templ) } } // Transfer shortcode names so HasShortcode works for shortcodes from included pages. cp.po.p.m.content.shortcodeState.transferNames(cp2.po.p.m.content.shortcodeState) if cp2.po.p.pageOutputTemplateVariationsState.Load() > 0 { - cp.po.p.pageOutputTemplateVariationsState.Add(1) + cp.po.p.incrPageOutputTemplateVariation() } } @@ -723,7 +720,7 @@ func (c *cachedContentScope) contentToC(ctx context.Context) (contentTableOfCont } if hasVariants { - p.pageOutputTemplateVariationsState.Add(1) + p.incrPageOutputTemplateVariation() } isHTML := cp.po.p.m.pageConfig.ContentMediaType.IsHTML() @@ -980,7 +977,7 @@ func (c *cachedContentScope) RenderString(ctx context.Context, args ...any) (tem return "", err } - placeholders, err := s.prepareShortcodesForPage(ctx, pco.po.p, pco.po.f, true) + placeholders, err := s.prepareShortcodesForPage(ctx, pco.po, true) if err != nil { return "", err } @@ -990,7 +987,7 @@ func (c *cachedContentScope) RenderString(ctx context.Context, args ...any) (tem return "", err } if hasVariants { - pco.po.p.pageOutputTemplateVariationsState.Add(1) + pco.po.p.incrPageOutputTemplateVariation() } b, err := pco.renderContentWithConverter(ctx, conv, contentToRender, false) if err != nil { @@ -1028,7 +1025,7 @@ func (c *cachedContentScope) RenderString(ctx context.Context, args ...any) (tem return "", err } if hasShortcodeVariants { - pco.po.p.pageOutputTemplateVariationsState.Add(1) + pco.po.p.incrPageOutputTemplateVariation() } } @@ -1110,7 +1107,7 @@ func (c *cachedContentScope) RenderShortcodes(ctx context.Context) (template.HTM } if hasVariants { - pco.po.p.pageOutputTemplateVariationsState.Add(1) + pco.po.p.incrPageOutputTemplateVariation() } if cb != nil { diff --git a/hugolib/page__meta.go b/hugolib/page__meta.go index e44224bae..9516c482a 100644 --- a/hugolib/page__meta.go +++ b/hugolib/page__meta.go @@ -72,8 +72,11 @@ type pageMeta struct { // Prepare for a rebuild of the data passed in from front matter. func (m *pageMeta) setMetaPostPrepareRebuild() { - params := xmaps.Clone[map[string]any](m.paramsOriginal) + params := xmaps.Clone(m.paramsOriginal) m.pageMetaParams.pageConfig = &pagemeta.PageConfig{ + Kind: m.pageConfig.Kind, + Lang: m.pageConfig.Lang, + Path: m.pageConfig.Path, Params: params, } m.pageMetaFrontMatter = pageMetaFrontMatter{} @@ -108,10 +111,10 @@ func (p *pageMeta) Aliases() []string { } func (p *pageMeta) BundleType() string { - switch p.pathInfo.BundleType() { - case paths.PathTypeLeaf: + switch p.pathInfo.Type() { + case paths.TypeLeaf: return "leaf" - case paths.PathTypeBranch: + case paths.TypeBranch: return "branch" default: return "" diff --git a/hugolib/page__per_output.go b/hugolib/page__per_output.go index 2915c6b8a..3add01408 100644 --- a/hugolib/page__per_output.go +++ b/hugolib/page__per_output.go @@ -19,23 +19,21 @@ import ( "errors" "fmt" "html/template" - "strings" "sync" "sync/atomic" "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/common/text" "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/tpl/tplimpl" "github.com/spf13/cast" "github.com/gohugoio/hugo/markup/converter/hooks" - "github.com/gohugoio/hugo/markup/highlight/chromalexers" "github.com/gohugoio/hugo/markup/tableofcontents" "github.com/gohugoio/hugo/markup/converter" bp "github.com/gohugoio/hugo/bufferpool" - "github.com/gohugoio/hugo/tpl" "github.com/gohugoio/hugo/output" "github.com/gohugoio/hugo/resources/page" @@ -120,9 +118,9 @@ func (pco *pageContentOutput) Render(ctx context.Context, layout ...string) (tem } // Make sure to send the *pageState and not the *pageContentOutput to the template. - res, err := executeToString(ctx, pco.po.p.s.Tmpl(), templ, pco.po.p) + res, err := executeToString(ctx, pco.po.p.s.GetTemplateStore(), templ, pco.po.p) if err != nil { - return "", pco.po.p.wrapError(fmt.Errorf("failed to execute template %s: %w", templ.Name(), err)) + return "", pco.po.p.wrapError(fmt.Errorf("failed to execute template %s: %w", templ.Template.Name(), err)) } return template.HTML(res), nil } @@ -274,103 +272,100 @@ func (pco *pageContentOutput) initRenderHooks() error { return r } - layoutDescriptor := pco.po.p.getLayoutDescriptor() - layoutDescriptor.RenderingHook = true - layoutDescriptor.LayoutOverride = false - layoutDescriptor.Layout = "" + // Inherit the descriptor from the page/current output format. + // This allows for fine-grained control of the template used for + // rendering of e.g. links. + base, layoutDescriptor := pco.po.p.getTemplateBasePathAndDescriptor() switch tp { case hooks.LinkRendererType: - layoutDescriptor.Kind = "render-link" + layoutDescriptor.Variant1 = "link" case hooks.ImageRendererType: - layoutDescriptor.Kind = "render-image" + layoutDescriptor.Variant1 = "image" case hooks.HeadingRendererType: - layoutDescriptor.Kind = "render-heading" + layoutDescriptor.Variant1 = "heading" case hooks.PassthroughRendererType: - layoutDescriptor.Kind = "render-passthrough" + layoutDescriptor.Variant1 = "passthrough" if id != nil { - layoutDescriptor.KindVariants = id.(string) + layoutDescriptor.Variant2 = id.(string) } case hooks.BlockquoteRendererType: - layoutDescriptor.Kind = "render-blockquote" + layoutDescriptor.Variant1 = "blockquote" if id != nil { - layoutDescriptor.KindVariants = id.(string) + layoutDescriptor.Variant2 = id.(string) } case hooks.TableRendererType: - layoutDescriptor.Kind = "render-table" + layoutDescriptor.Variant1 = "table" case hooks.CodeBlockRendererType: - layoutDescriptor.Kind = "render-codeblock" + layoutDescriptor.Variant1 = "codeblock" if id != nil { - lang := id.(string) - lexer := chromalexers.Get(lang) - if lexer != nil { - layoutDescriptor.KindVariants = strings.Join(lexer.Config().Aliases, ",") - } else { - layoutDescriptor.KindVariants = lang - } + layoutDescriptor.Variant2 = id.(string) } } - getHookTemplate := func(f output.Format) (tpl.Template, bool) { - templ, found, err := pco.po.p.s.Tmpl().LookupLayout(layoutDescriptor, f) - if err != nil { - panic(err) - } - if found { - if isitp, ok := templ.(tpl.IsInternalTemplateProvider); ok && isitp.IsInternalTemplate() { - - renderHookConfig := pco.po.p.s.conf.Markup.Goldmark.RenderHooks - - switch templ.Name() { - case "_default/_markup/render-link.html": - if !renderHookConfig.Link.IsEnableDefault() { - return nil, false - } - case "_default/_markup/render-image.html": - if !renderHookConfig.Image.IsEnableDefault() { - return nil, false - } - } - } - } - return templ, found + renderHookConfig := pco.po.p.s.conf.Markup.Goldmark.RenderHooks + var ignoreInternal bool + switch layoutDescriptor.Variant1 { + case "link": + ignoreInternal = !renderHookConfig.Link.IsEnableDefault() + case "image": + ignoreInternal = !renderHookConfig.Image.IsEnableDefault() } - templ, found1 := getHookTemplate(pco.po.f) - if !found1 || pco.po.p.reusePageOutputContent() { - defaultOutputFormat := pco.po.p.s.conf.C.DefaultOutputFormat - - candidates := pco.po.p.s.renderFormats - - // Some hooks may only be available in HTML, and if - // this site is configured to not have HTML output, we need to - // make sure we have a fallback. This should be very rare. - if pco.po.f.MediaType.FirstSuffix.Suffix != "html" { - if _, found := candidates.GetBySuffix("html"); !found { - candidates = append(candidates, output.HTMLFormat) - } + candidates := pco.po.p.s.renderFormats + var numCandidatesFound int + consider := func(candidate *tplimpl.TemplInfo) bool { + if layoutDescriptor.Variant1 != candidate.D.Variant1 { + return false } - // Check if some of the other output formats would give a different template. - for _, f := range candidates { - if f.Name == pco.po.f.Name { - continue - } - templ2, found2 := getHookTemplate(f) - - if found2 { - if !found1 && f.Name == defaultOutputFormat.Name { - templ = templ2 - found1 = true - break - } - - if templ != templ2 { - pco.po.p.pageOutputTemplateVariationsState.Add(1) - break - } - } + if layoutDescriptor.Variant2 != "" && candidate.D.Variant2 != "" && layoutDescriptor.Variant2 != candidate.D.Variant2 { + return false } + + if ignoreInternal && candidate.SubCategory == tplimpl.SubCategoryEmbedded { + // Don't consider the internal hook templates. + return false + } + + if pco.po.p.pageOutputTemplateVariationsState.Load() > 1 { + return true + } + + if candidate.D.OutputFormat == "" { + numCandidatesFound++ + } else if _, found := candidates.GetByName(candidate.D.OutputFormat); found { + numCandidatesFound++ + } + + return true + } + + getHookTemplate := func() (*tplimpl.TemplInfo, bool) { + q := tplimpl.TemplateQuery{ + Path: base, + Category: tplimpl.CategoryMarkup, + Desc: layoutDescriptor, + Consider: consider, + } + + v := pco.po.p.s.TemplateStore.LookupPagesLayout(q) + return v, v != nil + } + + templ, found1 := getHookTemplate() + if found1 && templ == nil { + panic("found1 is true, but templ is nil") + } + + if !found1 && layoutDescriptor.OutputFormat == pco.po.p.s.conf.DefaultOutputFormat { + numCandidatesFound++ + } + + if numCandidatesFound > 1 { + // More than one output format candidate found for this hook temoplate, + // so we cannot reuse the same rendered content. + pco.po.p.incrPageOutputTemplateVariation() } if !found1 { @@ -384,7 +379,7 @@ func (pco *pageContentOutput) initRenderHooks() error { } r := hookRendererTemplate{ - templateHandler: pco.po.p.s.Tmpl(), + templateHandler: pco.po.p.s.GetTemplateStore(), templ: templ, resolvePosition: resolvePosition, } @@ -488,7 +483,7 @@ func (t targetPathsHolder) targetPaths() page.TargetPaths { return t.paths } -func executeToString(ctx context.Context, h tpl.TemplateHandler, templ tpl.Template, data any) (string, error) { +func executeToString(ctx context.Context, h *tplimpl.TemplateStore, templ *tplimpl.TemplInfo, data any) (string, error) { b := bp.GetBuffer() defer bp.PutBuffer(b) if err := h.ExecuteWithContext(ctx, templ, b, data); err != nil { diff --git a/hugolib/pages_capture.go b/hugolib/pages_capture.go index f175895c4..50900e585 100644 --- a/hugolib/pages_capture.go +++ b/hugolib/pages_capture.go @@ -195,7 +195,7 @@ func (c *pagesCollector) Collect() (collectErr error) { return id.p.Dir() == fim.Meta().PathInfo.Dir() } - if fim.Meta().PathInfo.IsLeafBundle() && id.p.BundleType() == paths.PathTypeContentSingle { + if fim.Meta().PathInfo.IsLeafBundle() && id.p.Type() == paths.TypeContentSingle { return id.p.Dir() == fim.Meta().PathInfo.Dir() } @@ -314,7 +314,7 @@ func (c *pagesCollector) collectDirDir(path string, root hugofs.FileMetaInfo, in return nil, filepath.SkipDir } - seen := map[hstrings.Tuple]bool{} + seen := map[hstrings.Strings2]hugofs.FileMetaInfo{} for _, fi := range readdir { if fi.IsDir() { continue @@ -327,11 +327,14 @@ func (c *pagesCollector) collectDirDir(path string, root hugofs.FileMetaInfo, in // These would eventually have been filtered out as duplicates when // inserting them into the document store, // but doing it here will preserve a consistent ordering. - baseLang := hstrings.Tuple{First: pi.Base(), Second: meta.Lang} - if seen[baseLang] { + baseLang := hstrings.Strings2{pi.Base(), meta.Lang} + if fi2, ok := seen[baseLang]; ok { + if c.h.Configs.Base.PrintPathWarnings && !c.h.isRebuild() { + c.logger.Warnf("Duplicate content path: %q file: %q file: %q", pi.Base(), fi2.Meta().Filename, meta.Filename) + } continue } - seen[baseLang] = true + seen[baseLang] = fi if pi == nil { panic(fmt.Sprintf("no path info for %q", meta.Filename)) @@ -374,7 +377,7 @@ func (c *pagesCollector) collectDirDir(path string, root hugofs.FileMetaInfo, in func (c *pagesCollector) handleBundleLeaf(dir, bundle hugofs.FileMetaInfo, inPath string, readdir []hugofs.FileMetaInfo) error { bundlePi := bundle.Meta().PathInfo - seen := map[hstrings.Tuple]bool{} + seen := map[hstrings.Strings2]bool{} walk := func(path string, info hugofs.FileMetaInfo) error { if info.IsDir() { @@ -396,7 +399,7 @@ func (c *pagesCollector) handleBundleLeaf(dir, bundle hugofs.FileMetaInfo, inPat // These would eventually have been filtered out as duplicates when // inserting them into the document store, // but doing it here will preserve a consistent ordering. - baseLang := hstrings.Tuple{First: pi.Base(), Second: info.Meta().Lang} + baseLang := hstrings.Strings2{pi.Base(), info.Meta().Lang} if seen[baseLang] { return nil } diff --git a/hugolib/pagesfromdata/pagesfromgotmpl.go b/hugolib/pagesfromdata/pagesfromgotmpl.go index 58bb72978..a704b39ee 100644 --- a/hugolib/pagesfromdata/pagesfromgotmpl.go +++ b/hugolib/pagesfromdata/pagesfromgotmpl.go @@ -29,6 +29,7 @@ import ( "github.com/gohugoio/hugo/resources/page/pagemeta" "github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/tpl" + "github.com/gohugoio/hugo/tpl/tplimpl" "github.com/mitchellh/mapstructure" "github.com/spf13/cast" ) @@ -167,8 +168,7 @@ type PagesFromTemplateOptions struct { } type PagesFromTemplateDeps struct { - TmplFinder tpl.TemplateParseFinder - TmplExec tpl.TemplateExecutor + TemplateStore *tplimpl.TemplateStore } var _ resource.Staler = (*PagesFromTemplate)(nil) @@ -303,7 +303,7 @@ func (p *PagesFromTemplate) Execute(ctx context.Context) (BuildInfo, error) { } defer f.Close() - tmpl, err := p.TmplFinder.Parse(filepath.ToSlash(p.GoTmplFi.Meta().Filename), helpers.ReaderToString(f)) + tmpl, err := p.TemplateStore.TextParse(filepath.ToSlash(p.GoTmplFi.Meta().Filename), helpers.ReaderToString(f)) if err != nil { return BuildInfo{}, err } @@ -314,7 +314,7 @@ func (p *PagesFromTemplate) Execute(ctx context.Context) (BuildInfo, error) { ctx = tpl.Context.DependencyManagerScopedProvider.Set(ctx, p) - if err := p.TmplExec.ExecuteWithContext(ctx, tmpl, io.Discard, data); err != nil { + if err := p.TemplateStore.ExecuteWithContext(ctx, tmpl, io.Discard, data); err != nil { return BuildInfo{}, err } diff --git a/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go b/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go index c00a95bc1..a31b93999 100644 --- a/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go +++ b/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go @@ -98,7 +98,8 @@ ADD_MORE_PLACEHOLDER func TestPagesFromGoTmplMisc(t *testing.T) { t.Parallel() - b := hugolib.Test(t, filesPagesFromDataTempleBasic) + b := hugolib.Test(t, filesPagesFromDataTempleBasic, hugolib.TestOptWarn()) + b.AssertLogContains("! WARN") b.AssertPublishDir(` docs/p1/mytext.txt docs/p1/sub/mytex2.tx diff --git a/hugolib/paginator_test.go b/hugolib/paginator_test.go index 2470a9046..2fb87956f 100644 --- a/hugolib/paginator_test.go +++ b/hugolib/paginator_test.go @@ -15,7 +15,6 @@ package hugolib import ( "fmt" - "path/filepath" "testing" qt "github.com/frankban/quicktest" @@ -102,10 +101,18 @@ URL: {{ $pag.URL }} // Issue 6023 func TestPaginateWithSort(t *testing.T) { - b := newTestSitesBuilder(t).WithSimpleConfigFile() - b.WithTemplatesAdded("index.html", `{{ range (.Paginate (sort .Site.RegularPages ".File.Filename" "desc")).Pages }}|{{ .File.Filename }}{{ end }}`) - b.Build(BuildCfg{}).AssertFileContent("public/index.html", - filepath.FromSlash("|content/sect/doc1.nn.md|content/sect/doc1.nb.md|content/sect/doc1.fr.md|content/sect/doc1.en.md")) + files := ` +-- hugo.toml -- +-- content/a/a.md -- +-- content/z/b.md -- +-- content/x/b.md -- +-- content/x/a.md -- +-- layouts/home.html -- +Paginate: {{ range (.Paginate (sort .Site.RegularPages ".File.Filename" "desc")).Pages }}|{{ .Path }}{{ end }} +` + b := Test(t, files) + + b.AssertFileContent("public/index.html", "Paginate: |/z/b|/x/b|/x/a|/a/a") } // https://github.com/gohugoio/hugo/issues/6797 @@ -176,12 +183,12 @@ Paginator: {{ .Paginator }} func TestNilPointerErrorMessage(t *testing.T) { files := ` --- hugo.toml -- +-- hugo.toml -- -- content/p1.md -- -- layouts/_default/single.html -- Home Filename: {{ site.Home.File.Filename }} ` b, err := TestE(t, files) b.Assert(err, qt.IsNotNil) - b.Assert(err.Error(), qt.Contains, `_default/single.html:1:22: executing "_default/single.html" – File is nil; wrap it in if or with: {{ with site.Home.File }}{{ .Filename }}{{ end }}`) + b.Assert(err.Error(), qt.Contains, `single.html:1:22: executing "single.html" – File is nil; wrap it in if or with: {{ with site.Home.File }}{{ .Filename }}{{ end }}`) } diff --git a/hugolib/rebuild_test.go b/hugolib/rebuild_test.go index fab47679f..9d95a4b75 100644 --- a/hugolib/rebuild_test.go +++ b/hugolib/rebuild_test.go @@ -51,6 +51,7 @@ My Section Bundle Content Content. title: "My Section" --- -- content/mysection/mysectiontext.txt -- +Content. -- content/_index.md -- --- title: "Home" @@ -99,15 +100,17 @@ My Other Text: {{ $r.Content }}|{{ $r.Permalink }}| ` func TestRebuildEditLeafBundleHeaderOnly(t *testing.T) { - b := TestRunning(t, rebuildFilesSimple) - b.AssertFileContent("public/mysection/mysectionbundle/index.html", - "My Section Bundle Content Content.") - - b.EditFileReplaceAll("content/mysection/mysectionbundle/index.md", "My Section Bundle Content.", "My Section Bundle Content Edited.").Build() - b.AssertFileContent("public/mysection/mysectionbundle/index.html", - "My Section Bundle Content Edited.") - b.AssertRenderCountPage(2) // home (rss) + bundle. - b.AssertRenderCountContent(1) + t.Parallel() + for i := 0; i < 3; i++ { + b := TestRunning(t, rebuildFilesSimple) + b.AssertFileContent("public/mysection/mysectionbundle/index.html", + "My Section Bundle Content Content.") + b.EditFileReplaceAll("content/mysection/mysectionbundle/index.md", "My Section Bundle Content.", "My Section Bundle Content Edited.").Build() + b.AssertFileContent("public/mysection/mysectionbundle/index.html", + "My Section Bundle Content Edited.") + b.AssertRenderCountPage(2) // home (rss) + bundle. + b.AssertRenderCountContent(1) + } } func TestRebuildEditTextFileInLeafBundle(t *testing.T) { @@ -119,7 +122,7 @@ func TestRebuildEditTextFileInLeafBundle(t *testing.T) { b.AssertFileContent("public/mysection/mysectionbundle/index.html", "Text 2 Content Edited") b.AssertRenderCountPage(1) - b.AssertRenderCountContent(1) + b.AssertRenderCountContent(0) } func TestRebuildEditTextFileInShortcode(t *testing.T) { @@ -180,17 +183,17 @@ func TestRebuildEditTextFileInHomeBundle(t *testing.T) { b.AssertFileContent("public/index.html", "Home Content.") b.AssertFileContent("public/index.html", "Home Text Content Edited.") b.AssertRenderCountPage(1) - b.AssertRenderCountContent(1) + b.AssertRenderCountContent(0) } func TestRebuildEditTextFileInBranchBundle(t *testing.T) { b := TestRunning(t, rebuildFilesSimple) - b.AssertFileContent("public/mysection/index.html", "My Section") + b.AssertFileContent("public/mysection/index.html", "My Section", "0:/mysection/mysectiontext.txt|Content.|") b.EditFileReplaceAll("content/mysection/mysectiontext.txt", "Content.", "Content Edited.").Build() - b.AssertFileContent("public/mysection/index.html", "My Section") + b.AssertFileContent("public/mysection/index.html", "My Section", "0:/mysection/mysectiontext.txt|Content Edited.|") b.AssertRenderCountPage(1) - b.AssertRenderCountContent(1) + b.AssertRenderCountContent(0) } func testRebuildBothWatchingAndRunning(t *testing.T, files string, withB func(b *IntegrationTestBuilder)) { @@ -484,7 +487,43 @@ Home: {{ .Title }}|{{ .Content }}| }) } -func TestRebuildSingleWithBaseof(t *testing.T) { +func TestRebuildSingle(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +title = "Hugo Site" +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy", "sitemap", "robotstxt", "404"] +disableLiveReload = true +-- content/p1.md -- +--- +title: "P1" +--- +P1 Content. +-- layouts/index.html -- +Home. +-- layouts/single.html -- +Single: {{ .Title }}|{{ .Content }}| +{{ with (templates.Defer (dict "key" "global")) }} +Defer. +{{ end }} +` + b := Test(t, files, TestOptRunning()) + b.AssertFileContent("public/p1/index.html", "Single: P1|", "Defer.") + b.AssertRenderCountPage(3) + b.AssertRenderCountContent(1) + b.EditFileReplaceFunc("layouts/single.html", func(s string) string { + s = strings.Replace(s, "Single", "Single Edited", 1) + s = strings.Replace(s, "Defer.", "Defer Edited.", 1) + return s + }).Build() + b.AssertFileContent("public/p1/index.html", "Single Edited: P1|", "Defer Edited.") + b.AssertRenderCountPage(1) + b.AssertRenderCountContent(0) +} + +func TestRebuildSingleWithBaseofEditSingle(t *testing.T) { t.Parallel() files := ` @@ -498,9 +537,13 @@ disableLiveReload = true title: "P1" --- P1 Content. +[foo](/foo) -- layouts/_default/baseof.html -- Baseof: {{ .Title }}| {{ block "main" . }}default{{ end }} +{{ with (templates.Defer (dict "foo" "bar")) }} +Defer. +{{ end }} -- layouts/index.html -- Home. -- layouts/_default/single.html -- @@ -509,11 +552,81 @@ Single: {{ .Title }}|{{ .Content }}| {{ end }} ` b := Test(t, files, TestOptRunning()) - b.AssertFileContent("public/p1/index.html", "Baseof: P1|\n\nSingle: P1|

P1 Content.

\n|") + b.AssertFileContent("public/p1/index.html", "Single: P1|") b.EditFileReplaceFunc("layouts/_default/single.html", func(s string) string { return strings.Replace(s, "Single", "Single Edited", 1) }).Build() - b.AssertFileContent("public/p1/index.html", "Baseof: P1|\n\nSingle Edited: P1|

P1 Content.

\n|") + b.AssertFileContent("public/p1/index.html", "Single Edited") +} + +func TestRebuildSingleWithBaseofEditBaseof(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +title = "Hugo Site" +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +disableLiveReload = true +-- content/p1.md -- +--- +title: "P1" +--- +P1 Content. +[foo](/foo) +-- layouts/_default/baseof.html -- +Baseof: {{ .Title }}| +{{ block "main" . }}default{{ end }} +{{ with (templates.Defer (dict "foo" "bar")) }} +Defer. +{{ end }} +-- layouts/index.html -- +Home. +-- layouts/_default/single.html -- +{{ define "main" }} +Single: {{ .Title }}|{{ .Content }}| +{{ end }} +` + b := Test(t, files, TestOptRunning()) + b.AssertFileContent("public/p1/index.html", "Single: P1|") + fmt.Println("===============") + b.EditFileReplaceAll("layouts/_default/baseof.html", "Baseof", "Baseof Edited").Build() + b.AssertFileContent("public/p1/index.html", "Baseof Edited") +} + +func TestRebuildWithDeferEditRenderHook(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +title = "Hugo Site" +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +disableLiveReload = true +-- content/p1.md -- +--- +title: "P1" +--- +P1 Content. +[foo](/foo) +-- layouts/_default/baseof.html -- +Baseof: {{ .Title }}| +{{ block "main" . }}default{{ end }} + {{ with (templates.Defer (dict "foo" "bar")) }} +Defer. +{{ end }} +-- layouts/single.html -- +{{ define "main" }} +Single: {{ .Title }}|{{ .Content }}| +{{ end }} +-- layouts/_default/_markup/render-link.html -- +Render Link. +` + b := Test(t, files, TestOptRunning()) + // Edit render hook. + b.EditFileReplaceAll("layouts/_default/_markup/render-link.html", "Render Link", "Render Link Edited").Build() + + b.AssertFileContent("public/p1/index.html", "Render Link Edited") } func TestRebuildFromString(t *testing.T) { diff --git a/hugolib/shortcode.go b/hugolib/shortcode.go index 9a2bb7aeb..7a23b15d2 100644 --- a/hugolib/shortcode.go +++ b/hugolib/shortcode.go @@ -1,4 +1,4 @@ -// Copyright 2019 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. @@ -29,6 +29,7 @@ import ( "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/tpl/tplimpl" "github.com/gohugoio/hugo/parser/pageparser" "github.com/gohugoio/hugo/resources/page" @@ -36,7 +37,6 @@ import ( "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/common/text" "github.com/gohugoio/hugo/common/urls" - "github.com/gohugoio/hugo/output" bp "github.com/gohugoio/hugo/bufferpool" "github.com/gohugoio/hugo/tpl" @@ -205,8 +205,7 @@ type shortcode struct { indentation string // indentation from source. - info tpl.Info // One of the output formats (arbitrary) - templs []tpl.Template // All output formats + templ *tplimpl.TemplInfo // If set, the rendered shortcode is sent as part of the surrounding content // to Goldmark and similar. @@ -230,16 +229,15 @@ func (s shortcode) insertPlaceholder() bool { } func (s shortcode) needsInner() bool { - return s.info != nil && s.info.ParseInfo().IsInner + return s.templ != nil && s.templ.ParseInfo.IsInner } func (s shortcode) configVersion() int { - if s.info == nil { + if s.templ == nil { // Not set for inline shortcodes. return 2 } - - return s.info.ParseInfo().Config.Version + return s.templ.ParseInfo.Config.Version } func (s shortcode) innerString() string { @@ -315,12 +313,12 @@ func prepareShortcode( ctx context.Context, level int, s *Site, - tplVariants tpl.TemplateVariants, sc *shortcode, parent *ShortcodeWithPage, - p *pageState, + po *pageOutput, isRenderString bool, ) (shortcodeRenderer, error) { + p := po.p toParseErr := func(err error) error { source := p.m.content.mustSource() return p.parseError(fmt.Errorf("failed to render shortcode %q: %w", sc.name, err), source, sc.pos) @@ -333,7 +331,7 @@ func prepareShortcode( // parsed and rendered by Goldmark. ctx = tpl.Context.IsInGoldmark.Set(ctx, true) } - r, err := doRenderShortcode(ctx, level, s, tplVariants, sc, parent, p, isRenderString) + r, err := doRenderShortcode(ctx, level, s, sc, parent, po, isRenderString) if err != nil { return nil, false, toParseErr(err) } @@ -352,30 +350,29 @@ func doRenderShortcode( ctx context.Context, level int, s *Site, - tplVariants tpl.TemplateVariants, sc *shortcode, parent *ShortcodeWithPage, - p *pageState, + po *pageOutput, isRenderString bool, ) (shortcodeRenderer, error) { - var tmpl tpl.Template + var tmpl *tplimpl.TemplInfo + p := po.p // Tracks whether this shortcode or any of its children has template variations // in other languages or output formats. We are currently only interested in - // the output formats, so we may get some false positives -- we - // should improve on that. + // the output formats. var hasVariants bool if sc.isInline { if !p.s.ExecHelper.Sec().EnableInlineShortcodes { return zeroShortcode, nil } - templName := path.Join("_inline_shortcode", p.Path(), sc.name) + templatePath := path.Join("_inline_shortcode", p.Path(), sc.name) if sc.isClosing { templStr := sc.innerString() var err error - tmpl, err = s.TextTmpl().Parse(templName, templStr) + tmpl, err = s.TemplateStore.TextParse(templatePath, templStr) if err != nil { if isRenderString { return zeroShortcode, p.wrapError(err) @@ -389,21 +386,32 @@ func doRenderShortcode( } else { // Re-use of shortcode defined earlier in the same page. - var found bool - tmpl, found = s.TextTmpl().Lookup(templName) - if !found { + tmpl = s.TemplateStore.TextLookup(templatePath) + if tmpl == nil { return zeroShortcode, fmt.Errorf("no earlier definition of shortcode %q found", sc.name) } } - tmpl = tpl.AddIdentity(tmpl) } else { - var found, more bool - tmpl, found, more = s.Tmpl().LookupVariant(sc.name, tplVariants) - if !found { + ofCount := map[string]int{} + include := func(match *tplimpl.TemplInfo) bool { + ofCount[match.D.OutputFormat]++ + return true + } + base, layoutDescriptor := po.getTemplateBasePathAndDescriptor() + q := tplimpl.TemplateQuery{ + Path: base, + Name: sc.name, + Category: tplimpl.CategoryShortcode, + Desc: layoutDescriptor, + Consider: include, + } + v := s.TemplateStore.LookupShortcode(q) + if v == nil { s.Log.Errorf("Unable to locate template for shortcode %q in page %q", sc.name, p.File().Path()) return zeroShortcode, nil } - hasVariants = hasVariants || more + tmpl = v + hasVariants = hasVariants || len(ofCount) > 1 } data := &ShortcodeWithPage{ @@ -427,7 +435,7 @@ func doRenderShortcode( case string: inner += innerData case *shortcode: - s, err := prepareShortcode(ctx, level+1, s, tplVariants, innerData, data, p, isRenderString) + s, err := prepareShortcode(ctx, level+1, s, innerData, data, po, isRenderString) if err != nil { return zeroShortcode, err } @@ -484,7 +492,7 @@ func doRenderShortcode( } - result, err := renderShortcodeWithPage(ctx, s.Tmpl(), tmpl, data) + result, err := renderShortcodeWithPage(ctx, s.GetTemplateStore(), tmpl, data) if err != nil && sc.isInline { fe := herrors.NewFileErrorFromName(err, p.File().Filename()) @@ -534,16 +542,11 @@ func (s *shortcodeHandler) hasName(name string) bool { return ok } -func (s *shortcodeHandler) prepareShortcodesForPage(ctx context.Context, p *pageState, f output.Format, isRenderString bool) (map[string]shortcodeRenderer, error) { +func (s *shortcodeHandler) prepareShortcodesForPage(ctx context.Context, po *pageOutput, isRenderString bool) (map[string]shortcodeRenderer, error) { rendered := make(map[string]shortcodeRenderer) - tplVariants := tpl.TemplateVariants{ - Language: p.Language().Lang, - OutputFormat: f, - } - for _, v := range s.shortcodes { - s, err := prepareShortcode(ctx, 0, s.s, tplVariants, v, nil, p, isRenderString) + s, err := prepareShortcode(ctx, 0, s.s, v, nil, po, isRenderString) if err != nil { return nil, err } @@ -636,7 +639,7 @@ Loop: // we trust the template on this: // if there's no inner, we're done if !sc.isInline { - if !sc.info.ParseInfo().IsInner { + if !sc.templ.ParseInfo.IsInner { return sc, nil } } @@ -672,14 +675,19 @@ Loop: sc.name = currItem.ValStr(source) - // Used to check if the template expects inner content. - templs := s.s.Tmpl().LookupVariants(sc.name) - if templs == nil { + // 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) + if templ == nil { return nil, fmt.Errorf("%s: template for shortcode %q not found", errorPrefix, sc.name) } - - sc.info = templs[0].(tpl.Info) - sc.templs = templs + sc.templ = templ case currItem.IsInlineShortcodeName(): sc.name = currItem.ValStr(source) sc.isInline = true @@ -778,7 +786,7 @@ func expandShortcodeTokens( return source, nil } -func renderShortcodeWithPage(ctx context.Context, h tpl.TemplateHandler, tmpl tpl.Template, data *ShortcodeWithPage) (string, error) { +func renderShortcodeWithPage(ctx context.Context, h *tplimpl.TemplateStore, tmpl *tplimpl.TemplInfo, data *ShortcodeWithPage) (string, error) { buffer := bp.GetBuffer() defer bp.PutBuffer(buffer) diff --git a/hugolib/shortcode_test.go b/hugolib/shortcode_test.go index 93edd9345..f1d90e22e 100644 --- a/hugolib/shortcode_test.go +++ b/hugolib/shortcode_test.go @@ -33,14 +33,14 @@ func TestExtractShortcodes(t *testing.T) { b := newTestSitesBuilder(t).WithSimpleConfigFile() b.WithTemplates( - "default/single.html", `EMPTY`, - "_internal/shortcodes/tag.html", `tag`, - "_internal/shortcodes/legacytag.html", `{{ $_hugo_config := "{ \"version\": 1 }" }}tag`, - "_internal/shortcodes/sc1.html", `sc1`, - "_internal/shortcodes/sc2.html", `sc2`, - "_internal/shortcodes/inner.html", `{{with .Inner }}{{ . }}{{ end }}`, - "_internal/shortcodes/inner2.html", `{{.Inner}}`, - "_internal/shortcodes/inner3.html", `{{.Inner}}`, + "pages/single.html", `EMPTY`, + "shortcodes/tag.html", `tag`, + "shortcodes/legacytag.html", `{{ $_hugo_config := "{ \"version\": 1 }" }}tag`, + "shortcodes/sc1.html", `sc1`, + "shortcodes/sc2.html", `sc2`, + "shortcodes/inner.html", `{{with .Inner }}{{ . }}{{ end }}`, + "shortcodes/inner2.html", `{{.Inner}}`, + "shortcodes/inner3.html", `{{.Inner}}`, ).WithContent("page.md", `--- title: "Shortcodes Galore!" --- @@ -57,10 +57,9 @@ title: "Shortcodes Galore!" if s == nil { return "" } - var version int - if s.info != nil { - version = s.info.ParseInfo().Config.Version + if s.templ != nil { + version = s.templ.ParseInfo.Config.Version } return strReplacer.Replace(fmt.Sprintf("%s;inline:%t;closing:%t;inner:%v;params:%v;ordinal:%d;markup:%t;version:%d;pos:%d", s.name, s.isInline, s.isClosing, s.inner, s.params, s.ordinal, s.doMarkup, version, s.pos)) @@ -69,7 +68,7 @@ title: "Shortcodes Galore!" regexpCheck := func(re string) func(c *qt.C, shortcode *shortcode, err error) { return func(c *qt.C, shortcode *shortcode, err error) { c.Assert(err, qt.IsNil) - c.Assert(str(shortcode), qt.Matches, ".*"+re+".*") + c.Assert(str(shortcode), qt.Matches, ".*"+re+".*", qt.Commentf("%s", shortcode.name)) } } @@ -888,6 +887,7 @@ outputs: ["html", "css", "csv", "json"] "_default/single.json", "{{ .Content }}", "shortcodes/myshort.html", `Short-HTML`, "shortcodes/myshort.csv", `Short-CSV`, + "shortcodes/myshort.txt", `Short-TXT`, ) b.Build(BuildCfg{}) @@ -897,12 +897,12 @@ outputs: ["html", "css", "csv", "json"] for i := range numPages { b.AssertFileContent(fmt.Sprintf("public/page%d/index.html", i), "Short-HTML") b.AssertFileContent(fmt.Sprintf("public/page%d/index.csv", i), "Short-CSV") - b.AssertFileContent(fmt.Sprintf("public/page%d/index.json", i), "Short-HTML") + b.AssertFileContent(fmt.Sprintf("public/page%d/index.json", i), "Short-CSV") } for i := range numPages { - b.AssertFileContent(fmt.Sprintf("public/page%d/styles.css", i), "Short-HTML") + b.AssertFileContent(fmt.Sprintf("public/page%d/styles.css", i), "Short-CSV") } } diff --git a/hugolib/site.go b/hugolib/site.go index dab23d670..ebf06eee3 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -47,7 +47,13 @@ import ( "github.com/gohugoio/hugo/langs/i18n" "github.com/gohugoio/hugo/modules" "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/tpl/tplimpl" + "github.com/gohugoio/hugo/tpl/tplimplinit" + xmaps "golang.org/x/exp/maps" + + // Loads the template funcs namespaces. + "golang.org/x/text/unicode/norm" "github.com/gohugoio/hugo/common/paths" @@ -188,8 +194,8 @@ func NewHugoSites(cfg deps.DepsCfg) (*HugoSites, error) { BuildState: &deps.BuildState{ OnSignalRebuild: onSignalRebuild, }, + Counters: &deps.Counters{}, MemCache: memCache, - TemplateProvider: tplimpl.DefaultTemplateProvider, TranslationProvider: i18n.NewTranslationProvider(), WasmDispatchers: warpc.AllDispatchers( warpc.Options{ @@ -385,6 +391,34 @@ func newHugoSites(cfg deps.DepsCfg, d *deps.Deps, pageTrees *pageTrees, sites [] var prototype *deps.Deps for i, s := range sites { s.h = h + // The template store needs to be initialized after the h container is set on s. + if i == 0 { + templateStore, err := tplimpl.NewStore( + tplimpl.StoreOptions{ + Fs: s.BaseFs.Layouts.Fs, + DefaultContentLanguage: s.Conf.DefaultContentLanguage(), + Watching: s.Conf.Watching(), + PathParser: s.Conf.PathParser(), + Metrics: d.Metrics, + OutputFormats: s.conf.OutputFormats.Config, + MediaTypes: s.conf.MediaTypes.Config, + DefaultOutputFormat: s.conf.DefaultOutputFormat, + TaxonomySingularPlural: s.conf.Taxonomies, + }, tplimpl.SiteOptions{ + Site: s, + TemplateFuncs: tplimplinit.CreateFuncMap(s.Deps), + }) + if err != nil { + return nil, err + } + s.Deps.TemplateStore = templateStore + } else { + s.Deps.TemplateStore = prototype.TemplateStore.WithSiteOpts( + tplimpl.SiteOptions{ + Site: s, + TemplateFuncs: tplimplinit.CreateFuncMap(s.Deps), + }) + } if err := s.Deps.Compile(prototype); err != nil { return nil, err } @@ -464,7 +498,10 @@ func (s *Site) MainSections() []string { // Returns a struct with some information about the build. func (s *Site) Hugo() hugo.HugoInfo { - if s.h == nil || s.h.hugoInfo.Environment == "" { + if s.h == nil { + panic("site: hugo: h not initialized") + } + if s.h.hugoInfo.Environment == "" { panic("site: hugo: hugoInfo not initialized") } return s.h.hugoInfo @@ -797,7 +834,7 @@ func (s *Site) initRenderFormats() { s.renderFormats = formats } -func (s *Site) GetRelatedDocsHandler() *page.RelatedDocsHandler { +func (s *Site) GetInternalRelatedDocsHandler() *page.RelatedDocsHandler { return s.relatedDocsHandler } @@ -923,19 +960,24 @@ type WhatChanged struct { mu sync.Mutex needsPagesAssembly bool - identitySet identity.Identities + + ids map[identity.Identity]bool +} + +func (w *WhatChanged) init() { + if w.ids == nil { + w.ids = make(map[identity.Identity]bool) + } } func (w *WhatChanged) Add(ids ...identity.Identity) { w.mu.Lock() defer w.mu.Unlock() - if w.identitySet == nil { - w.identitySet = make(identity.Identities) - } + w.init() for _, id := range ids { - w.identitySet[id] = true + w.ids[id] = true } } @@ -946,20 +988,20 @@ func (w *WhatChanged) Clear() { } func (w *WhatChanged) clear() { - w.identitySet = identity.Identities{} + w.ids = nil } func (w *WhatChanged) Changes() []identity.Identity { - if w == nil || w.identitySet == nil { + if w == nil || w.ids == nil { return nil } - return w.identitySet.AsSlice() + return xmaps.Keys(w.ids) } func (w *WhatChanged) Drain() []identity.Identity { w.mu.Lock() defer w.mu.Unlock() - ids := w.identitySet.AsSlice() + ids := w.Changes() w.clear() return ids } @@ -1394,7 +1436,7 @@ const ( pageDependencyScopeGlobal ) -func (s *Site) renderAndWritePage(statCounter *uint64, name string, targetPath string, p *pageState, d any, templ tpl.Template) error { +func (s *Site) renderAndWritePage(statCounter *uint64, name string, targetPath string, p *pageState, d any, templ *tplimpl.TemplInfo) error { s.h.buildCounters.pageRenderCounter.Add(1) renderBuffer := bp.GetBuffer() defer bp.PutBuffer(renderBuffer) @@ -1453,8 +1495,8 @@ var infoOnMissingLayout = map[string]bool{ // hookRendererTemplate is the canonical implementation of all hooks.ITEMRenderer, // where ITEM is the thing being hooked. type hookRendererTemplate struct { - templateHandler tpl.TemplateHandler - templ tpl.Template + templateHandler *tplimpl.TemplateStore + templ *tplimpl.TemplInfo resolvePosition func(ctx any) text.Position } @@ -1490,7 +1532,7 @@ func (hr hookRendererTemplate) IsDefaultCodeBlockRenderer() bool { return false } -func (s *Site) renderForTemplate(ctx context.Context, name, outputFormat string, d any, w io.Writer, templ tpl.Template) (err error) { +func (s *Site) renderForTemplate(ctx context.Context, name, outputFormat string, d any, w io.Writer, templ *tplimpl.TemplInfo) (err error) { if templ == nil { s.logMissingLayout(name, "", "", outputFormat) return nil @@ -1500,7 +1542,7 @@ func (s *Site) renderForTemplate(ctx context.Context, name, outputFormat string, panic("nil context") } - if err = s.Tmpl().ExecuteWithContext(ctx, templ, w, d); err != nil { + if err = s.GetTemplateStore().ExecuteWithContext(ctx, templ, w, d); err != nil { filename := name if p, ok := d.(*pageState); ok { filename = p.String() diff --git a/hugolib/site_output.go b/hugolib/site_output.go index 47778b8b0..3438ea9f7 100644 --- a/hugolib/site_output.go +++ b/hugolib/site_output.go @@ -27,6 +27,7 @@ func createDefaultOutputFormats(allFormats output.Formats) map[string]output.For htmlOut, _ := allFormats.GetByName(output.HTMLFormat.Name) robotsOut, _ := allFormats.GetByName(output.RobotsTxtFormat.Name) sitemapOut, _ := allFormats.GetByName(output.SitemapFormat.Name) + httpStatus404Out, _ := allFormats.GetByName(output.HTTPStatus404HTMLFormat.Name) defaultListTypes := output.Formats{htmlOut} if rssFound { @@ -42,7 +43,7 @@ func createDefaultOutputFormats(allFormats output.Formats) map[string]output.For // Below are for consistency. They are currently not used during rendering. kinds.KindSitemap: {sitemapOut}, kinds.KindRobotsTXT: {robotsOut}, - kinds.KindStatus404: {htmlOut}, + kinds.KindStatus404: {httpStatus404Out}, } // May be disabled diff --git a/hugolib/site_output_test.go b/hugolib/site_output_test.go index 37ebce730..caec4c700 100644 --- a/hugolib/site_output_test.go +++ b/hugolib/site_output_test.go @@ -387,7 +387,7 @@ func TestCreateSiteOutputFormats(t *testing.T) { c.Assert(outputs[kinds.KindRSS], deepEqualsOutputFormats, output.Formats{output.RSSFormat}) c.Assert(outputs[kinds.KindSitemap], deepEqualsOutputFormats, output.Formats{output.SitemapFormat}) c.Assert(outputs[kinds.KindRobotsTXT], deepEqualsOutputFormats, output.Formats{output.RobotsTxtFormat}) - c.Assert(outputs[kinds.KindStatus404], deepEqualsOutputFormats, output.Formats{output.HTMLFormat}) + c.Assert(outputs[kinds.KindStatus404], deepEqualsOutputFormats, output.Formats{output.HTTPStatus404HTMLFormat}) }) // Issue #4528 @@ -481,6 +481,7 @@ permalinkable = true [outputFormats.nobase] mediaType = "application/json" permalinkable = true +isPlainText = true ` diff --git a/hugolib/site_render.go b/hugolib/site_render.go index c88036f26..ed22d4258 100644 --- a/hugolib/site_render.go +++ b/hugolib/site_render.go @@ -23,9 +23,9 @@ import ( "github.com/bep/logg" "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/hugolib/doctree" + "github.com/gohugoio/hugo/tpl/tplimpl" "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/tpl" "github.com/gohugoio/hugo/resources/kinds" "github.com/gohugoio/hugo/resources/page" @@ -57,7 +57,7 @@ func (s siteRenderContext) shouldRenderStandalonePage(kind string) bool { return s.outIdx == 0 } - if kind == kinds.KindStatus404 { + if kind == kinds.KindTemporary || kind == kinds.KindStatus404 { // 1 for all output formats return s.outIdx == 0 } @@ -168,7 +168,7 @@ func pageRenderer( s.Log.Trace( func() string { - return fmt.Sprintf("rendering outputFormat %q kind %q using layout %q to %q", p.pageOutput.f.Name, p.Kind(), templ.Name(), targetPath) + return fmt.Sprintf("rendering outputFormat %q kind %q using layout %q to %q", p.pageOutput.f.Name, p.Kind(), templ.Template.Name(), targetPath) }, ) @@ -225,7 +225,7 @@ func (s *Site) logMissingLayout(name, layout, kind, outputFormat string) { } // renderPaginator must be run after the owning Page has been rendered. -func (s *Site) renderPaginator(p *pageState, templ tpl.Template) error { +func (s *Site) renderPaginator(p *pageState, templ *tplimpl.TemplInfo) error { paginatePath := s.Conf.Pagination().Path d := p.targetPathDescriptor diff --git a/hugolib/site_test.go b/hugolib/site_test.go index e611897fe..a9fe977cf 100644 --- a/hugolib/site_test.go +++ b/hugolib/site_test.go @@ -978,8 +978,7 @@ func TestRefLinking(t *testing.T) { {".", "", true, "/level2/level3/"}, {"./", "", true, "/level2/level3/"}, - // try to confuse parsing - {"embedded.dot.md", "", true, "/level2/level3/embedded.dot/"}, + {"embedded.dot.md", "", true, "/level2/level3/embedded/"}, // test empty link, as well as fragment only link {"", "", true, ""}, diff --git a/hugolib/taxonomy_test.go b/hugolib/taxonomy_test.go index 6577a22c1..7aeaa780c 100644 --- a/hugolib/taxonomy_test.go +++ b/hugolib/taxonomy_test.go @@ -76,6 +76,8 @@ func TestTaxonomiesWithAndWithoutContentFile(t *testing.T) { } func doTestTaxonomiesWithAndWithoutContentFile(t *testing.T, uglyURLs bool) { + t.Helper() + siteConfig := ` baseURL = "http://example.com/blog" titleCaseStyle = "firstupper" diff --git a/hugolib/template_test.go b/hugolib/template_test.go index 01dfc7eba..a08f83cb8 100644 --- a/hugolib/template_test.go +++ b/hugolib/template_test.go @@ -26,6 +26,8 @@ import ( "github.com/gohugoio/hugo/hugofs" ) +// TODO(bep) keep this until we release v0.146.0 as a security against breaking changes, but it's rather messy and mostly duplicate of +// tests in the tplimpl package, so eventually just remove it. func TestTemplateLookupOrder(t *testing.T) { var ( fs *hugofs.Fs @@ -185,6 +187,9 @@ func TestTemplateLookupOrder(t *testing.T) { } { this := this + if this.name != "Variant 1" { + continue + } t.Run(this.name, func(t *testing.T) { // TODO(bep) there are some function vars need to pull down here to enable => t.Parallel() cfg, fs = newTestCfg() @@ -200,7 +205,7 @@ Some content } buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) - // helpers.PrintFs(s.BaseFs.Layouts.Fs, "", os.Stdout) + // s.TemplateStore.PrintDebug("", 0, os.Stdout) this.assert(t) }) @@ -270,11 +275,11 @@ func TestTemplateNoBasePlease(t *testing.T) { b := newTestSitesBuilder(t).WithSimpleConfigFile() b.WithTemplates("_default/list.html", ` - {{ define "main" }} - Bonjour - {{ end }} +{{ define "main" }} + Bonjour +{{ end }} - {{ printf "list" }} +{{ printf "list" }} `) @@ -344,33 +349,36 @@ title: %s b.AssertFileContent("public/p1/index.html", `Single: P1`) }) - t.Run("baseof", func(t *testing.T) { - t.Parallel() - b := newTestSitesBuilder(t).WithDefaultMultiSiteConfig() + { + } +} - b.WithTemplatesAdded( - "index.html", `{{ define "main" }}Main Home En{{ end }}`, - "index.fr.html", `{{ define "main" }}Main Home Fr{{ end }}`, - "baseof.html", `Baseof en: {{ block "main" . }}main block{{ end }}`, - "baseof.fr.html", `Baseof fr: {{ block "main" . }}main block{{ end }}`, - "mysection/baseof.html", `Baseof mysection: {{ block "main" . }}mysection block{{ end }}`, - "_default/single.html", `{{ define "main" }}Main Default Single{{ end }}`, - "_default/list.html", `{{ define "main" }}Main Default List{{ end }}`, - ) +func TestTemplateLookupSitBaseOf(t *testing.T) { + t.Parallel() + b := newTestSitesBuilder(t).WithDefaultMultiSiteConfig() - b.WithContent("mysection/p1.md", `--- + b.WithTemplatesAdded( + "index.html", `{{ define "main" }}Main Home En{{ end }}`, + "index.fr.html", `{{ define "main" }}Main Home Fr{{ end }}`, + "baseof.html", `Baseof en: {{ block "main" . }}main block{{ end }}`, + "baseof.fr.html", `Baseof fr: {{ block "main" . }}main block{{ end }}`, + "mysection/baseof.html", `Baseof mysection: {{ block "main" . }}mysection block{{ end }}`, + "_default/single.html", `{{ define "main" }}Main Default Single{{ end }}`, + "_default/list.html", `{{ define "main" }}Main Default List{{ end }}`, + ) + + b.WithContent("mysection/p1.md", `--- title: My Page --- `) - b.CreateSites().Build(BuildCfg{}) + b.CreateSites().Build(BuildCfg{}) - b.AssertFileContent("public/en/index.html", `Baseof en: Main Home En`) - b.AssertFileContent("public/fr/index.html", `Baseof fr: Main Home Fr`) - b.AssertFileContent("public/en/mysection/index.html", `Baseof mysection: Main Default List`) - b.AssertFileContent("public/en/mysection/p1/index.html", `Baseof mysection: Main Default Single`) - }) + b.AssertFileContent("public/en/index.html", `Baseof en: Main Home En`) + b.AssertFileContent("public/fr/index.html", `Baseof fr: Main Home Fr`) + b.AssertFileContent("public/en/mysection/index.html", `Baseof mysection: Main Default List`) + b.AssertFileContent("public/en/mysection/p1/index.html", `Baseof mysection: Main Default Single`) } func TestTemplateFuncs(t *testing.T) { @@ -707,6 +715,7 @@ a: {{ $a }} b.AssertFileContent("public/index.html", `a: [a b c]`) } +// Legacy behavior for internal templates. func TestOverrideInternalTemplate(t *testing.T) { files := ` -- hugo.toml -- diff --git a/identity/identity.go b/identity/identity.go index 53169dfe1..c78ed0fdd 100644 --- a/identity/identity.go +++ b/identity/identity.go @@ -509,6 +509,10 @@ func probablyEq(a, b Identity) bool { return true } + if a2, ok := a.(compare.ProbablyEqer); ok && a2.ProbablyEq(b) { + return true + } + if a2, ok := a.(IsProbablyDependentProvider); ok { return a2.IsProbablyDependent(b) } diff --git a/internal/js/esbuild/batch.go b/internal/js/esbuild/batch.go index c5394ac0a..aa50cf2c1 100644 --- a/internal/js/esbuild/batch.go +++ b/internal/js/esbuild/batch.go @@ -43,7 +43,7 @@ import ( "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/resources/resource_factories/create" - "github.com/gohugoio/hugo/tpl" + "github.com/gohugoio/hugo/tpl/tplimpl" "github.com/mitchellh/mapstructure" "github.com/spf13/cast" ) @@ -192,7 +192,7 @@ type BatcherClient struct { d *deps.Deps once sync.Once - runnerTemplate tpl.Template + runnerTemplate *tplimpl.TemplInfo createClient *create.Client buildClient *BuildClient @@ -208,7 +208,7 @@ func (c *BatcherClient) New(id string) (js.Batcher, error) { c.once.Do(func() { // We should fix the initialization order here (or use the Go template package directly), but we need to wait // for the Hugo templates to be ready. - tmpl, err := c.d.TextTmpl().Parse("batch-esm-runner", runnerTemplateStr) + tmpl, err := c.d.TemplateStore.TextParse("batch-esm-runner", runnerTemplateStr) if err != nil { initErr = err return @@ -287,7 +287,7 @@ func (c *BatcherClient) Store() *maps.Cache[string, js.Batcher] { func (c *BatcherClient) buildBatchGroup(ctx context.Context, t *batchGroupTemplateContext) (resource.Resource, string, error) { var buf bytes.Buffer - if err := c.d.Tmpl().ExecuteWithContext(ctx, c.runnerTemplate, &buf, t); err != nil { + if err := c.d.GetTemplateStore().ExecuteWithContext(ctx, c.runnerTemplate, &buf, t); err != nil { return nil, "", err } diff --git a/langs/i18n/i18n_test.go b/langs/i18n/i18n_test.go index 8d34e069d..a23cee539 100644 --- a/langs/i18n/i18n_test.go +++ b/langs/i18n/i18n_test.go @@ -23,8 +23,6 @@ import ( "github.com/gohugoio/hugo/common/types" "github.com/gohugoio/hugo/config/testconfig" - "github.com/gohugoio/hugo/tpl/tplimpl" - "github.com/gohugoio/hugo/resources/page" "github.com/spf13/afero" @@ -472,7 +470,6 @@ func prepareTranslationProvider(t testing.TB, test i18nTest, cfg config.Provider func prepareDeps(afs afero.Fs, cfg config.Provider) (*deps.Deps, *TranslationProvider) { d := testconfig.GetTestDeps(afs, cfg) translationProvider := NewTranslationProvider() - d.TemplateProvider = tplimpl.DefaultTemplateProvider d.TranslationProvider = translationProvider d.Site = page.NewDummyHugoSite(d.Conf) if err := d.Compile(nil); err != nil { diff --git a/markup/goldmark/codeblocks/codeblocks_integration_test.go b/markup/goldmark/codeblocks/codeblocks_integration_test.go index 8ed691302..97f4eb384 100644 --- a/markup/goldmark/codeblocks/codeblocks_integration_test.go +++ b/markup/goldmark/codeblocks/codeblocks_integration_test.go @@ -69,7 +69,7 @@ fmt.Println("Hello, World!"); ## Golang Code -§§§golang +§§§go fmt.Println("Hello, Golang!"); §§§ @@ -97,14 +97,14 @@ Go Language: go| Go Code: fmt.Println("Hello, World!"); Go Code: fmt.Println("Hello, Golang!"); -Go Language: golang| +Go Language: go| `, "Goat SVG:Go Code\nGo Code: fmt.Println(\"Hello, World!\");\n|\nGo Language: go|", - "

Golang Code

\nGo Code: fmt.Println(\"Hello, Golang!\");\n|\nGo Language: golang|", + "

Golang Code

\nGo Code: fmt.Println(\"Hello, Golang!\");\n|\nGo Language: go|", "

Bash Code

\n
32echo "l1";\n33",
 	)
 }
diff --git a/media/builtin.go b/media/builtin.go
index ee35b7d08..41d1ed655 100644
--- a/media/builtin.go
+++ b/media/builtin.go
@@ -5,6 +5,7 @@ type BuiltinTypes struct {
 	CSSType        Type
 	SCSSType       Type
 	SASSType       Type
+	GotmplType     Type
 	CSVType        Type
 	HTMLType       Type
 	JavascriptType Type
@@ -60,6 +61,7 @@ var Builtin = BuiltinTypes{
 	CSSType:        Type{Type: "text/css"},
 	SCSSType:       Type{Type: "text/x-scss"},
 	SASSType:       Type{Type: "text/x-sass"},
+	GotmplType:     Type{Type: "text/x-gotmpl"},
 	CSVType:        Type{Type: "text/csv"},
 	HTMLType:       Type{Type: "text/html"},
 	JavascriptType: Type{Type: "text/javascript"},
@@ -121,6 +123,7 @@ var defaultMediaTypesConfig = map[string]any{
 	"text/typescript": map[string]any{"suffixes": []string{"ts"}},
 	"text/tsx":        map[string]any{"suffixes": []string{"tsx"}},
 	"text/jsx":        map[string]any{"suffixes": []string{"jsx"}},
+	"text/x-gotmpl":   map[string]any{"suffixes": []string{"gotmpl"}},
 
 	"application/json":          map[string]any{"suffixes": []string{"json"}},
 	"application/manifest+json": map[string]any{"suffixes": []string{"webmanifest"}},
diff --git a/media/config.go b/media/config.go
index 394159d04..6d3687a4f 100644
--- a/media/config.go
+++ b/media/config.go
@@ -17,6 +17,7 @@ import (
 	"fmt"
 	"path/filepath"
 	"reflect"
+	"slices"
 	"sort"
 	"strings"
 
@@ -26,7 +27,6 @@ import (
 
 	"github.com/mitchellh/mapstructure"
 	"github.com/spf13/cast"
-	"slices"
 )
 
 // DefaultTypes is the default media types supported by Hugo.
@@ -271,4 +271,7 @@ var DefaultPathParser = &paths.PathParser{
 	IsContentExt: func(ext string) bool {
 		panic("not supported")
 	},
+	IsOutputFormat: func(name, ext string) bool {
+		panic("DefaultPathParser: not supported")
+	},
 }
diff --git a/media/config_test.go b/media/config_test.go
index 634686060..5abbcac2f 100644
--- a/media/config_test.go
+++ b/media/config_test.go
@@ -151,5 +151,5 @@ func TestDefaultTypes(t *testing.T) {
 
 	}
 
-	c.Assert(len(DefaultTypes), qt.Equals, 40)
+	c.Assert(len(DefaultTypes), qt.Equals, 41)
 }
diff --git a/media/mediaType.go b/media/mediaType.go
index 97b10879c..b3b615444 100644
--- a/media/mediaType.go
+++ b/media/mediaType.go
@@ -282,7 +282,7 @@ func (t Types) BySuffix(suffix string) []Type {
 	suffix = t.normalizeSuffix(suffix)
 	var types []Type
 	for _, tt := range t {
-		if tt.hasSuffix(suffix) {
+		if tt.HasSuffix(suffix) {
 			types = append(types, tt)
 		}
 	}
@@ -293,7 +293,7 @@ func (t Types) BySuffix(suffix string) []Type {
 func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) {
 	suffix = t.normalizeSuffix(suffix)
 	for _, tt := range t {
-		if tt.hasSuffix(suffix) {
+		if tt.HasSuffix(suffix) {
 			return tt, SuffixInfo{
 				FullSuffix: tt.Delimiter + suffix,
 				Suffix:     suffix,
@@ -310,7 +310,7 @@ func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) {
 func (t Types) GetBySuffix(suffix string) (tp Type, si SuffixInfo, found bool) {
 	suffix = t.normalizeSuffix(suffix)
 	for _, tt := range t {
-		if tt.hasSuffix(suffix) {
+		if tt.HasSuffix(suffix) {
 			if found {
 				// ambiguous
 				found = false
@@ -330,14 +330,14 @@ func (t Types) GetBySuffix(suffix string) (tp Type, si SuffixInfo, found bool) {
 func (t Types) IsTextSuffix(suffix string) bool {
 	suffix = t.normalizeSuffix(suffix)
 	for _, tt := range t {
-		if tt.hasSuffix(suffix) {
+		if tt.HasSuffix(suffix) {
 			return tt.IsText()
 		}
 	}
 	return false
 }
 
-func (m Type) hasSuffix(suffix string) bool {
+func (m Type) HasSuffix(suffix string) bool {
 	return strings.Contains(","+m.SuffixesCSV+",", ","+suffix+",")
 }
 
diff --git a/output/docshelper.go b/output/docshelper.go
index 0f2a0eff6..387c011ad 100644
--- a/output/docshelper.go
+++ b/output/docshelper.go
@@ -1,12 +1,10 @@
 package output
 
 import (
-	"strings"
 
 	//	"fmt"
 
 	"github.com/gohugoio/hugo/docshelper"
-	"github.com/gohugoio/hugo/output/layouts"
 )
 
 // This is is just some helpers used to create some JSON used in the Hugo docs.
@@ -14,90 +12,12 @@ func init() {
 	docsProvider := func() docshelper.DocProvider {
 		return docshelper.DocProvider{
 			"output": map[string]any{
-				"layouts": createLayoutExamples(),
+				// TODO(bep), maybe revisit this later, but I hope this isn't needed.
+				// "layouts": createLayoutExamples(),
+				"layouts": map[string]any{},
 			},
 		}
 	}
 
 	docshelper.AddDocProviderFunc(docsProvider)
 }
-
-func createLayoutExamples() any {
-	type Example struct {
-		Example      string
-		Kind         string
-		OutputFormat string
-		Suffix       string
-		Layouts      []string `json:"Template Lookup Order"`
-	}
-
-	var (
-		basicExamples []Example
-		demoLayout    = "demolayout"
-		demoType      = "demotype"
-	)
-
-	for _, example := range []struct {
-		name string
-		d    layouts.LayoutDescriptor
-	}{
-		// Taxonomy layouts.LayoutDescriptor={categories category taxonomy en  false Type Section
-		{"Single page in \"posts\" section", layouts.LayoutDescriptor{Kind: "page", Type: "posts", OutputFormatName: "html", Suffix: "html"}},
-		{"Base template for single page in \"posts\" section", layouts.LayoutDescriptor{Baseof: true, Kind: "page", Type: "posts", OutputFormatName: "html", Suffix: "html"}},
-		{"Single page in \"posts\" section with layout set to \"demolayout\"", layouts.LayoutDescriptor{Kind: "page", Type: "posts", Layout: demoLayout, OutputFormatName: "html", Suffix: "html"}},
-		{"Base template for single page in \"posts\" section with layout set to \"demolayout\"", layouts.LayoutDescriptor{Baseof: true, Kind: "page", Type: "posts", Layout: demoLayout, OutputFormatName: "html", Suffix: "html"}},
-		{"AMP single page in \"posts\" section", layouts.LayoutDescriptor{Kind: "page", Type: "posts", OutputFormatName: "amp", Suffix: "html"}},
-		{"AMP single page in \"posts\" section, French language", layouts.LayoutDescriptor{Kind: "page", Type: "posts", Lang: "fr", OutputFormatName: "amp", Suffix: "html"}},
-		// Typeless pages get "page" as type
-		{"Home page", layouts.LayoutDescriptor{Kind: "home", Type: "page", OutputFormatName: "html", Suffix: "html"}},
-		{"Base template for home page", layouts.LayoutDescriptor{Baseof: true, Kind: "home", Type: "page", OutputFormatName: "html", Suffix: "html"}},
-		{"Home page with type set to \"demotype\"", layouts.LayoutDescriptor{Kind: "home", Type: demoType, OutputFormatName: "html", Suffix: "html"}},
-		{"Base template for home page with type set to \"demotype\"", layouts.LayoutDescriptor{Baseof: true, Kind: "home", Type: demoType, OutputFormatName: "html", Suffix: "html"}},
-		{"Home page with layout set to \"demolayout\"", layouts.LayoutDescriptor{Kind: "home", Type: "page", Layout: demoLayout, OutputFormatName: "html", Suffix: "html"}},
-		{"AMP home, French language", layouts.LayoutDescriptor{Kind: "home", Type: "page", Lang: "fr", OutputFormatName: "amp", Suffix: "html"}},
-		{"JSON home", layouts.LayoutDescriptor{Kind: "home", Type: "page", OutputFormatName: "json", Suffix: "json"}},
-		{"RSS home", layouts.LayoutDescriptor{Kind: "home", Type: "page", OutputFormatName: "rss", Suffix: "xml"}},
-
-		{"Section list for \"posts\"", layouts.LayoutDescriptor{Kind: "section", Type: "posts", Section: "posts", OutputFormatName: "html", Suffix: "html"}},
-		{"Section list for \"posts\" with type set to \"blog\"", layouts.LayoutDescriptor{Kind: "section", Type: "blog", Section: "posts", OutputFormatName: "html", Suffix: "html"}},
-		{"Section list for \"posts\" with layout set to \"demolayout\"", layouts.LayoutDescriptor{Kind: "section", Layout: demoLayout, Section: "posts", OutputFormatName: "html", Suffix: "html"}},
-		{"Section list for \"posts\"", layouts.LayoutDescriptor{Kind: "section", Type: "posts", OutputFormatName: "rss", Suffix: "xml"}},
-
-		{"Taxonomy list for \"categories\"", layouts.LayoutDescriptor{Kind: "taxonomy", Type: "categories", Section: "category", OutputFormatName: "html", Suffix: "html"}},
-		{"Taxonomy list for \"categories\"", layouts.LayoutDescriptor{Kind: "taxonomy", Type: "categories", Section: "category", OutputFormatName: "rss", Suffix: "xml"}},
-
-		{"Term list for \"categories\"", layouts.LayoutDescriptor{Kind: "term", Type: "categories", Section: "category", OutputFormatName: "html", Suffix: "html"}},
-		{"Term list for \"categories\"", layouts.LayoutDescriptor{Kind: "term", Type: "categories", Section: "category", OutputFormatName: "rss", Suffix: "xml"}},
-	} {
-
-		l := layouts.NewLayoutHandler()
-		layouts, _ := l.For(example.d)
-
-		basicExamples = append(basicExamples, Example{
-			Example:      example.name,
-			Kind:         example.d.Kind,
-			OutputFormat: example.d.OutputFormatName,
-			Suffix:       example.d.Suffix,
-			Layouts:      makeLayoutsPresentable(layouts),
-		})
-	}
-
-	return basicExamples
-}
-
-func makeLayoutsPresentable(l []string) []string {
-	var filtered []string
-	for _, ll := range l {
-		if strings.Contains(ll, "page/") {
-			// This is a valid lookup, but it's more confusing than useful.
-			continue
-		}
-		ll = "layouts/" + strings.TrimPrefix(ll, "_text/")
-
-		if !strings.Contains(ll, "indexes") {
-			filtered = append(filtered, ll)
-		}
-	}
-
-	return filtered
-}
diff --git a/output/layouts/layout.go b/output/layouts/layout.go
deleted file mode 100644
index 79f718dda..000000000
--- a/output/layouts/layout.go
+++ /dev/null
@@ -1,336 +0,0 @@
-// Copyright 2024 The Hugo Authors. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package layouts
-
-import (
-	"strings"
-	"sync"
-)
-
-// These may be used as content sections with potential conflicts. Avoid that.
-var reservedSections = map[string]bool{
-	"shortcodes": true,
-	"partials":   true,
-}
-
-// LayoutDescriptor describes how a layout should be chosen. This is
-// typically built from a Page.
-type LayoutDescriptor struct {
-	Type    string
-	Section string
-
-	// E.g. "page", but also used for the _markup render kinds, e.g. "render-image".
-	Kind string
-
-	// Comma-separated list of kind variants, e.g. "go,json" as variants which would find "render-codeblock-go.html"
-	KindVariants string
-
-	Lang   string
-	Layout string
-	// LayoutOverride indicates what we should only look for the above layout.
-	LayoutOverride bool
-
-	// From OutputFormat and MediaType.
-	OutputFormatName string
-	Suffix           string
-
-	RenderingHook bool
-	Baseof        bool
-}
-
-func (d LayoutDescriptor) isList() bool {
-	return !d.RenderingHook && d.Kind != "page" && d.Kind != "404" && d.Kind != "sitemap" && d.Kind != "sitemapindex"
-}
-
-// LayoutHandler calculates the layout template to use to render a given output type.
-type LayoutHandler struct {
-	mu    sync.RWMutex
-	cache map[LayoutDescriptor][]string
-}
-
-// NewLayoutHandler creates a new LayoutHandler.
-func NewLayoutHandler() *LayoutHandler {
-	return &LayoutHandler{cache: make(map[LayoutDescriptor][]string)}
-}
-
-// For returns a layout for the given LayoutDescriptor and options.
-// Layouts are rendered and cached internally.
-func (l *LayoutHandler) For(d LayoutDescriptor) ([]string, error) {
-	// We will get lots of requests for the same layouts, so avoid recalculations.
-	l.mu.RLock()
-	if cacheVal, found := l.cache[d]; found {
-		l.mu.RUnlock()
-		return cacheVal, nil
-	}
-	l.mu.RUnlock()
-
-	layouts := resolvePageTemplate(d)
-
-	layouts = uniqueStringsReuse(layouts)
-
-	l.mu.Lock()
-	l.cache[d] = layouts
-	l.mu.Unlock()
-
-	return layouts, nil
-}
-
-type layoutBuilder struct {
-	layoutVariations []string
-	typeVariations   []string
-	d                LayoutDescriptor
-	// f                Format
-}
-
-func (l *layoutBuilder) addLayoutVariations(vars ...string) {
-	for _, layoutVar := range vars {
-		if l.d.Baseof && layoutVar != "baseof" {
-			l.layoutVariations = append(l.layoutVariations, layoutVar+"-baseof")
-			continue
-		}
-		if !l.d.RenderingHook && !l.d.Baseof && l.d.LayoutOverride && layoutVar != l.d.Layout {
-			continue
-		}
-		l.layoutVariations = append(l.layoutVariations, layoutVar)
-	}
-}
-
-func (l *layoutBuilder) addTypeVariations(vars ...string) {
-	for _, typeVar := range vars {
-		if !reservedSections[typeVar] {
-			if l.d.RenderingHook {
-				typeVar = typeVar + renderingHookRoot
-			}
-			l.typeVariations = append(l.typeVariations, typeVar)
-		}
-	}
-}
-
-func (l *layoutBuilder) addSectionType() {
-	if l.d.Section != "" {
-		l.addTypeVariations(l.d.Section)
-	}
-}
-
-func (l *layoutBuilder) addKind() {
-	l.addLayoutVariations(l.d.Kind)
-	l.addTypeVariations(l.d.Kind)
-}
-
-const renderingHookRoot = "/_markup"
-
-func resolvePageTemplate(d LayoutDescriptor) []string {
-	b := &layoutBuilder{d: d}
-
-	if !d.RenderingHook && d.Layout != "" {
-		b.addLayoutVariations(d.Layout)
-	}
-	if d.Type != "" {
-		b.addTypeVariations(d.Type)
-	}
-
-	if d.RenderingHook {
-		if d.KindVariants != "" {
-			// Add the more specific variants first.
-			for _, variant := range strings.Split(d.KindVariants, ",") {
-				b.addLayoutVariations(d.Kind + "-" + variant)
-			}
-		}
-		b.addLayoutVariations(d.Kind)
-		b.addSectionType()
-	}
-
-	switch d.Kind {
-	case "page":
-		b.addLayoutVariations("single")
-		b.addSectionType()
-	case "home":
-		b.addLayoutVariations("index", "home")
-		// Also look in the root
-		b.addTypeVariations("")
-	case "section":
-		if d.Section != "" {
-			b.addLayoutVariations(d.Section)
-		}
-		b.addSectionType()
-		b.addKind()
-	case "term":
-		b.addKind()
-		if d.Section != "" {
-			b.addLayoutVariations(d.Section)
-		}
-		b.addLayoutVariations("taxonomy")
-		b.addTypeVariations("taxonomy")
-		b.addSectionType()
-	case "taxonomy":
-		if d.Section != "" {
-			b.addLayoutVariations(d.Section + ".terms")
-		}
-		b.addSectionType()
-		b.addLayoutVariations("terms")
-		// For legacy reasons this is deliberately put last.
-		b.addKind()
-	case "404":
-		b.addLayoutVariations("404")
-		b.addTypeVariations("")
-	case "robotstxt":
-		b.addLayoutVariations("robots")
-		b.addTypeVariations("")
-	case "sitemap":
-		b.addLayoutVariations("sitemap")
-		b.addTypeVariations("")
-	case "sitemapindex":
-		b.addLayoutVariations("sitemapindex")
-		b.addTypeVariations("")
-	}
-
-	isRSS := d.OutputFormatName == "rss"
-	if !d.RenderingHook && !d.Baseof && isRSS {
-		// The historic and common rss.xml case
-		b.addLayoutVariations("")
-	}
-
-	if d.Baseof || d.Kind != "404" {
-		// Most have _default in their lookup path
-		b.addTypeVariations("_default")
-	}
-
-	if d.isList() {
-		// Add the common list type
-		b.addLayoutVariations("list")
-	}
-
-	if d.Baseof {
-		b.addLayoutVariations("baseof")
-	}
-
-	layouts := b.resolveVariations()
-
-	if !d.RenderingHook && !d.Baseof && isRSS {
-		layouts = append(layouts, "_internal/_default/rss.xml")
-	}
-
-	switch d.Kind {
-	case "robotstxt":
-		layouts = append(layouts, "_internal/_default/robots.txt")
-	case "sitemap":
-		layouts = append(layouts, "_internal/_default/sitemap.xml")
-	case "sitemapindex":
-		layouts = append(layouts, "_internal/_default/sitemapindex.xml")
-	}
-
-	return layouts
-}
-
-func (l *layoutBuilder) resolveVariations() []string {
-	var layouts []string
-
-	var variations []string
-	name := strings.ToLower(l.d.OutputFormatName)
-
-	if l.d.Lang != "" {
-		// We prefer the most specific type before language.
-		variations = append(variations, []string{l.d.Lang + "." + name, name, l.d.Lang}...)
-	} else {
-		variations = append(variations, name)
-	}
-
-	variations = append(variations, "")
-
-	for _, typeVar := range l.typeVariations {
-		for _, variation := range variations {
-			for _, layoutVar := range l.layoutVariations {
-				if variation == "" && layoutVar == "" {
-					continue
-				}
-
-				s := constructLayoutPath(typeVar, layoutVar, variation, l.d.Suffix)
-				if s != "" {
-					layouts = append(layouts, s)
-				}
-			}
-		}
-	}
-
-	return layouts
-}
-
-// constructLayoutPath constructs a layout path given a type, layout,
-// variations, and extension.  The path constructed follows the pattern of
-// type/layout.variations.extension.  If any value is empty, it will be left out
-// of the path construction.
-//
-// Path construction requires at least 2 of 3 out of layout, variations, and extension.
-// If more than one of those is empty, an empty string is returned.
-func constructLayoutPath(typ, layout, variations, extension string) string {
-	// we already know that layout and variations are not both empty because of
-	// checks in resolveVariants().
-	if extension == "" && (layout == "" || variations == "") {
-		return ""
-	}
-
-	// Commence valid path construction...
-
-	var (
-		p       strings.Builder
-		needDot bool
-	)
-
-	if typ != "" {
-		p.WriteString(typ)
-		p.WriteString("/")
-	}
-
-	if layout != "" {
-		p.WriteString(layout)
-		needDot = true
-	}
-
-	if variations != "" {
-		if needDot {
-			p.WriteString(".")
-		}
-		p.WriteString(variations)
-		needDot = true
-	}
-
-	if extension != "" {
-		if needDot {
-			p.WriteString(".")
-		}
-		p.WriteString(extension)
-	}
-
-	return p.String()
-}
-
-// Inline this here so we can use tinygo to compile a wasm binary of this package.
-func uniqueStringsReuse(s []string) []string {
-	result := s[:0]
-	for i, val := range s {
-		var seen bool
-
-		for j := range i {
-			if s[j] == val {
-				seen = true
-				break
-			}
-		}
-
-		if !seen {
-			result = append(result, val)
-		}
-	}
-	return result
-}
diff --git a/output/layouts/layout_test.go b/output/layouts/layout_test.go
deleted file mode 100644
index b6033247c..000000000
--- a/output/layouts/layout_test.go
+++ /dev/null
@@ -1,982 +0,0 @@
-// Copyright 2017-present The Hugo Authors. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package layouts
-
-import (
-	"fmt"
-	"reflect"
-	"strings"
-	"testing"
-
-	qt "github.com/frankban/quicktest"
-	"github.com/kylelemons/godebug/diff"
-)
-
-func TestLayout(t *testing.T) {
-	c := qt.New(t)
-
-	for _, this := range []struct {
-		name             string
-		layoutDescriptor LayoutDescriptor
-		layoutOverride   string
-		expect           []string
-	}{
-		{
-			"Home",
-			LayoutDescriptor{Kind: "home", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"index.amp.html",
-				"home.amp.html",
-				"list.amp.html",
-				"index.html",
-				"home.html",
-				"list.html",
-				"_default/index.amp.html",
-				"_default/home.amp.html",
-				"_default/list.amp.html",
-				"_default/index.html",
-				"_default/home.html",
-				"_default/list.html",
-			},
-		},
-		{
-			"Home baseof",
-			LayoutDescriptor{Kind: "home", Baseof: true, OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"index-baseof.amp.html",
-				"home-baseof.amp.html",
-				"list-baseof.amp.html",
-				"baseof.amp.html",
-				"index-baseof.html",
-				"home-baseof.html",
-				"list-baseof.html",
-				"baseof.html",
-				"_default/index-baseof.amp.html",
-				"_default/home-baseof.amp.html",
-				"_default/list-baseof.amp.html",
-				"_default/baseof.amp.html",
-				"_default/index-baseof.html",
-				"_default/home-baseof.html",
-				"_default/list-baseof.html",
-				"_default/baseof.html",
-			},
-		},
-		{
-			"Home, HTML",
-			LayoutDescriptor{Kind: "home", OutputFormatName: "html", Suffix: "html"},
-			"",
-			// We will eventually get to index.html. This looks stuttery, but makes the lookup logic easy to understand.
-			[]string{
-				"index.html.html",
-				"home.html.html",
-				"list.html.html",
-				"index.html",
-				"home.html",
-				"list.html",
-				"_default/index.html.html",
-				"_default/home.html.html",
-				"_default/list.html.html",
-				"_default/index.html",
-				"_default/home.html",
-				"_default/list.html",
-			},
-		},
-		{
-			"Home, HTML, baseof",
-			LayoutDescriptor{Kind: "home", Baseof: true, OutputFormatName: "html", Suffix: "html"},
-			"",
-			[]string{
-				"index-baseof.html.html",
-				"home-baseof.html.html",
-				"list-baseof.html.html",
-				"baseof.html.html",
-				"index-baseof.html",
-				"home-baseof.html",
-				"list-baseof.html",
-				"baseof.html",
-				"_default/index-baseof.html.html",
-				"_default/home-baseof.html.html",
-				"_default/list-baseof.html.html",
-				"_default/baseof.html.html",
-				"_default/index-baseof.html",
-				"_default/home-baseof.html",
-				"_default/list-baseof.html",
-				"_default/baseof.html",
-			},
-		},
-		{
-			"Home, french language",
-			LayoutDescriptor{Kind: "home", Lang: "fr", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"index.fr.amp.html",
-				"home.fr.amp.html",
-				"list.fr.amp.html",
-				"index.amp.html",
-				"home.amp.html",
-				"list.amp.html",
-				"index.fr.html",
-				"home.fr.html",
-				"list.fr.html",
-				"index.html",
-				"home.html",
-				"list.html",
-				"_default/index.fr.amp.html",
-				"_default/home.fr.amp.html",
-				"_default/list.fr.amp.html",
-				"_default/index.amp.html",
-				"_default/home.amp.html",
-				"_default/list.amp.html",
-				"_default/index.fr.html",
-				"_default/home.fr.html",
-				"_default/list.fr.html",
-				"_default/index.html",
-				"_default/home.html",
-				"_default/list.html",
-			},
-		},
-		{
-			"Home, no ext or delim",
-			LayoutDescriptor{Kind: "home", OutputFormatName: "nem", Suffix: ""},
-			"",
-			[]string{
-				"index.nem",
-				"home.nem",
-				"list.nem",
-				"_default/index.nem",
-				"_default/home.nem",
-				"_default/list.nem",
-			},
-		},
-		{
-			"Home, no ext",
-			LayoutDescriptor{Kind: "home", OutputFormatName: "nex", Suffix: ""},
-			"",
-			[]string{
-				"index.nex",
-				"home.nex",
-				"list.nex",
-				"_default/index.nex",
-				"_default/home.nex",
-				"_default/list.nex",
-			},
-		},
-		{
-			"Page, no ext or delim",
-			LayoutDescriptor{Kind: "page", OutputFormatName: "nem", Suffix: ""},
-			"",
-			[]string{"_default/single.nem"},
-		},
-		{
-			"Section",
-			LayoutDescriptor{Kind: "section", Section: "sect1", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"sect1/sect1.amp.html",
-				"sect1/section.amp.html",
-				"sect1/list.amp.html",
-				"sect1/sect1.html",
-				"sect1/section.html",
-				"sect1/list.html",
-				"section/sect1.amp.html",
-				"section/section.amp.html",
-				"section/list.amp.html",
-				"section/sect1.html",
-				"section/section.html",
-				"section/list.html",
-				"_default/sect1.amp.html",
-				"_default/section.amp.html",
-				"_default/list.amp.html",
-				"_default/sect1.html",
-				"_default/section.html",
-				"_default/list.html",
-			},
-		},
-		{
-			"Section, baseof",
-			LayoutDescriptor{Kind: "section", Section: "sect1", Baseof: true, OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"sect1/sect1-baseof.amp.html",
-				"sect1/section-baseof.amp.html",
-				"sect1/list-baseof.amp.html",
-				"sect1/baseof.amp.html",
-				"sect1/sect1-baseof.html",
-				"sect1/section-baseof.html",
-				"sect1/list-baseof.html",
-				"sect1/baseof.html",
-				"section/sect1-baseof.amp.html",
-				"section/section-baseof.amp.html",
-				"section/list-baseof.amp.html",
-				"section/baseof.amp.html",
-				"section/sect1-baseof.html",
-				"section/section-baseof.html",
-				"section/list-baseof.html",
-				"section/baseof.html",
-				"_default/sect1-baseof.amp.html",
-				"_default/section-baseof.amp.html",
-				"_default/list-baseof.amp.html",
-				"_default/baseof.amp.html",
-				"_default/sect1-baseof.html",
-				"_default/section-baseof.html",
-				"_default/list-baseof.html",
-				"_default/baseof.html",
-			},
-		},
-		{
-			"Section, baseof, French, AMP",
-			LayoutDescriptor{Kind: "section", Section: "sect1", Lang: "fr", Baseof: true, OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"sect1/sect1-baseof.fr.amp.html",
-				"sect1/section-baseof.fr.amp.html",
-				"sect1/list-baseof.fr.amp.html",
-				"sect1/baseof.fr.amp.html",
-				"sect1/sect1-baseof.amp.html",
-				"sect1/section-baseof.amp.html",
-				"sect1/list-baseof.amp.html",
-				"sect1/baseof.amp.html",
-				"sect1/sect1-baseof.fr.html",
-				"sect1/section-baseof.fr.html",
-				"sect1/list-baseof.fr.html",
-				"sect1/baseof.fr.html",
-				"sect1/sect1-baseof.html",
-				"sect1/section-baseof.html",
-				"sect1/list-baseof.html",
-				"sect1/baseof.html",
-				"section/sect1-baseof.fr.amp.html",
-				"section/section-baseof.fr.amp.html",
-				"section/list-baseof.fr.amp.html",
-				"section/baseof.fr.amp.html",
-				"section/sect1-baseof.amp.html",
-				"section/section-baseof.amp.html",
-				"section/list-baseof.amp.html",
-				"section/baseof.amp.html",
-				"section/sect1-baseof.fr.html",
-				"section/section-baseof.fr.html",
-				"section/list-baseof.fr.html",
-				"section/baseof.fr.html",
-				"section/sect1-baseof.html",
-				"section/section-baseof.html",
-				"section/list-baseof.html",
-				"section/baseof.html",
-				"_default/sect1-baseof.fr.amp.html",
-				"_default/section-baseof.fr.amp.html",
-				"_default/list-baseof.fr.amp.html",
-				"_default/baseof.fr.amp.html",
-				"_default/sect1-baseof.amp.html",
-				"_default/section-baseof.amp.html",
-				"_default/list-baseof.amp.html",
-				"_default/baseof.amp.html",
-				"_default/sect1-baseof.fr.html",
-				"_default/section-baseof.fr.html",
-				"_default/list-baseof.fr.html",
-				"_default/baseof.fr.html",
-				"_default/sect1-baseof.html",
-				"_default/section-baseof.html",
-				"_default/list-baseof.html",
-				"_default/baseof.html",
-			},
-		},
-		{
-			"Section with layout",
-			LayoutDescriptor{Kind: "section", Section: "sect1", Layout: "mylayout", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"sect1/mylayout.amp.html",
-				"sect1/sect1.amp.html",
-				"sect1/section.amp.html",
-				"sect1/list.amp.html",
-				"sect1/mylayout.html",
-				"sect1/sect1.html",
-				"sect1/section.html",
-				"sect1/list.html",
-				"section/mylayout.amp.html",
-				"section/sect1.amp.html",
-				"section/section.amp.html",
-				"section/list.amp.html",
-				"section/mylayout.html",
-				"section/sect1.html",
-				"section/section.html",
-				"section/list.html",
-				"_default/mylayout.amp.html",
-				"_default/sect1.amp.html",
-				"_default/section.amp.html",
-				"_default/list.amp.html",
-				"_default/mylayout.html",
-				"_default/sect1.html",
-				"_default/section.html",
-				"_default/list.html",
-			},
-		},
-		{
-			"Term, French, AMP",
-			LayoutDescriptor{Kind: "term", Section: "tags", Lang: "fr", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"term/term.fr.amp.html",
-				"term/tags.fr.amp.html",
-				"term/taxonomy.fr.amp.html",
-				"term/list.fr.amp.html",
-				"term/term.amp.html",
-				"term/tags.amp.html",
-				"term/taxonomy.amp.html",
-				"term/list.amp.html",
-				"term/term.fr.html",
-				"term/tags.fr.html",
-				"term/taxonomy.fr.html",
-				"term/list.fr.html",
-				"term/term.html",
-				"term/tags.html",
-				"term/taxonomy.html",
-				"term/list.html",
-				"taxonomy/term.fr.amp.html",
-				"taxonomy/tags.fr.amp.html",
-				"taxonomy/taxonomy.fr.amp.html",
-				"taxonomy/list.fr.amp.html",
-				"taxonomy/term.amp.html",
-				"taxonomy/tags.amp.html",
-				"taxonomy/taxonomy.amp.html",
-				"taxonomy/list.amp.html",
-				"taxonomy/term.fr.html",
-				"taxonomy/tags.fr.html",
-				"taxonomy/taxonomy.fr.html",
-				"taxonomy/list.fr.html",
-				"taxonomy/term.html",
-				"taxonomy/tags.html",
-				"taxonomy/taxonomy.html",
-				"taxonomy/list.html",
-				"tags/term.fr.amp.html",
-				"tags/tags.fr.amp.html",
-				"tags/taxonomy.fr.amp.html",
-				"tags/list.fr.amp.html",
-				"tags/term.amp.html",
-				"tags/tags.amp.html",
-				"tags/taxonomy.amp.html",
-				"tags/list.amp.html",
-				"tags/term.fr.html",
-				"tags/tags.fr.html",
-				"tags/taxonomy.fr.html",
-				"tags/list.fr.html",
-				"tags/term.html",
-				"tags/tags.html",
-				"tags/taxonomy.html",
-				"tags/list.html",
-				"_default/term.fr.amp.html",
-				"_default/tags.fr.amp.html",
-				"_default/taxonomy.fr.amp.html",
-				"_default/list.fr.amp.html",
-				"_default/term.amp.html",
-				"_default/tags.amp.html",
-				"_default/taxonomy.amp.html",
-				"_default/list.amp.html",
-				"_default/term.fr.html",
-				"_default/tags.fr.html",
-				"_default/taxonomy.fr.html",
-				"_default/list.fr.html",
-				"_default/term.html",
-				"_default/tags.html",
-				"_default/taxonomy.html",
-				"_default/list.html",
-			},
-		},
-		{
-			"Term, baseof, French, AMP",
-			LayoutDescriptor{Kind: "term", Section: "tags", Lang: "fr", Baseof: true, OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"term/term-baseof.fr.amp.html",
-				"term/tags-baseof.fr.amp.html",
-				"term/taxonomy-baseof.fr.amp.html",
-				"term/list-baseof.fr.amp.html",
-				"term/baseof.fr.amp.html",
-				"term/term-baseof.amp.html",
-				"term/tags-baseof.amp.html",
-				"term/taxonomy-baseof.amp.html",
-				"term/list-baseof.amp.html",
-				"term/baseof.amp.html",
-				"term/term-baseof.fr.html",
-				"term/tags-baseof.fr.html",
-				"term/taxonomy-baseof.fr.html",
-				"term/list-baseof.fr.html",
-				"term/baseof.fr.html",
-				"term/term-baseof.html",
-				"term/tags-baseof.html",
-				"term/taxonomy-baseof.html",
-				"term/list-baseof.html",
-				"term/baseof.html",
-				"taxonomy/term-baseof.fr.amp.html",
-				"taxonomy/tags-baseof.fr.amp.html",
-				"taxonomy/taxonomy-baseof.fr.amp.html",
-				"taxonomy/list-baseof.fr.amp.html",
-				"taxonomy/baseof.fr.amp.html",
-				"taxonomy/term-baseof.amp.html",
-				"taxonomy/tags-baseof.amp.html",
-				"taxonomy/taxonomy-baseof.amp.html",
-				"taxonomy/list-baseof.amp.html",
-				"taxonomy/baseof.amp.html",
-				"taxonomy/term-baseof.fr.html",
-				"taxonomy/tags-baseof.fr.html",
-				"taxonomy/taxonomy-baseof.fr.html",
-				"taxonomy/list-baseof.fr.html",
-				"taxonomy/baseof.fr.html",
-				"taxonomy/term-baseof.html",
-				"taxonomy/tags-baseof.html",
-				"taxonomy/taxonomy-baseof.html",
-				"taxonomy/list-baseof.html",
-				"taxonomy/baseof.html",
-				"tags/term-baseof.fr.amp.html",
-				"tags/tags-baseof.fr.amp.html",
-				"tags/taxonomy-baseof.fr.amp.html",
-				"tags/list-baseof.fr.amp.html",
-				"tags/baseof.fr.amp.html",
-				"tags/term-baseof.amp.html",
-				"tags/tags-baseof.amp.html",
-				"tags/taxonomy-baseof.amp.html",
-				"tags/list-baseof.amp.html",
-				"tags/baseof.amp.html",
-				"tags/term-baseof.fr.html",
-				"tags/tags-baseof.fr.html",
-				"tags/taxonomy-baseof.fr.html",
-				"tags/list-baseof.fr.html",
-				"tags/baseof.fr.html",
-				"tags/term-baseof.html",
-				"tags/tags-baseof.html",
-				"tags/taxonomy-baseof.html",
-				"tags/list-baseof.html",
-				"tags/baseof.html",
-				"_default/term-baseof.fr.amp.html",
-				"_default/tags-baseof.fr.amp.html",
-				"_default/taxonomy-baseof.fr.amp.html",
-				"_default/list-baseof.fr.amp.html",
-				"_default/baseof.fr.amp.html",
-				"_default/term-baseof.amp.html",
-				"_default/tags-baseof.amp.html",
-				"_default/taxonomy-baseof.amp.html",
-				"_default/list-baseof.amp.html",
-				"_default/baseof.amp.html",
-				"_default/term-baseof.fr.html",
-				"_default/tags-baseof.fr.html",
-				"_default/taxonomy-baseof.fr.html",
-				"_default/list-baseof.fr.html",
-				"_default/baseof.fr.html",
-				"_default/term-baseof.html",
-				"_default/tags-baseof.html",
-				"_default/taxonomy-baseof.html",
-				"_default/list-baseof.html",
-				"_default/baseof.html",
-			},
-		},
-		{
-			"Term",
-			LayoutDescriptor{Kind: "term", Section: "tags", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"term/term.amp.html",
-				"term/tags.amp.html",
-				"term/taxonomy.amp.html",
-				"term/list.amp.html",
-				"term/term.html",
-				"term/tags.html",
-				"term/taxonomy.html",
-				"term/list.html",
-				"taxonomy/term.amp.html",
-				"taxonomy/tags.amp.html",
-				"taxonomy/taxonomy.amp.html",
-				"taxonomy/list.amp.html",
-				"taxonomy/term.html",
-				"taxonomy/tags.html",
-				"taxonomy/taxonomy.html",
-				"taxonomy/list.html",
-				"tags/term.amp.html",
-				"tags/tags.amp.html",
-				"tags/taxonomy.amp.html",
-				"tags/list.amp.html",
-				"tags/term.html",
-				"tags/tags.html",
-				"tags/taxonomy.html",
-				"tags/list.html",
-				"_default/term.amp.html",
-				"_default/tags.amp.html",
-				"_default/taxonomy.amp.html",
-				"_default/list.amp.html",
-				"_default/term.html",
-				"_default/tags.html",
-				"_default/taxonomy.html",
-				"_default/list.html",
-			},
-		},
-		{
-			"Taxonomy",
-			LayoutDescriptor{Kind: "taxonomy", Section: "categories", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"categories/categories.terms.amp.html",
-				"categories/terms.amp.html",
-				"categories/taxonomy.amp.html",
-				"categories/list.amp.html",
-				"categories/categories.terms.html",
-				"categories/terms.html",
-				"categories/taxonomy.html",
-				"categories/list.html",
-				"taxonomy/categories.terms.amp.html",
-				"taxonomy/terms.amp.html",
-				"taxonomy/taxonomy.amp.html",
-				"taxonomy/list.amp.html",
-				"taxonomy/categories.terms.html",
-				"taxonomy/terms.html",
-				"taxonomy/taxonomy.html",
-				"taxonomy/list.html",
-				"_default/categories.terms.amp.html",
-				"_default/terms.amp.html",
-				"_default/taxonomy.amp.html",
-				"_default/list.amp.html",
-				"_default/categories.terms.html",
-				"_default/terms.html",
-				"_default/taxonomy.html",
-				"_default/list.html",
-			},
-		},
-		{
-			"Page",
-			LayoutDescriptor{Kind: "page", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"_default/single.amp.html",
-				"_default/single.html",
-			},
-		},
-		{
-			"Page, baseof",
-			LayoutDescriptor{Kind: "page", Baseof: true, OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"_default/single-baseof.amp.html",
-				"_default/baseof.amp.html",
-				"_default/single-baseof.html",
-				"_default/baseof.html",
-			},
-		},
-		{
-			"Page with layout",
-			LayoutDescriptor{Kind: "page", Layout: "mylayout", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"_default/mylayout.amp.html",
-				"_default/single.amp.html",
-				"_default/mylayout.html",
-				"_default/single.html",
-			},
-		},
-		{
-			"Page with layout, baseof",
-			LayoutDescriptor{Kind: "page", Layout: "mylayout", Baseof: true, OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"_default/mylayout-baseof.amp.html",
-				"_default/single-baseof.amp.html",
-				"_default/baseof.amp.html",
-				"_default/mylayout-baseof.html",
-				"_default/single-baseof.html",
-				"_default/baseof.html",
-			},
-		},
-		{
-			"Page with layout and type",
-			LayoutDescriptor{Kind: "page", Layout: "mylayout", Type: "myttype", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"myttype/mylayout.amp.html",
-				"myttype/single.amp.html",
-				"myttype/mylayout.html",
-				"myttype/single.html",
-				"_default/mylayout.amp.html",
-				"_default/single.amp.html",
-				"_default/mylayout.html",
-				"_default/single.html",
-			},
-		},
-		{
-			"Page baseof with layout and type",
-			LayoutDescriptor{Kind: "page", Layout: "mylayout", Type: "myttype", Baseof: true, OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"myttype/mylayout-baseof.amp.html",
-				"myttype/single-baseof.amp.html",
-				"myttype/baseof.amp.html",
-				"myttype/mylayout-baseof.html",
-				"myttype/single-baseof.html",
-				"myttype/baseof.html",
-				"_default/mylayout-baseof.amp.html",
-				"_default/single-baseof.amp.html",
-				"_default/baseof.amp.html",
-				"_default/mylayout-baseof.html",
-				"_default/single-baseof.html",
-				"_default/baseof.html",
-			},
-		},
-		{
-			"Page baseof with layout and type in French",
-			LayoutDescriptor{Kind: "page", Layout: "mylayout", Type: "myttype", Lang: "fr", Baseof: true, OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"myttype/mylayout-baseof.fr.amp.html",
-				"myttype/single-baseof.fr.amp.html",
-				"myttype/baseof.fr.amp.html",
-				"myttype/mylayout-baseof.amp.html",
-				"myttype/single-baseof.amp.html",
-				"myttype/baseof.amp.html",
-				"myttype/mylayout-baseof.fr.html",
-				"myttype/single-baseof.fr.html",
-				"myttype/baseof.fr.html",
-				"myttype/mylayout-baseof.html",
-				"myttype/single-baseof.html",
-				"myttype/baseof.html",
-				"_default/mylayout-baseof.fr.amp.html",
-				"_default/single-baseof.fr.amp.html",
-				"_default/baseof.fr.amp.html",
-				"_default/mylayout-baseof.amp.html",
-				"_default/single-baseof.amp.html",
-				"_default/baseof.amp.html",
-				"_default/mylayout-baseof.fr.html",
-				"_default/single-baseof.fr.html",
-				"_default/baseof.fr.html",
-				"_default/mylayout-baseof.html",
-				"_default/single-baseof.html",
-				"_default/baseof.html",
-			},
-		},
-		{
-			"Page with layout and type with subtype",
-			LayoutDescriptor{Kind: "page", Layout: "mylayout", Type: "myttype/mysubtype", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"myttype/mysubtype/mylayout.amp.html",
-				"myttype/mysubtype/single.amp.html",
-				"myttype/mysubtype/mylayout.html",
-				"myttype/mysubtype/single.html",
-				"_default/mylayout.amp.html",
-				"_default/single.amp.html",
-				"_default/mylayout.html",
-				"_default/single.html",
-			},
-		},
-		// RSS
-		{
-			"RSS Home",
-			LayoutDescriptor{Kind: "home", OutputFormatName: "rss", Suffix: "xml"},
-			"",
-			[]string{
-				"index.rss.xml",
-				"home.rss.xml",
-				"rss.xml",
-				"list.rss.xml",
-				"index.xml",
-				"home.xml",
-				"list.xml",
-				"_default/index.rss.xml",
-				"_default/home.rss.xml",
-				"_default/rss.xml",
-				"_default/list.rss.xml",
-				"_default/index.xml",
-				"_default/home.xml",
-				"_default/list.xml",
-				"_internal/_default/rss.xml",
-			},
-		},
-		{
-			"RSS Home, baseof",
-			LayoutDescriptor{Kind: "home", Baseof: true, OutputFormatName: "rss", Suffix: "xml"},
-			"",
-			[]string{
-				"index-baseof.rss.xml",
-				"home-baseof.rss.xml",
-				"list-baseof.rss.xml",
-				"baseof.rss.xml",
-				"index-baseof.xml",
-				"home-baseof.xml",
-				"list-baseof.xml",
-				"baseof.xml",
-				"_default/index-baseof.rss.xml",
-				"_default/home-baseof.rss.xml",
-				"_default/list-baseof.rss.xml",
-				"_default/baseof.rss.xml",
-				"_default/index-baseof.xml",
-				"_default/home-baseof.xml",
-				"_default/list-baseof.xml",
-				"_default/baseof.xml",
-			},
-		},
-		{
-			"RSS Section",
-			LayoutDescriptor{Kind: "section", Section: "sect1", OutputFormatName: "rss", Suffix: "xml"},
-			"",
-			[]string{
-				"sect1/sect1.rss.xml",
-				"sect1/section.rss.xml",
-				"sect1/rss.xml",
-				"sect1/list.rss.xml",
-				"sect1/sect1.xml",
-				"sect1/section.xml",
-				"sect1/list.xml",
-				"section/sect1.rss.xml",
-				"section/section.rss.xml",
-				"section/rss.xml",
-				"section/list.rss.xml",
-				"section/sect1.xml",
-				"section/section.xml",
-				"section/list.xml",
-				"_default/sect1.rss.xml",
-				"_default/section.rss.xml",
-				"_default/rss.xml",
-				"_default/list.rss.xml",
-				"_default/sect1.xml",
-				"_default/section.xml",
-				"_default/list.xml",
-				"_internal/_default/rss.xml",
-			},
-		},
-		{
-			"RSS Term",
-			LayoutDescriptor{Kind: "term", Section: "tag", OutputFormatName: "rss", Suffix: "xml"},
-			"",
-			[]string{
-				"term/term.rss.xml",
-				"term/tag.rss.xml",
-				"term/taxonomy.rss.xml",
-				"term/rss.xml",
-				"term/list.rss.xml",
-				"term/term.xml",
-				"term/tag.xml",
-				"term/taxonomy.xml",
-				"term/list.xml",
-				"taxonomy/term.rss.xml",
-				"taxonomy/tag.rss.xml",
-				"taxonomy/taxonomy.rss.xml",
-				"taxonomy/rss.xml",
-				"taxonomy/list.rss.xml",
-				"taxonomy/term.xml",
-				"taxonomy/tag.xml",
-				"taxonomy/taxonomy.xml",
-				"taxonomy/list.xml",
-				"tag/term.rss.xml",
-				"tag/tag.rss.xml",
-				"tag/taxonomy.rss.xml",
-				"tag/rss.xml",
-				"tag/list.rss.xml",
-				"tag/term.xml",
-				"tag/tag.xml",
-				"tag/taxonomy.xml",
-				"tag/list.xml",
-				"_default/term.rss.xml",
-				"_default/tag.rss.xml",
-				"_default/taxonomy.rss.xml",
-				"_default/rss.xml",
-				"_default/list.rss.xml",
-				"_default/term.xml",
-				"_default/tag.xml",
-				"_default/taxonomy.xml",
-				"_default/list.xml",
-				"_internal/_default/rss.xml",
-			},
-		},
-		{
-			"RSS Taxonomy",
-			LayoutDescriptor{Kind: "taxonomy", Section: "tag", OutputFormatName: "rss", Suffix: "xml"},
-			"",
-			[]string{
-				"tag/tag.terms.rss.xml",
-				"tag/terms.rss.xml",
-				"tag/taxonomy.rss.xml",
-				"tag/rss.xml",
-				"tag/list.rss.xml",
-				"tag/tag.terms.xml",
-				"tag/terms.xml",
-				"tag/taxonomy.xml",
-				"tag/list.xml",
-				"taxonomy/tag.terms.rss.xml",
-				"taxonomy/terms.rss.xml",
-				"taxonomy/taxonomy.rss.xml",
-				"taxonomy/rss.xml",
-				"taxonomy/list.rss.xml",
-				"taxonomy/tag.terms.xml",
-				"taxonomy/terms.xml",
-				"taxonomy/taxonomy.xml",
-				"taxonomy/list.xml",
-				"_default/tag.terms.rss.xml",
-				"_default/terms.rss.xml",
-				"_default/taxonomy.rss.xml",
-				"_default/rss.xml",
-				"_default/list.rss.xml",
-				"_default/tag.terms.xml",
-				"_default/terms.xml",
-				"_default/taxonomy.xml",
-				"_default/list.xml",
-				"_internal/_default/rss.xml",
-			},
-		},
-		{
-			"Home plain text",
-			LayoutDescriptor{Kind: "home", OutputFormatName: "json", Suffix: "json"},
-			"",
-			[]string{
-				"index.json.json",
-				"home.json.json",
-				"list.json.json",
-				"index.json",
-				"home.json",
-				"list.json",
-				"_default/index.json.json",
-				"_default/home.json.json",
-				"_default/list.json.json",
-				"_default/index.json",
-				"_default/home.json",
-				"_default/list.json",
-			},
-		},
-		{
-			"Page plain text",
-			LayoutDescriptor{Kind: "page", OutputFormatName: "json", Suffix: "json"},
-			"",
-			[]string{
-				"_default/single.json.json",
-				"_default/single.json",
-			},
-		},
-		{
-			"Reserved section, shortcodes",
-			LayoutDescriptor{Kind: "section", Section: "shortcodes", Type: "shortcodes", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"section/shortcodes.amp.html",
-				"section/section.amp.html",
-				"section/list.amp.html",
-				"section/shortcodes.html",
-				"section/section.html",
-				"section/list.html",
-				"_default/shortcodes.amp.html",
-				"_default/section.amp.html",
-				"_default/list.amp.html",
-				"_default/shortcodes.html",
-				"_default/section.html",
-				"_default/list.html",
-			},
-		},
-		{
-			"Reserved section, partials",
-			LayoutDescriptor{Kind: "section", Section: "partials", Type: "partials", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"section/partials.amp.html",
-				"section/section.amp.html",
-				"section/list.amp.html",
-				"section/partials.html",
-				"section/section.html",
-				"section/list.html",
-				"_default/partials.amp.html",
-				"_default/section.amp.html",
-				"_default/list.amp.html",
-				"_default/partials.html",
-				"_default/section.html",
-				"_default/list.html",
-			},
-		},
-		// This is currently always HTML only
-		{
-			"404, HTML",
-			LayoutDescriptor{Kind: "404", OutputFormatName: "html", Suffix: "html"},
-			"",
-			[]string{
-				"404.html.html",
-				"404.html",
-			},
-		},
-		{
-			"404, HTML baseof",
-			LayoutDescriptor{Kind: "404", Baseof: true, OutputFormatName: "html", Suffix: "html"},
-			"",
-			[]string{
-				"404-baseof.html.html",
-				"baseof.html.html",
-				"404-baseof.html",
-				"baseof.html",
-				"_default/404-baseof.html.html",
-				"_default/baseof.html.html",
-				"_default/404-baseof.html",
-				"_default/baseof.html",
-			},
-		},
-		{
-			"Content hook",
-			LayoutDescriptor{Kind: "render-link", RenderingHook: true, Layout: "mylayout", Section: "blog", OutputFormatName: "amp", Suffix: "html"},
-			"",
-			[]string{
-				"blog/_markup/render-link.amp.html",
-				"blog/_markup/render-link.html",
-				"_default/_markup/render-link.amp.html",
-				"_default/_markup/render-link.html",
-			},
-		},
-	} {
-		c.Run(this.name, func(c *qt.C) {
-			l := NewLayoutHandler()
-
-			layouts, err := l.For(this.layoutDescriptor)
-
-			c.Assert(err, qt.IsNil)
-			c.Assert(layouts, qt.Not(qt.IsNil), qt.Commentf(this.layoutDescriptor.Kind))
-
-			if !reflect.DeepEqual(layouts, this.expect) {
-				r := strings.NewReplacer(
-					"[", "\t\"",
-					"]", "\",",
-					" ", "\",\n\t\"",
-				)
-				fmtGot := r.Replace(fmt.Sprintf("%v", layouts))
-				fmtExp := r.Replace(fmt.Sprintf("%v", this.expect))
-
-				c.Fatalf("got %d items, expected %d:\nGot:\n\t%v\nExpected:\n\t%v\nDiff:\n%s", len(layouts), len(this.expect), layouts, this.expect, diff.Diff(fmtExp, fmtGot))
-
-			}
-		})
-	}
-}
-
-/*
-func BenchmarkLayout(b *testing.B) {
-	descriptor := LayoutDescriptor{Kind: "taxonomy", Section: "categories"}
-	l := NewLayoutHandler()
-
-	for i := 0; i < b.N; i++ {
-		_, err := l.For(descriptor, HTMLFormat)
-		if err != nil {
-			panic(err)
-		}
-	}
-}
-
-func BenchmarkLayoutUncached(b *testing.B) {
-	for i := 0; i < b.N; i++ {
-		descriptor := LayoutDescriptor{Kind: "taxonomy", Section: "categories"}
-		l := NewLayoutHandler()
-
-		_, err := l.For(descriptor, HTMLFormat)
-		if err != nil {
-			panic(err)
-		}
-	}
-}
-*/
diff --git a/output/outputFormat.go b/output/outputFormat.go
index d249c72b9..8f3716e3e 100644
--- a/output/outputFormat.go
+++ b/output/outputFormat.go
@@ -133,6 +133,15 @@ var (
 		Weight: 10,
 	}
 
+	// Alias is the output format used for alias redirects.
+	AliasHTMLFormat = Format{
+		Name:          "alias",
+		MediaType:     media.Builtin.HTMLType,
+		IsHTML:        true,
+		Ugly:          true,
+		Permalinkable: false,
+	}
+
 	MarkdownFormat = Format{
 		Name:        "markdown",
 		MediaType:   media.Builtin.MarkdownType,
@@ -192,8 +201,17 @@ var (
 		Rel:       "sitemap",
 	}
 
-	HTTPStatusHTMLFormat = Format{
-		Name:           "httpstatus",
+	GotmplFormat = Format{
+		Name:           "gotmpl",
+		MediaType:      media.Builtin.GotmplType,
+		IsPlainText:    true,
+		NotAlternative: true,
+	}
+
+	// I'm not sure having a 404 format is a good idea,
+	// for one, we would want to have multiple formats for this.
+	HTTPStatus404HTMLFormat = Format{
+		Name:           "404",
 		MediaType:      media.Builtin.HTMLType,
 		NotAlternative: true,
 		Ugly:           true,
@@ -209,12 +227,16 @@ var DefaultFormats = Formats{
 	CSSFormat,
 	CSVFormat,
 	HTMLFormat,
+	GotmplFormat,
+	HTTPStatus404HTMLFormat,
+	AliasHTMLFormat,
 	JSONFormat,
 	MarkdownFormat,
 	WebAppManifestFormat,
 	RobotsTxtFormat,
 	RSSFormat,
 	SitemapFormat,
+	SitemapIndexFormat,
 }
 
 func init() {
diff --git a/output/outputFormat_test.go b/output/outputFormat_test.go
index c86bcd634..5950c590c 100644
--- a/output/outputFormat_test.go
+++ b/output/outputFormat_test.go
@@ -68,7 +68,7 @@ func TestDefaultTypes(t *testing.T) {
 	c.Assert(RSSFormat.NoUgly, qt.Equals, true)
 	c.Assert(CalendarFormat.IsHTML, qt.Equals, false)
 
-	c.Assert(len(DefaultFormats), qt.Equals, 11)
+	c.Assert(len(DefaultFormats), qt.Equals, 15)
 }
 
 func TestGetFormatByName(t *testing.T) {
@@ -140,7 +140,7 @@ func TestGetFormatByFilename(t *testing.T) {
 func TestSort(t *testing.T) {
 	c := qt.New(t)
 	c.Assert(DefaultFormats[0].Name, qt.Equals, "html")
-	c.Assert(DefaultFormats[1].Name, qt.Equals, "amp")
+	c.Assert(DefaultFormats[1].Name, qt.Equals, "404")
 
 	json := JSONFormat
 	json.Weight = 1
diff --git a/resources/kinds/kinds.go b/resources/kinds/kinds.go
index 2660ec719..30bc35e43 100644
--- a/resources/kinds/kinds.go
+++ b/resources/kinds/kinds.go
@@ -34,6 +34,7 @@ const (
 
 	// The following are (currently) temporary nodes,
 	// i.e. nodes we create just to render in isolation.
+	KindTemporary    = "temporary"
 	KindRSS          = "rss"
 	KindSitemap      = "sitemap"
 	KindSitemapIndex = "sitemapindex"
diff --git a/resources/page/page.go b/resources/page/page.go
index e9ecf26d7..da7b6beec 100644
--- a/resources/page/page.go
+++ b/resources/page/page.go
@@ -150,8 +150,8 @@ type InSectionPositioner interface {
 
 // InternalDependencies is considered an internal interface.
 type InternalDependencies interface {
-	// GetRelatedDocsHandler is for internal use only.
-	GetRelatedDocsHandler() *RelatedDocsHandler
+	// GetInternalRelatedDocsHandler is for internal use only.
+	GetInternalRelatedDocsHandler() *RelatedDocsHandler
 }
 
 // OutputFormatsProvider provides the OutputFormats of a Page.
diff --git a/resources/page/page_paths.go b/resources/page/page_paths.go
index ea22eab81..6b2c8e8b1 100644
--- a/resources/page/page_paths.go
+++ b/resources/page/page_paths.go
@@ -145,7 +145,7 @@ func CreateTargetPaths(d TargetPathDescriptor) (tp TargetPaths) {
 		pb.isUgly = true
 	}
 
-	if d.Type == output.HTTPStatusHTMLFormat || d.Type == output.SitemapFormat || d.Type == output.RobotsTxtFormat {
+	if d.Type == output.HTTPStatus404HTMLFormat || d.Type == output.SitemapFormat || d.Type == output.RobotsTxtFormat {
 		pb.noSubResources = true
 	} else if d.Kind != kinds.KindPage && d.URL == "" && d.Section.Base() != "/" {
 		if d.ExpandedPermalink != "" {
diff --git a/resources/page/pages_related.go b/resources/page/pages_related.go
index 3322a4fbf..9debda324 100644
--- a/resources/page/pages_related.go
+++ b/resources/page/pages_related.go
@@ -129,7 +129,7 @@ func (p Pages) withInvertedIndex(ctx context.Context, search func(idx *related.I
 		return nil, fmt.Errorf("invalid type %T in related search", p[0])
 	}
 
-	cache := d.GetRelatedDocsHandler()
+	cache := d.GetInternalRelatedDocsHandler()
 
 	searchIndex, err := cache.getOrCreateIndex(ctx, p)
 	if err != nil {
diff --git a/resources/page/testhelpers_test.go b/resources/page/testhelpers_test.go
index 19bdc0068..1d2ee6223 100644
--- a/resources/page/testhelpers_test.go
+++ b/resources/page/testhelpers_test.go
@@ -221,7 +221,7 @@ func (p *testPage) GetTerms(taxonomy string) Pages {
 	panic("testpage: not implemented")
 }
 
-func (p *testPage) GetRelatedDocsHandler() *RelatedDocsHandler {
+func (p *testPage) GetInternalRelatedDocsHandler() *RelatedDocsHandler {
 	return relatedDocsHandler
 }
 
diff --git a/resources/resource_spec.go b/resources/resource_spec.go
index f1c30e0a2..56ad9a27d 100644
--- a/resources/resource_spec.go
+++ b/resources/resource_spec.go
@@ -42,7 +42,6 @@ import (
 	"github.com/gohugoio/hugo/resources/images"
 	"github.com/gohugoio/hugo/resources/page"
 	"github.com/gohugoio/hugo/resources/resource"
-	"github.com/gohugoio/hugo/tpl"
 )
 
 func NewSpec(
@@ -123,8 +122,6 @@ type Spec struct {
 	BuildClosers types.CloseAdder
 	Rebuilder    identity.SignalRebuilder
 
-	TextTemplates tpl.TemplateParseFinder
-
 	Permalinks page.PermalinkExpander
 
 	ImageCache *ImageCache
diff --git a/resources/resource_transformers/templates/execute_as_template.go b/resources/resource_transformers/templates/execute_as_template.go
index 79d249bd6..cd421e08f 100644
--- a/resources/resource_transformers/templates/execute_as_template.go
+++ b/resources/resource_transformers/templates/execute_as_template.go
@@ -23,17 +23,17 @@ import (
 	"github.com/gohugoio/hugo/resources"
 	"github.com/gohugoio/hugo/resources/internal"
 	"github.com/gohugoio/hugo/resources/resource"
-	"github.com/gohugoio/hugo/tpl"
+	"github.com/gohugoio/hugo/tpl/tplimpl"
 )
 
 // Client contains methods to perform template processing of Resource objects.
 type Client struct {
 	rs *resources.Spec
-	t  tpl.TemplatesProvider
+	t  tplimpl.TemplateStoreProvider
 }
 
 // New creates a new Client with the given specification.
-func New(rs *resources.Spec, t tpl.TemplatesProvider) *Client {
+func New(rs *resources.Spec, t tplimpl.TemplateStoreProvider) *Client {
 	if rs == nil {
 		panic("must provide a resource Spec")
 	}
@@ -45,7 +45,7 @@ func New(rs *resources.Spec, t tpl.TemplatesProvider) *Client {
 
 type executeAsTemplateTransform struct {
 	rs         *resources.Spec
-	t          tpl.TemplatesProvider
+	t          tplimpl.TemplateStoreProvider
 	targetPath string
 	data       any
 }
@@ -56,14 +56,13 @@ func (t *executeAsTemplateTransform) Key() internal.ResourceTransformationKey {
 
 func (t *executeAsTemplateTransform) Transform(ctx *resources.ResourceTransformationCtx) error {
 	tplStr := helpers.ReaderToString(ctx.From)
-	templ, err := t.t.TextTmpl().Parse(ctx.InPath, tplStr)
+	th := t.t.GetTemplateStore()
+	ti, err := th.TextParse(ctx.InPath, tplStr)
 	if err != nil {
 		return fmt.Errorf("failed to parse Resource %q as Template:: %w", ctx.InPath, err)
 	}
-
 	ctx.OutPath = t.targetPath
-
-	return t.t.Tmpl().ExecuteWithContext(ctx.Ctx, templ, ctx.To, t.data)
+	return th.ExecuteWithContext(ctx.Ctx, ti, ctx.To, t.data)
 }
 
 func (c *Client) ExecuteAsTemplate(ctx context.Context, res resources.ResourceTransformer, targetPath string, data any) (resource.Resource, error) {
diff --git a/testscripts/commands/hugo_printunusedtemplates.txt b/testscripts/commands/hugo_printunusedtemplates.txt
index eb9e068d7..a7a5d87c3 100644
--- a/testscripts/commands/hugo_printunusedtemplates.txt
+++ b/testscripts/commands/hugo_printunusedtemplates.txt
@@ -1,6 +1,6 @@
 hugo  --printUnusedTemplates
 
-stderr 'Template _default/list.html is unused'
+stderr 'Template /list.html is unused'
 
 -- hugo.toml --
 disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "404", "section", "page"]
diff --git a/tpl/collections/apply.go b/tpl/collections/apply.go
index 39b66a27f..caa51f149 100644
--- a/tpl/collections/apply.go
+++ b/tpl/collections/apply.go
@@ -21,7 +21,6 @@ import (
 	"strings"
 
 	"github.com/gohugoio/hugo/common/hreflect"
-	"github.com/gohugoio/hugo/tpl"
 )
 
 // Apply takes an array or slice c and returns a new slice with the function fname applied over it.
@@ -109,8 +108,7 @@ func applyFnToThis(ctx context.Context, fn, this reflect.Value, args ...any) (re
 func (ns *Namespace) lookupFunc(ctx context.Context, fname string) (reflect.Value, bool) {
 	namespace, methodName, ok := strings.Cut(fname, ".")
 	if !ok {
-		templ := ns.deps.Tmpl().(tpl.TemplateFuncGetter)
-		return templ.GetFunc(fname)
+		return ns.deps.GetTemplateStore().GetFunc(fname)
 	}
 
 	// Namespace
diff --git a/tpl/collections/apply_test.go b/tpl/collections/apply_test.go
deleted file mode 100644
index 0a5764264..000000000
--- a/tpl/collections/apply_test.go
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright 2019 The Hugo Authors. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package collections
-
-import (
-	"context"
-	"fmt"
-	"io"
-	"reflect"
-	"testing"
-
-	qt "github.com/frankban/quicktest"
-	"github.com/gohugoio/hugo/config/testconfig"
-	"github.com/gohugoio/hugo/identity"
-	"github.com/gohugoio/hugo/output"
-	"github.com/gohugoio/hugo/output/layouts"
-	"github.com/gohugoio/hugo/tpl"
-)
-
-type templateFinder int
-
-func (templateFinder) GetIdentity(string) (identity.Identity, bool) {
-	return identity.StringIdentity("test"), true
-}
-
-func (templateFinder) Lookup(name string) (tpl.Template, bool) {
-	return nil, false
-}
-
-func (templateFinder) HasTemplate(name string) bool {
-	return false
-}
-
-func (templateFinder) LookupVariant(name string, variants tpl.TemplateVariants) (tpl.Template, bool, bool) {
-	return nil, false, false
-}
-
-func (templateFinder) LookupVariants(name string) []tpl.Template {
-	return nil
-}
-
-func (templateFinder) LookupLayout(d layouts.LayoutDescriptor, f output.Format) (tpl.Template, bool, error) {
-	return nil, false, nil
-}
-
-func (templateFinder) Execute(t tpl.Template, wr io.Writer, data any) error {
-	return nil
-}
-
-func (templateFinder) ExecuteWithContext(ctx context.Context, t tpl.Template, wr io.Writer, data any) error {
-	return nil
-}
-
-func (templateFinder) GetFunc(name string) (reflect.Value, bool) {
-	if name == "dobedobedo" {
-		return reflect.Value{}, false
-	}
-
-	return reflect.ValueOf(fmt.Sprint), true
-}
-
-func TestApply(t *testing.T) {
-	t.Parallel()
-	c := qt.New(t)
-	d := testconfig.GetTestDeps(nil, nil)
-	d.SetTempl(&tpl.TemplateHandlers{
-		Tmpl: new(templateFinder),
-	})
-	ns := New(d)
-
-	strings := []any{"a\n", "b\n"}
-
-	ctx := context.Background()
-
-	result, err := ns.Apply(ctx, strings, "print", "a", "b", "c")
-	c.Assert(err, qt.IsNil)
-	c.Assert(result, qt.DeepEquals, []any{"abc", "abc"})
-
-	_, err = ns.Apply(ctx, strings, "apply", ".")
-	c.Assert(err, qt.Not(qt.IsNil))
-
-	var nilErr *error
-	_, err = ns.Apply(ctx, nilErr, "chomp", ".")
-	c.Assert(err, qt.Not(qt.IsNil))
-
-	_, err = ns.Apply(ctx, strings, "dobedobedo", ".")
-	c.Assert(err, qt.Not(qt.IsNil))
-
-	_, err = ns.Apply(ctx, strings, "foo.Chomp", "c\n")
-	if err == nil {
-		t.Errorf("apply with unknown func should fail")
-	}
-}
diff --git a/tpl/internal/go_templates/htmltemplate/hugo_template.go b/tpl/internal/go_templates/htmltemplate/hugo_template.go
index aae41e2ce..cb7d0dc25 100644
--- a/tpl/internal/go_templates/htmltemplate/hugo_template.go
+++ b/tpl/internal/go_templates/htmltemplate/hugo_template.go
@@ -14,6 +14,8 @@
 package template
 
 import (
+	"fmt"
+
 	"github.com/gohugoio/hugo/common/types"
 	template "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate"
 )
@@ -51,3 +53,28 @@ func indirect(a any) any {
 
 	return in
 }
+
+// CloneShallow creates a shallow copy of the template. It does not clone  or copy the nested templates.
+func (t *Template) CloneShallow() (*Template, error) {
+	t.nameSpace.mu.Lock()
+	defer t.nameSpace.mu.Unlock()
+	if t.escapeErr != nil {
+		return nil, fmt.Errorf("html/template: cannot Clone %q after it has executed", t.Name())
+	}
+	textClone, err := t.text.Clone()
+	if err != nil {
+		return nil, err
+	}
+	ns := &nameSpace{set: make(map[string]*Template)}
+	ns.esc = makeEscaper(ns)
+	ret := &Template{
+		nil,
+		textClone,
+		textClone.Tree,
+		ns,
+	}
+	ret.set[ret.Name()] = ret
+
+	// Return the template associated with the name of this template.
+	return ret.set[ret.Name()], nil
+}
diff --git a/tpl/internal/go_templates/htmltemplate/template.go b/tpl/internal/go_templates/htmltemplate/template.go
index 4582ddd5f..cd80e0183 100644
--- a/tpl/internal/go_templates/htmltemplate/template.go
+++ b/tpl/internal/go_templates/htmltemplate/template.go
@@ -267,7 +267,7 @@ func (t *Template) Clone() (*Template, error) {
 		name := x.Name()
 		src := t.set[name]
 		if src == nil || src.escapeErr != nil {
-			return nil, fmt.Errorf("html/template: cannot Clone %q after it has executed", t.Name())
+			return nil, fmt.Errorf("html/template: cannot Clone %q after it has executed, %q not found", t.Name(), name)
 		}
 		x.Tree = x.Tree.Copy()
 		ret.set[name] = &Template{
diff --git a/tpl/internal/go_templates/texttemplate/example_test.go b/tpl/internal/go_templates/texttemplate/example_test.go
index 295a810b8..975ceea93 100644
--- a/tpl/internal/go_templates/texttemplate/example_test.go
+++ b/tpl/internal/go_templates/texttemplate/example_test.go
@@ -35,7 +35,7 @@ Josie
 		Name, Gift string
 		Attended   bool
 	}
-	var recipients = []Recipient{
+	recipients := []Recipient{
 		{"Aunt Mildred", "bone china tea set", true},
 		{"Uncle John", "moleskin pants", false},
 		{"Cousin Rodney", "", false},
diff --git a/tpl/math/init.go b/tpl/math/init.go
index 0ef02550c..bfaf4526a 100644
--- a/tpl/math/init.go
+++ b/tpl/math/init.go
@@ -24,7 +24,7 @@ const name = "math"
 
 func init() {
 	f := func(d *deps.Deps) *internal.TemplateFuncsNamespace {
-		ctx := New()
+		ctx := New(d)
 
 		ns := &internal.TemplateFuncsNamespace{
 			Name:    name,
diff --git a/tpl/math/math.go b/tpl/math/math.go
index 5a5e42f4e..a0512c045 100644
--- a/tpl/math/math.go
+++ b/tpl/math/math.go
@@ -20,9 +20,9 @@ import (
 	"math"
 	"math/rand"
 	"reflect"
-	"sync/atomic"
 
 	_math "github.com/gohugoio/hugo/common/math"
+	"github.com/gohugoio/hugo/deps"
 	"github.com/spf13/cast"
 )
 
@@ -32,12 +32,16 @@ var (
 )
 
 // New returns a new instance of the math-namespaced template functions.
-func New() *Namespace {
-	return &Namespace{}
+func New(d *deps.Deps) *Namespace {
+	return &Namespace{
+		d: d,
+	}
 }
 
 // Namespace provides template functions for the "math" namespace.
-type Namespace struct{}
+type Namespace struct {
+	d *deps.Deps
+}
 
 // Abs returns the absolute value of n.
 func (ns *Namespace) Abs(n any) (float64, error) {
@@ -345,8 +349,6 @@ func (ns *Namespace) doArithmetic(inputs []any, operation rune) (value any, err
 	return
 }
 
-var counter uint64
-
 // Counter increments and returns a global counter.
 // This was originally added to be used in tests where now.UnixNano did not
 // have the needed precision (especially on Windows).
@@ -354,5 +356,5 @@ var counter uint64
 // and the counter will reset on new builds.
 // {"identifiers": ["now.UnixNano"] }
 func (ns *Namespace) Counter() uint64 {
-	return atomic.AddUint64(&counter, uint64(1))
+	return ns.d.Counters.MathCounter.Add(1)
 }
diff --git a/tpl/math/math_test.go b/tpl/math/math_test.go
index 67abbc27e..d45a2aace 100644
--- a/tpl/math/math_test.go
+++ b/tpl/math/math_test.go
@@ -24,7 +24,7 @@ func TestBasicNSArithmetic(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	type TestCase struct {
 		fn     func(inputs ...any) (any, error)
@@ -66,7 +66,7 @@ func TestBasicNSArithmetic(t *testing.T) {
 func TestAbs(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		x      any
@@ -93,7 +93,7 @@ func TestAbs(t *testing.T) {
 func TestCeil(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		x      any
@@ -126,7 +126,7 @@ func TestFloor(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		x      any
@@ -159,7 +159,7 @@ func TestLog(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		a      any
@@ -200,7 +200,7 @@ func TestSqrt(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		a      any
@@ -239,7 +239,7 @@ func TestMod(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		a      any
@@ -279,7 +279,7 @@ func TestModBool(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		a      any
@@ -325,7 +325,7 @@ func TestRound(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		x      any
@@ -358,7 +358,7 @@ func TestPow(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		a      any
@@ -398,7 +398,7 @@ func TestMax(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	type TestCase struct {
 		values []any
@@ -452,7 +452,7 @@ func TestMin(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	type TestCase struct {
 		values []any
@@ -507,7 +507,7 @@ func TestSum(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	mustSum := func(values ...any) any {
 		result, err := ns.Sum(values...)
@@ -530,7 +530,7 @@ func TestProduct(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	mustProduct := func(values ...any) any {
 		result, err := ns.Product(values...)
@@ -554,7 +554,7 @@ func TestPi(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	expect := 3.1415
 	result := ns.Pi()
@@ -570,7 +570,7 @@ func TestSin(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		a      any
@@ -604,7 +604,7 @@ func TestCos(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		a      any
@@ -638,7 +638,7 @@ func TestTan(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
 
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		a      any
@@ -680,7 +680,7 @@ func TestTan(t *testing.T) {
 func TestAsin(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		x      any
@@ -715,7 +715,7 @@ func TestAsin(t *testing.T) {
 func TestAcos(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		x      any
@@ -751,7 +751,7 @@ func TestAcos(t *testing.T) {
 func TestAtan(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		x      any
@@ -782,7 +782,7 @@ func TestAtan(t *testing.T) {
 func TestAtan2(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		x      any
@@ -821,7 +821,7 @@ func TestAtan2(t *testing.T) {
 func TestToDegrees(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		x      any
@@ -852,7 +852,7 @@ func TestToDegrees(t *testing.T) {
 func TestToRadians(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)
-	ns := New()
+	ns := New(nil)
 
 	for _, test := range []struct {
 		x      any
diff --git a/tpl/partials/partials.go b/tpl/partials/partials.go
index 4441b5fa5..27f13253a 100644
--- a/tpl/partials/partials.go
+++ b/tpl/partials/partials.go
@@ -25,12 +25,12 @@ import (
 
 	"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"
 
 	bp "github.com/gohugoio/hugo/bufferpool"
 	"github.com/gohugoio/hugo/deps"
@@ -54,13 +54,6 @@ func (k partialCacheKey) Key() string {
 	return hashing.HashString(append([]any{k.Name}, k.Variants...)...)
 }
 
-func (k partialCacheKey) templateName() string {
-	if !strings.HasPrefix(k.Name, "partials/") {
-		return "partials/" + k.Name
-	}
-	return k.Name
-}
-
 // partialCache represents a LRU cache of partials.
 type partialCache struct {
 	cache *lazycache.Cache[string, includeResult]
@@ -129,6 +122,11 @@ func (ns *Namespace) Include(ctx context.Context, name string, contextList ...an
 }
 
 func (ns *Namespace) includWithTimeout(ctx context.Context, name string, dataList ...any) includeResult {
+	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()
@@ -159,28 +157,14 @@ func (ns *Namespace) include(ctx context.Context, name string, dataList ...any)
 	if len(dataList) > 0 {
 		data = dataList[0]
 	}
-
-	var n string
-	if strings.HasPrefix(name, "partials/") {
-		n = name
-	} else {
-		n = "partials/" + name
-	}
-
-	templ, found := ns.deps.Tmpl().Lookup(n)
-	if !found {
-		// For legacy reasons.
-		templ, found = ns.deps.Tmpl().Lookup(n + ".html")
-	}
-
-	if !found {
+	name, desc := ns.deps.TemplateStore.TemplateDescriptorFromPath(name)
+	v := ns.deps.TemplateStore.LookupPartial(name, desc)
+	if v == nil {
 		return includeResult{err: fmt.Errorf("partial %q not found", name)}
 	}
 
-	var info tpl.ParseInfo
-	if ip, ok := templ.(tpl.Info); ok {
-		info = ip.ParseInfo()
-	}
+	templ := v
+	info := v.ParseInfo
 
 	var w io.Writer
 
@@ -200,7 +184,7 @@ func (ns *Namespace) include(ctx context.Context, name string, dataList ...any)
 		w = b
 	}
 
-	if err := ns.deps.Tmpl().ExecuteWithContext(ctx, templ, w, data); err != nil {
+	if err := ns.deps.GetTemplateStore().ExecuteWithContext(ctx, templ, w, data); err != nil {
 		return includeResult{err: err}
 	}
 
@@ -208,14 +192,14 @@ func (ns *Namespace) include(ctx context.Context, name string, dataList ...any)
 
 	if ctx, ok := data.(*contextWrapper); ok {
 		result = ctx.Result
-	} else if _, ok := templ.(*texttemplate.Template); ok {
+	} else if _, ok := templ.Template.(*texttemplate.Template); ok {
 		result = w.(fmt.Stringer).String()
 	} else {
 		result = template.HTML(w.(fmt.Stringer).String())
 	}
 
 	return includeResult{
-		name:   templ.Name(),
+		name:   templ.Template.Name(),
 		result: result,
 	}
 }
@@ -253,9 +237,9 @@ func (ns *Namespace) IncludeCached(ctx context.Context, name string, context any
 			// The templates that gets executed is measured in Execute.
 			// We need to track the time spent in the cache to
 			// get the totals correct.
-			ns.deps.Metrics.MeasureSince(key.templateName(), start)
+			ns.deps.Metrics.MeasureSince(r.name, start)
 		}
-		ns.deps.Metrics.TrackValue(key.templateName(), r.result, found)
+		ns.deps.Metrics.TrackValue(r.name, r.result, found)
 	}
 
 	if r.mangager != nil && depsManagerIn != nil {
diff --git a/tpl/partials/partials_integration_test.go b/tpl/partials/partials_integration_test.go
index f2bde29c3..6fab3abd8 100644
--- a/tpl/partials/partials_integration_test.go
+++ b/tpl/partials/partials_integration_test.go
@@ -170,7 +170,7 @@ D1
 	got := buf.String()
 
 	// Get rid of all the durations, they are never the same.
-	durationRe := regexp.MustCompile(`\b[\.\d]*(ms|µs|s)\b`)
+	durationRe := regexp.MustCompile(`\b[\.\d]*(ms|ns|µs|s)\b`)
 
 	normalize := func(s string) string {
 		s = durationRe.ReplaceAllString(s, "")
@@ -193,10 +193,10 @@ D1
 
 	expect := `
 	0        0       0      1  index.html
-	100        0       0      1  partials/static2.html
-	100       50       1      2  partials/static1.html
-	25       50       2      4  partials/dynamic1.html
-	66       33       1      3  partials/halfdynamic1.html
+	100        0       0      1  _partials/static2.html
+	100       50       1      2  _partials/static1.html
+	25       50       2      4  _partials/dynamic1.html
+	66       33       1      3  _partials/halfdynamic1.html
 	`
 
 	b.Assert(got, hqt.IsSameString, expect)
diff --git a/tpl/template.go b/tpl/template.go
index 39c6b4f1c..865fea52e 100644
--- a/tpl/template.go
+++ b/tpl/template.go
@@ -1,4 +1,4 @@
-// Copyright 2019 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.
@@ -16,9 +16,6 @@ package tpl
 
 import (
 	"context"
-	"io"
-	"reflect"
-	"regexp"
 	"strings"
 	"sync"
 	"unicode"
@@ -27,140 +24,18 @@ import (
 	"github.com/gohugoio/hugo/common/hcontext"
 	"github.com/gohugoio/hugo/identity"
 	"github.com/gohugoio/hugo/langs"
-	"github.com/gohugoio/hugo/output/layouts"
-
-	"github.com/gohugoio/hugo/output"
 
 	htmltemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/htmltemplate"
 	texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate"
 )
 
-// TemplateManager manages the collection of templates.
-type TemplateManager interface {
-	TemplateHandler
-	TemplateFuncGetter
-	AddTemplate(name, tpl string) error
-}
-
-// TemplateVariants describes the possible variants of a template.
-// All of these may be empty.
-type TemplateVariants struct {
-	Language     string
-	OutputFormat output.Format
-}
-
-// TemplateFinder finds templates.
-type TemplateFinder interface {
-	TemplateLookup
-	TemplateLookupVariant
-}
-
-// UnusedTemplatesProvider lists unused templates if the build is configured to track those.
-type UnusedTemplatesProvider interface {
-	UnusedTemplates() []FileInfo
-}
-
-// TemplateHandlers holds the templates needed by Hugo.
-type TemplateHandlers struct {
-	Tmpl    TemplateHandler
-	TxtTmpl TemplateParseFinder
-}
-
-type TemplateExecutor interface {
-	ExecuteWithContext(ctx context.Context, t Template, wr io.Writer, data any) error
-}
-
-// TemplateHandler finds and executes templates.
-type TemplateHandler interface {
-	TemplateFinder
-	TemplateExecutor
-	LookupLayout(d layouts.LayoutDescriptor, f output.Format) (Template, bool, error)
-	HasTemplate(name string) bool
-	GetIdentity(name string) (identity.Identity, bool)
-}
-
-type TemplateLookup interface {
-	Lookup(name string) (Template, bool)
-}
-
-type TemplateLookupVariant interface {
-	// TODO(bep) this currently only works for shortcodes.
-	// We may unify and expand this variant pattern to the
-	// other templates, but we need this now for the shortcodes to
-	// quickly determine if a shortcode has a template for a given
-	// output format.
-	// It returns the template, if it was found or not and if there are
-	// alternative representations (output format, language).
-	// We are currently only interested in output formats, so we should improve
-	// this for speed.
-	LookupVariant(name string, variants TemplateVariants) (Template, bool, bool)
-	LookupVariants(name string) []Template
-}
-
 // Template is the common interface between text/template and html/template.
 type Template interface {
 	Name() string
 	Prepare() (*texttemplate.Template, error)
 }
 
-// AddIdentity checks if t is an identity.Identity and returns it if so.
-// Else it wraps it in a templateIdentity using its name as the base.
-func AddIdentity(t Template) Template {
-	if _, ok := t.(identity.IdentityProvider); ok {
-		return t
-	}
-	return templateIdentityProvider{
-		Template: t,
-		id:       identity.StringIdentity(t.Name()),
-	}
-}
-
-type templateIdentityProvider struct {
-	Template
-	id identity.Identity
-}
-
-func (t templateIdentityProvider) GetIdentity() identity.Identity {
-	return t.id
-}
-
-// TemplateParser is used to parse ad-hoc templates, e.g. in the Resource chain.
-type TemplateParser interface {
-	Parse(name, tpl string) (Template, error)
-}
-
-// TemplateParseFinder provides both parsing and finding.
-type TemplateParseFinder interface {
-	TemplateParser
-	TemplateFinder
-}
-
-// TemplateDebugger prints some debug info to stdout.
-type TemplateDebugger interface {
-	Debug()
-}
-
-// TemplatesProvider as implemented by deps.Deps.
-type TemplatesProvider interface {
-	Tmpl() TemplateHandler
-	TextTmpl() TemplateParseFinder
-}
-
-var baseOfRe = regexp.MustCompile("template: (.*?):")
-
-func extractBaseOf(err string) string {
-	m := baseOfRe.FindStringSubmatch(err)
-	if len(m) == 2 {
-		return m[1]
-	}
-	return ""
-}
-
-// TemplateFuncGetter allows to find a template func by name.
-type TemplateFuncGetter interface {
-	GetFunc(name string) (reflect.Value, bool)
-}
-
+// RenderingContext represents the currently rendered site/language.
 type RenderingContext struct {
 	Site       site
 	SiteOutIdx int
@@ -201,7 +76,9 @@ type site interface {
 }
 
 const (
+	// HugoDeferredTemplatePrefix is the prefix for placeholders for deferred templates.
 	HugoDeferredTemplatePrefix = "__hdeferred/"
+	// HugoDeferredTemplateSuffix is the suffix for placeholders for deferred templates.
 	HugoDeferredTemplateSuffix = "__d="
 )
 
@@ -243,10 +120,11 @@ func StripHTML(s string) string {
 	return s
 }
 
+// DeferredExecution holds the template and data for a deferred execution.
 type DeferredExecution struct {
 	Mu           sync.Mutex
 	Ctx          context.Context
-	TemplateName string
+	TemplatePath string
 	Data         any
 
 	Executed bool
diff --git a/tpl/template_test.go b/tpl/template_test.go
index 333513a3d..4c68f8132 100644
--- a/tpl/template_test.go
+++ b/tpl/template_test.go
@@ -1,4 +1,4 @@
-// Copyright 2018 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.
@@ -15,20 +15,8 @@ package tpl
 
 import (
 	"testing"
-
-	qt "github.com/frankban/quicktest"
 )
 
-func TestExtractBaseof(t *testing.T) {
-	c := qt.New(t)
-
-	replaced := extractBaseOf(`failed: template: _default/baseof.html:37:11: executing "_default/baseof.html" at <.Parents>: can't evaluate field Parents in type *hugolib.PageOutput`)
-
-	c.Assert(replaced, qt.Equals, "_default/baseof.html")
-	c.Assert(extractBaseOf("not baseof for you"), qt.Equals, "")
-	c.Assert(extractBaseOf("template: blog/baseof.html:23:11:"), qt.Equals, "blog/baseof.html")
-}
-
 func TestStripHTML(t *testing.T) {
 	type test struct {
 		input, expected string
diff --git a/tpl/templates/defer_integration_test.go b/tpl/templates/defer_integration_test.go
index 27b8fbf8c..f5ee09c06 100644
--- a/tpl/templates/defer_integration_test.go
+++ b/tpl/templates/defer_integration_test.go
@@ -71,6 +71,81 @@ AMP.
 
 `
 
+func TestDeferNoBaseof(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+-- layouts/index.html --
+Home.
+{{ with (templates.Defer (dict "key" "foo")) }}
+ Defer
+{{ end }}
+-- content/_index.md --
+---
+title: "Home"
+---
+
+`
+
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/index.html", "Home.\n\n Defer")
+}
+
+func TestDeferBaseof(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+-- layouts/baseof.html --
+{{ with (templates.Defer (dict "key" "foo")) }}
+Defer
+{{ end }}
+Block:{{ block "main" . }}{{ end }}$
+-- layouts/index.html --
+{{ define "main" }}
+Home.
+{{ end }}
+-- content/_index.md --
+---
+title: "Home"
+---
+
+`
+
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/index.html", "Home.\n\n Defer")
+}
+
+func TestDeferMain(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+-- layouts/baseof.html --
+
+Block:{{ block "main" . }}{{ end }}$
+-- layouts/index.html --
+{{ define "main" }}
+Home.
+{{ with (templates.Defer (dict "key" "foo")) }}
+Defer
+{{ end }}
+{{ end }}
+-- content/_index.md --
+---
+title: "Home"
+---
+
+`
+
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/index.html", "Home.\n\n Defer")
+}
+
 func TestDeferBasic(t *testing.T) {
 	t.Parallel()
 
diff --git a/tpl/templates/templates.go b/tpl/templates/templates.go
index 0be44a013..98ae0a639 100644
--- a/tpl/templates/templates.go
+++ b/tpl/templates/templates.go
@@ -44,7 +44,7 @@ type Namespace struct {
 // Note that this is the Unix-styled relative path including filename suffix,
 // e.g. partials/header.html
 func (ns *Namespace) Exists(name string) bool {
-	return ns.deps.Tmpl().HasTemplate(name)
+	return ns.deps.GetTemplateStore().HasTemplate(name)
 }
 
 // Defer defers the execution of a template block.
@@ -93,7 +93,7 @@ func (ns *Namespace) DoDefer(ctx context.Context, id string, optsv any) string {
 	_, _ = ns.deps.BuildState.DeferredExecutions.Executions.GetOrCreate(id,
 		func() (*tpl.DeferredExecution, error) {
 			return &tpl.DeferredExecution{
-				TemplateName: templateName,
+				TemplatePath: templateName,
 				Ctx:          ctx,
 				Data:         opts.Data,
 				Executed:     false,
diff --git a/tpl/tplimpl/category_string.go b/tpl/tplimpl/category_string.go
new file mode 100644
index 000000000..2f7730113
--- /dev/null
+++ b/tpl/tplimpl/category_string.go
@@ -0,0 +1,30 @@
+// Code generated by "stringer -type Category"; DO NOT EDIT.
+
+package tplimpl
+
+import "strconv"
+
+func _() {
+	// An "invalid array index" compiler error signifies that the constant values have changed.
+	// Re-run the stringer command to generate them again.
+	var x [1]struct{}
+	_ = x[CategoryLayout-1]
+	_ = x[CategoryBaseof-2]
+	_ = x[CategoryMarkup-3]
+	_ = x[CategoryShortcode-4]
+	_ = x[CategoryPartial-5]
+	_ = x[CategoryServer-6]
+	_ = x[CategoryHugo-7]
+}
+
+const _Category_name = "CategoryLayoutCategoryBaseofCategoryMarkupCategoryShortcodeCategoryPartialCategoryServerCategoryHugo"
+
+var _Category_index = [...]uint8{0, 14, 28, 42, 59, 74, 88, 100}
+
+func (i Category) String() string {
+	i -= 1
+	if i < 0 || i >= Category(len(_Category_index)-1) {
+		return "Category(" + strconv.FormatInt(int64(i+1), 10) + ")"
+	}
+	return _Category_name[_Category_index[i]:_Category_index[i+1]]
+}
diff --git a/tpl/tplimpl/embedded/templates/_default/_markup/render-codeblock-goat.html b/tpl/tplimpl/embedded/templates/_markup/render-codeblock-goat.html
similarity index 100%
rename from tpl/tplimpl/embedded/templates/_default/_markup/render-codeblock-goat.html
rename to tpl/tplimpl/embedded/templates/_markup/render-codeblock-goat.html
diff --git a/tpl/tplimpl/embedded/templates/_default/_markup/render-image.html b/tpl/tplimpl/embedded/templates/_markup/render-image.html
similarity index 100%
rename from tpl/tplimpl/embedded/templates/_default/_markup/render-image.html
rename to tpl/tplimpl/embedded/templates/_markup/render-image.html
diff --git a/tpl/tplimpl/embedded/templates/_default/_markup/render-link.html b/tpl/tplimpl/embedded/templates/_markup/render-link.html
similarity index 100%
rename from tpl/tplimpl/embedded/templates/_default/_markup/render-link.html
rename to tpl/tplimpl/embedded/templates/_markup/render-link.html
diff --git a/tpl/tplimpl/embedded/templates/_default/_markup/render-table.html b/tpl/tplimpl/embedded/templates/_markup/render-table.html
similarity index 100%
rename from tpl/tplimpl/embedded/templates/_default/_markup/render-table.html
rename to tpl/tplimpl/embedded/templates/_markup/render-table.html
diff --git a/tpl/tplimpl/embedded/templates/partials/_funcs/get-page-images.html b/tpl/tplimpl/embedded/templates/_partials/_funcs/get-page-images.html
similarity index 100%
rename from tpl/tplimpl/embedded/templates/partials/_funcs/get-page-images.html
rename to tpl/tplimpl/embedded/templates/_partials/_funcs/get-page-images.html
diff --git a/tpl/tplimpl/embedded/templates/disqus.html b/tpl/tplimpl/embedded/templates/_partials/disqus.html
similarity index 92%
rename from tpl/tplimpl/embedded/templates/disqus.html
rename to tpl/tplimpl/embedded/templates/_partials/disqus.html
index 19fcb7fda..fb7cbd0fe 100644
--- a/tpl/tplimpl/embedded/templates/disqus.html
+++ b/tpl/tplimpl/embedded/templates/_partials/disqus.html
@@ -5,7 +5,7 @@
     window.disqus_config = function () {
     {{with .Params.disqus_identifier }}this.page.identifier = '{{ . }}';{{end}}
     {{with .Params.disqus_title }}this.page.title = '{{ . }}';{{end}}
-    {{with .Params.disqus_url }}this.page.url = '{{ . | html  }}';{{end}}
+    {{with .Params.disqus_url }}this.page.url = '{{ . |  transform.HTMLEscape | safeURL }}';{{end}}
     };
     (function() {
         if (["localhost", "127.0.0.1"].indexOf(window.location.hostname) != -1) {
diff --git a/tpl/tplimpl/embedded/templates/google_analytics.html b/tpl/tplimpl/embedded/templates/_partials/google_analytics.html
similarity index 100%
rename from tpl/tplimpl/embedded/templates/google_analytics.html
rename to tpl/tplimpl/embedded/templates/_partials/google_analytics.html
diff --git a/tpl/tplimpl/embedded/templates/opengraph.html b/tpl/tplimpl/embedded/templates/_partials/opengraph.html
similarity index 100%
rename from tpl/tplimpl/embedded/templates/opengraph.html
rename to tpl/tplimpl/embedded/templates/_partials/opengraph.html
diff --git a/tpl/tplimpl/embedded/templates/pagination.html b/tpl/tplimpl/embedded/templates/_partials/pagination.html
similarity index 98%
rename from tpl/tplimpl/embedded/templates/pagination.html
rename to tpl/tplimpl/embedded/templates/_partials/pagination.html
index 717797ab2..9f8244702 100644
--- a/tpl/tplimpl/embedded/templates/pagination.html
+++ b/tpl/tplimpl/embedded/templates/_partials/pagination.html
@@ -20,7 +20,7 @@
 {{- if in $validFormats $format }}
   {{- if gt $page.Paginator.TotalPages 1 }}
     
    - {{- partial (printf "partials/inline/pagination/%s" $format) $page }} + {{- partial (printf "inline/pagination/%s" $format) $page }}
{{- end }} {{- else }} diff --git a/tpl/tplimpl/embedded/templates/schema.html b/tpl/tplimpl/embedded/templates/_partials/schema.html similarity index 100% rename from tpl/tplimpl/embedded/templates/schema.html rename to tpl/tplimpl/embedded/templates/_partials/schema.html diff --git a/tpl/tplimpl/embedded/templates/twitter_cards.html b/tpl/tplimpl/embedded/templates/_partials/twitter_cards.html similarity index 100% rename from tpl/tplimpl/embedded/templates/twitter_cards.html rename to tpl/tplimpl/embedded/templates/_partials/twitter_cards.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/1__h_simple_assets.html b/tpl/tplimpl/embedded/templates/_shortcodes/1__h_simple_assets.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/1__h_simple_assets.html rename to tpl/tplimpl/embedded/templates/_shortcodes/1__h_simple_assets.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/comment.html b/tpl/tplimpl/embedded/templates/_shortcodes/comment.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/comment.html rename to tpl/tplimpl/embedded/templates/_shortcodes/comment.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/details.html b/tpl/tplimpl/embedded/templates/_shortcodes/details.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/details.html rename to tpl/tplimpl/embedded/templates/_shortcodes/details.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/figure.html b/tpl/tplimpl/embedded/templates/_shortcodes/figure.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/figure.html rename to tpl/tplimpl/embedded/templates/_shortcodes/figure.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/gist.html b/tpl/tplimpl/embedded/templates/_shortcodes/gist.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/gist.html rename to tpl/tplimpl/embedded/templates/_shortcodes/gist.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/highlight.html b/tpl/tplimpl/embedded/templates/_shortcodes/highlight.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/highlight.html rename to tpl/tplimpl/embedded/templates/_shortcodes/highlight.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/instagram.html b/tpl/tplimpl/embedded/templates/_shortcodes/instagram.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/instagram.html rename to tpl/tplimpl/embedded/templates/_shortcodes/instagram.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/instagram_simple.html b/tpl/tplimpl/embedded/templates/_shortcodes/instagram_simple.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/instagram_simple.html rename to tpl/tplimpl/embedded/templates/_shortcodes/instagram_simple.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/param.html b/tpl/tplimpl/embedded/templates/_shortcodes/param.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/param.html rename to tpl/tplimpl/embedded/templates/_shortcodes/param.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/qr.html b/tpl/tplimpl/embedded/templates/_shortcodes/qr.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/qr.html rename to tpl/tplimpl/embedded/templates/_shortcodes/qr.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/ref.html b/tpl/tplimpl/embedded/templates/_shortcodes/ref.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/ref.html rename to tpl/tplimpl/embedded/templates/_shortcodes/ref.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/relref.html b/tpl/tplimpl/embedded/templates/_shortcodes/relref.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/relref.html rename to tpl/tplimpl/embedded/templates/_shortcodes/relref.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/twitter.html b/tpl/tplimpl/embedded/templates/_shortcodes/twitter.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/twitter.html rename to tpl/tplimpl/embedded/templates/_shortcodes/twitter.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/twitter_simple.html b/tpl/tplimpl/embedded/templates/_shortcodes/twitter_simple.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/twitter_simple.html rename to tpl/tplimpl/embedded/templates/_shortcodes/twitter_simple.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/vimeo.html b/tpl/tplimpl/embedded/templates/_shortcodes/vimeo.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/vimeo.html rename to tpl/tplimpl/embedded/templates/_shortcodes/vimeo.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/vimeo_simple.html b/tpl/tplimpl/embedded/templates/_shortcodes/vimeo_simple.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/vimeo_simple.html rename to tpl/tplimpl/embedded/templates/_shortcodes/vimeo_simple.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/x.html b/tpl/tplimpl/embedded/templates/_shortcodes/x.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/x.html rename to tpl/tplimpl/embedded/templates/_shortcodes/x.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/x_simple.html b/tpl/tplimpl/embedded/templates/_shortcodes/x_simple.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/x_simple.html rename to tpl/tplimpl/embedded/templates/_shortcodes/x_simple.html diff --git a/tpl/tplimpl/embedded/templates/shortcodes/youtube.html b/tpl/tplimpl/embedded/templates/_shortcodes/youtube.html similarity index 100% rename from tpl/tplimpl/embedded/templates/shortcodes/youtube.html rename to tpl/tplimpl/embedded/templates/_shortcodes/youtube.html diff --git a/tpl/tplimpl/embedded/templates/_default/robots.txt b/tpl/tplimpl/embedded/templates/robots.txt similarity index 100% rename from tpl/tplimpl/embedded/templates/_default/robots.txt rename to tpl/tplimpl/embedded/templates/robots.txt diff --git a/tpl/tplimpl/embedded/templates/_default/rss.xml b/tpl/tplimpl/embedded/templates/rss.xml similarity index 100% rename from tpl/tplimpl/embedded/templates/_default/rss.xml rename to tpl/tplimpl/embedded/templates/rss.xml diff --git a/tpl/tplimpl/embedded/templates/_default/sitemap.xml b/tpl/tplimpl/embedded/templates/sitemap.xml similarity index 100% rename from tpl/tplimpl/embedded/templates/_default/sitemap.xml rename to tpl/tplimpl/embedded/templates/sitemap.xml diff --git a/tpl/tplimpl/embedded/templates/_default/sitemapindex.xml b/tpl/tplimpl/embedded/templates/sitemapindex.xml similarity index 100% rename from tpl/tplimpl/embedded/templates/_default/sitemapindex.xml rename to tpl/tplimpl/embedded/templates/sitemapindex.xml diff --git a/tpl/tplimpl/legacy.go b/tpl/tplimpl/legacy.go new file mode 100644 index 000000000..ee9a6e5d1 --- /dev/null +++ b/tpl/tplimpl/legacy.go @@ -0,0 +1,130 @@ +// 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. +// 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 ( + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/resources/kinds" +) + +type layoutLegacyMapping struct { + sourcePath string + target layoutLegacyMappingTarget +} + +type layoutLegacyMappingTarget struct { + targetPath string + targetDesc TemplateDescriptor + targetCategory Category +} + +var ( + ltermPlural = layoutLegacyMappingTarget{ + targetPath: "/PLURAL", + targetDesc: TemplateDescriptor{Kind: kinds.KindTerm}, + targetCategory: CategoryLayout, + } + ltermBase = layoutLegacyMappingTarget{ + targetPath: "", + targetDesc: TemplateDescriptor{Kind: kinds.KindTerm}, + targetCategory: CategoryLayout, + } + + ltaxPlural = layoutLegacyMappingTarget{ + targetPath: "/PLURAL", + targetDesc: TemplateDescriptor{Kind: kinds.KindTaxonomy}, + targetCategory: CategoryLayout, + } + ltaxBase = layoutLegacyMappingTarget{ + targetPath: "", + targetDesc: TemplateDescriptor{Kind: kinds.KindTaxonomy}, + targetCategory: CategoryLayout, + } + + lsectBase = layoutLegacyMappingTarget{ + targetPath: "", + targetDesc: TemplateDescriptor{Kind: kinds.KindSection}, + targetCategory: CategoryLayout, + } + lsectTheSection = layoutLegacyMappingTarget{ + targetPath: "/THESECTION", + targetDesc: TemplateDescriptor{Kind: kinds.KindSection}, + targetCategory: CategoryLayout, + } +) + +type legacyTargetPathIdentifiers struct { + targetPath string + targetCategory Category + kind string + lang string + outputFormat string + ext string +} + +type legacyOrdinalMapping struct { + ordinal int + mapping layoutLegacyMappingTarget +} + +type legacyOrdinalMappingFi struct { + m legacyOrdinalMapping + fi hugofs.FileMetaInfo +} + +var legacyTermMappings = []layoutLegacyMapping{ + {sourcePath: "/PLURAL/term", target: ltermPlural}, + {sourcePath: "/PLURAL/SINGULAR", target: ltermPlural}, + {sourcePath: "/term/term", target: ltermBase}, + {sourcePath: "/term/SINGULAR", target: ltermPlural}, + {sourcePath: "/term/taxonomy", target: ltermPlural}, + {sourcePath: "/term/list", target: ltermBase}, + {sourcePath: "/taxonomy/term", target: ltermBase}, + {sourcePath: "/taxonomy/SINGULAR", target: ltermPlural}, + {sourcePath: "/SINGULAR/term", target: ltermPlural}, + {sourcePath: "/SINGULAR/SINGULAR", target: ltermPlural}, + {sourcePath: "/_default/SINGULAR", target: ltermPlural}, + {sourcePath: "/_default/taxonomy", target: ltermBase}, +} + +var legacyTaxonomyMappings = []layoutLegacyMapping{ + {sourcePath: "/PLURAL/SINGULAR.terms", target: ltaxPlural}, + {sourcePath: "/PLURAL/terms", target: ltaxPlural}, + {sourcePath: "/PLURAL/taxonomy", target: ltaxPlural}, + {sourcePath: "/PLURAL/list", target: ltaxPlural}, + {sourcePath: "/SINGULAR/SINGULAR.terms", target: ltaxPlural}, + {sourcePath: "/SINGULAR/terms", target: ltaxPlural}, + {sourcePath: "/SINGULAR/taxonomy", target: ltaxPlural}, + {sourcePath: "/SINGULAR/list", target: ltaxPlural}, + {sourcePath: "/taxonomy/SINGULAR.terms", target: ltaxPlural}, + {sourcePath: "/taxonomy/terms", target: ltaxBase}, + {sourcePath: "/taxonomy/taxonomy", target: ltaxBase}, + {sourcePath: "/taxonomy/list", target: ltaxBase}, + {sourcePath: "/_default/SINGULAR.terms", target: ltaxBase}, + {sourcePath: "/_default/terms", target: ltaxBase}, + {sourcePath: "/_default/taxonomy", target: ltaxBase}, +} + +var legacySectionMappings = []layoutLegacyMapping{ + // E.g. /mysection/mysection.html + {sourcePath: "/THESECTION/THESECTION", target: lsectTheSection}, + // E.g. /section/mysection.html + {sourcePath: "/SECTIONKIND/THESECTION", target: lsectTheSection}, + // E.g. /section/section.html + {sourcePath: "/SECTIONKIND/SECTIONKIND", target: lsectBase}, + // E.g. /section/list.html + {sourcePath: "/SECTIONKIND/list", target: lsectBase}, + // E.g. /_default/mysection.html + {sourcePath: "/_default/THESECTION", target: lsectTheSection}, +} diff --git a/tpl/tplimpl/render_hook_integration_test.go b/tpl/tplimpl/render_hook_integration_test.go index 000470f87..45c6cced1 100644 --- a/tpl/tplimpl/render_hook_integration_test.go +++ b/tpl/tplimpl/render_hook_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. diff --git a/tpl/tplimpl/shortcodes.go b/tpl/tplimpl/shortcodes.go deleted file mode 100644 index a1e7b463e..000000000 --- a/tpl/tplimpl/shortcodes.go +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tplimpl - -import ( - "strings" - - "github.com/gohugoio/hugo/tpl" -) - -// Currently lang, outFormat, suffix -const numTemplateVariants = 3 - -type shortcodeVariant struct { - // The possible variants: lang, outFormat, suffix - // gtag - // gtag.html - // gtag.no.html - // gtag.no.amp.html - // A slice of length numTemplateVariants. - variants []string - - ts *templateState -} - -type shortcodeTemplates struct { - variants []shortcodeVariant -} - -func (s *shortcodeTemplates) indexOf(variants []string) int { -L: - for i, v1 := range s.variants { - for i, v2 := range v1.variants { - if v2 != variants[i] { - continue L - } - } - return i - } - return -1 -} - -func (s *shortcodeTemplates) fromVariants(variants tpl.TemplateVariants) (shortcodeVariant, bool) { - return s.fromVariantsSlice([]string{ - variants.Language, - strings.ToLower(variants.OutputFormat.Name), - variants.OutputFormat.MediaType.FirstSuffix.Suffix, - }) -} - -func (s *shortcodeTemplates) fromVariantsSlice(variants []string) (shortcodeVariant, bool) { - var ( - bestMatch shortcodeVariant - bestMatchWeight int - ) - - for _, variant := range s.variants { - w := s.compareVariants(variants, variant.variants) - if bestMatchWeight == 0 || w > bestMatchWeight { - bestMatch = variant - bestMatchWeight = w - } - } - - return bestMatch, true -} - -// calculate a weight for two string slices of same length. -// higher value means "better match". -func (s *shortcodeTemplates) compareVariants(a, b []string) int { - weight := 0 - k := len(a) - for i, av := range a { - bv := b[i] - if av == bv { - // Add more weight to the left side (language...). - weight = weight + k - i - } else { - weight-- - } - } - return weight -} - -func templateVariants(name string) []string { - _, variants := templateNameAndVariants(name) - return variants -} - -func templateNameAndVariants(name string) (string, []string) { - variants := make([]string, numTemplateVariants) - - parts := strings.Split(name, ".") - - if len(parts) <= 1 { - // No variants. - return name, variants - } - - name = parts[0] - parts = parts[1:] - lp := len(parts) - start := len(variants) - lp - - for i, j := start, 0; i < len(variants); i, j = i+1, j+1 { - variants[i] = parts[j] - } - - if lp > 1 && lp < len(variants) { - for i := lp - 1; i > 0; i-- { - variants[i-1] = variants[i] - } - } - - if lp == 1 { - // Suffix only. Duplicate it into the output format field to - // make HTML win over AMP. - variants[len(variants)-2] = variants[len(variants)-1] - } - - return name, variants -} - -func resolveTemplateType(name string) templateType { - if isShortcode(name) { - return templateShortcode - } - - if strings.Contains(name, "partials/") { - return templatePartial - } - - return templateUndefined -} - -func isShortcode(name string) bool { - return strings.Contains(name, shortcodesPathPrefix) -} - -func isInternal(name string) bool { - return strings.HasPrefix(name, internalPathPrefix) -} diff --git a/tpl/tplimpl/shortcodes_test.go b/tpl/tplimpl/shortcodes_test.go deleted file mode 100644 index f97c7f278..000000000 --- a/tpl/tplimpl/shortcodes_test.go +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tplimpl - -import ( - "testing" - - qt "github.com/frankban/quicktest" -) - -func TestShortcodesTemplate(t *testing.T) { - t.Run("isShortcode", func(t *testing.T) { - c := qt.New(t) - c.Assert(isShortcode("shortcodes/figures.html"), qt.Equals, true) - c.Assert(isShortcode("_internal/shortcodes/figures.html"), qt.Equals, true) - c.Assert(isShortcode("shortcodes\\figures.html"), qt.Equals, false) - c.Assert(isShortcode("myshortcodes"), qt.Equals, false) - }) - - t.Run("variantsFromName", func(t *testing.T) { - c := qt.New(t) - c.Assert(templateVariants("figure.html"), qt.DeepEquals, []string{"", "html", "html"}) - c.Assert(templateVariants("figure.no.html"), qt.DeepEquals, []string{"no", "no", "html"}) - c.Assert(templateVariants("figure.no.amp.html"), qt.DeepEquals, []string{"no", "amp", "html"}) - c.Assert(templateVariants("figure.amp.html"), qt.DeepEquals, []string{"amp", "amp", "html"}) - - name, variants := templateNameAndVariants("figure.html") - c.Assert(name, qt.Equals, "figure") - c.Assert(variants, qt.DeepEquals, []string{"", "html", "html"}) - }) - - t.Run("compareVariants", func(t *testing.T) { - c := qt.New(t) - var s *shortcodeTemplates - - tests := []struct { - name string - name1 string - name2 string - expected int - }{ - {"Same suffix", "figure.html", "figure.html", 6}, - {"Same suffix and output format", "figure.html.html", "figure.html.html", 6}, - {"Same suffix, output format and language", "figure.no.html.html", "figure.no.html.html", 6}, - {"No suffix", "figure", "figure", 6}, - {"Different output format", "figure.amp.html", "figure.html.html", -1}, - {"One with output format, one without", "figure.amp.html", "figure.html", -1}, - } - - for _, test := range tests { - w := s.compareVariants(templateVariants(test.name1), templateVariants(test.name2)) - c.Assert(w, qt.Equals, test.expected) - } - }) - - t.Run("indexOf", func(t *testing.T) { - c := qt.New(t) - - s := &shortcodeTemplates{ - variants: []shortcodeVariant{ - {variants: []string{"a", "b", "c"}}, - {variants: []string{"a", "b", "d"}}, - }, - } - - c.Assert(s.indexOf([]string{"a", "b", "c"}), qt.Equals, 0) - c.Assert(s.indexOf([]string{"a", "b", "d"}), qt.Equals, 1) - c.Assert(s.indexOf([]string{"a", "b", "x"}), qt.Equals, -1) - }) - - t.Run("Name", func(t *testing.T) { - c := qt.New(t) - - c.Assert(templateBaseName(templateShortcode, "shortcodes/foo.html"), qt.Equals, "foo.html") - c.Assert(templateBaseName(templateShortcode, "_internal/shortcodes/foo.html"), qt.Equals, "foo.html") - c.Assert(templateBaseName(templateShortcode, "shortcodes/test/foo.html"), qt.Equals, "test/foo.html") - - c.Assert(true, qt.Equals, true) - }) -} diff --git a/tpl/tplimpl/subcategory_string.go b/tpl/tplimpl/subcategory_string.go new file mode 100644 index 000000000..a36bdee7c --- /dev/null +++ b/tpl/tplimpl/subcategory_string.go @@ -0,0 +1,25 @@ +// Code generated by "stringer -type SubCategory"; DO NOT EDIT. + +package tplimpl + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[SubCategoryMain-0] + _ = x[SubCategoryEmbedded-1] + _ = x[SubCategoryInline-2] +} + +const _SubCategory_name = "SubCategoryMainSubCategoryEmbeddedSubCategoryInline" + +var _SubCategory_index = [...]uint8{0, 15, 34, 51} + +func (i SubCategory) String() string { + if i < 0 || i >= SubCategory(len(_SubCategory_index)-1) { + return "SubCategory(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _SubCategory_name[_SubCategory_index[i]:_SubCategory_index[i+1]] +} diff --git a/tpl/tplimpl/template.go b/tpl/tplimpl/template.go deleted file mode 100644 index 3b643162a..000000000 --- a/tpl/tplimpl/template.go +++ /dev/null @@ -1,1235 +0,0 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tplimpl - -import ( - "bytes" - "context" - "embed" - "fmt" - "io" - "io/fs" - "path/filepath" - "reflect" - "regexp" - "sort" - "strings" - "sync" - "time" - "unicode" - "unicode/utf8" - - "github.com/gohugoio/hugo/common/maps" - "github.com/gohugoio/hugo/common/types" - "github.com/gohugoio/hugo/output/layouts" - - "github.com/gohugoio/hugo/helpers" - - "github.com/gohugoio/hugo/output" - - "github.com/gohugoio/hugo/deps" - "github.com/spf13/afero" - - "github.com/gohugoio/hugo/common/herrors" - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate/parse" - - htmltemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/htmltemplate" - texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate" - - "github.com/gohugoio/hugo/identity" - "github.com/gohugoio/hugo/tpl" -) - -const ( - textTmplNamePrefix = "_text/" - - shortcodesPathPrefix = "shortcodes/" - internalPathPrefix = "_internal/" - embeddedPathPrefix = "_embedded/" - baseFileBase = "baseof" -) - -// The identifiers may be truncated in the log, e.g. -// "executing "main" at <$scaled.SRelPermalin...>: can't evaluate field SRelPermalink in type *resource.Image" -// We need this to identify position in templates with base templates applied. -var identifiersRe = regexp.MustCompile(`at \<(.*?)(\.{3})?\>:`) - -// The tweet and twitter shortcodes were deprecated in favor of the x shortcode -// in v0.141.0. We can remove these aliases in v0.155.0 or later. -var embeddedTemplatesAliases = map[string][]string{ - "shortcodes/twitter.html": {"shortcodes/tweet.html"}, -} - -var ( - _ tpl.TemplateManager = (*templateExec)(nil) - _ tpl.TemplateHandler = (*templateExec)(nil) - _ tpl.TemplateFuncGetter = (*templateExec)(nil) - _ tpl.TemplateFinder = (*templateExec)(nil) - _ tpl.UnusedTemplatesProvider = (*templateExec)(nil) - - _ tpl.Template = (*templateState)(nil) - _ tpl.Info = (*templateState)(nil) -) - -var baseTemplateDefineRe = regexp.MustCompile(`^{{-?\s*define`) - -// needsBaseTemplate returns true if the first non-comment template block is a -// define block. -// If a base template does not exist, we will handle that when it's used. -func needsBaseTemplate(templ string) bool { - idx := -1 - inComment := false - for i := 0; i < len(templ); { - if !inComment && strings.HasPrefix(templ[i:], "{{/*") { - inComment = true - i += 4 - } else if !inComment && strings.HasPrefix(templ[i:], "{{- /*") { - inComment = true - i += 6 - } else if inComment && strings.HasPrefix(templ[i:], "*/}}") { - inComment = false - i += 4 - } else if inComment && strings.HasPrefix(templ[i:], "*/ -}}") { - inComment = false - i += 6 - } else { - r, size := utf8.DecodeRuneInString(templ[i:]) - if !inComment { - if strings.HasPrefix(templ[i:], "{{") { - idx = i - break - } else if !unicode.IsSpace(r) { - break - } - } - i += size - } - } - - if idx == -1 { - return false - } - - return baseTemplateDefineRe.MatchString(templ[idx:]) -} - -func newStandaloneTextTemplate(funcs map[string]any) tpl.TemplateParseFinder { - return &textTemplateWrapperWithLock{ - RWMutex: &sync.RWMutex{}, - Template: texttemplate.New("").Funcs(funcs), - } -} - -func newTemplateHandlers(d *deps.Deps) (*tpl.TemplateHandlers, error) { - exec, funcs := newTemplateExecuter(d) - funcMap := make(map[string]any) - for k, v := range funcs { - funcMap[k] = v.Interface() - } - - var templateUsageTracker map[string]templateInfo - if d.Conf.PrintUnusedTemplates() { - templateUsageTracker = make(map[string]templateInfo) - } - - h := &templateHandler{ - nameBaseTemplateName: make(map[string]string), - transformNotFound: make(map[string]*templateState), - - shortcodes: make(map[string]*shortcodeTemplates), - templateInfo: make(map[string]tpl.Info), - baseof: make(map[string]templateInfo), - needsBaseof: make(map[string]templateInfo), - - main: newTemplateNamespace(funcMap), - - Deps: d, - layoutHandler: layouts.NewLayoutHandler(), - layoutsFs: d.BaseFs.Layouts.Fs, - layoutTemplateCache: make(map[layoutCacheKey]layoutCacheEntry), - - templateUsageTracker: templateUsageTracker, - } - - if err := h.loadEmbedded(); err != nil { - return nil, err - } - - if err := h.loadTemplates(); err != nil { - return nil, err - } - - if err := h.main.createPrototypes(); err != nil { - return nil, err - } - - e := &templateExec{ - d: d, - executor: exec, - funcs: funcs, - templateHandler: h, - } - - if err := e.postTransform(); err != nil { - return nil, err - } - - return &tpl.TemplateHandlers{ - Tmpl: e, - TxtTmpl: newStandaloneTextTemplate(funcMap), - }, nil -} - -func newTemplateNamespace(funcs map[string]any) *templateNamespace { - return &templateNamespace{ - prototypeHTML: htmltemplate.New("").Funcs(funcs), - prototypeText: texttemplate.New("").Funcs(funcs), - prototypeHTMLCloneCache: maps.NewCache[prototypeCloneID, *htmltemplate.Template](), - prototypeTextCloneCache: maps.NewCache[prototypeCloneID, *texttemplate.Template](), - templateStateMap: &templateStateMap{ - templates: make(map[string]*templateState), - }, - } -} - -func newTemplateState(owner *templateState, templ tpl.Template, info templateInfo, id identity.Identity) *templateState { - if id == nil { - id = info - } - return &templateState{ - owner: owner, - info: info, - typ: info.resolveType(), - Template: templ, - parseInfo: tpl.DefaultParseInfo, - id: id, - } -} - -type layoutCacheKey struct { - d layouts.LayoutDescriptor - f string -} - -type templateExec struct { - d *deps.Deps - executor texttemplate.Executer - funcs map[string]reflect.Value - - *templateHandler -} - -func (t templateExec) Clone(d *deps.Deps) *templateExec { - exec, funcs := newTemplateExecuter(d) - t.executor = exec - t.funcs = funcs - t.d = d - return &t -} - -func (t *templateExec) Execute(templ tpl.Template, wr io.Writer, data any) error { - return t.ExecuteWithContext(context.Background(), templ, wr, data) -} - -func (t *templateExec) ExecuteWithContext(ctx context.Context, templ tpl.Template, wr io.Writer, data any) error { - if rlocker, ok := templ.(types.RLocker); ok { - rlocker.RLock() - defer rlocker.RUnlock() - } - if t.Metrics != nil { - defer t.Metrics.MeasureSince(templ.Name(), time.Now()) - } - - if t.templateUsageTracker != nil { - if ts, ok := templ.(*templateState); ok { - - t.templateUsageTrackerMu.Lock() - if _, found := t.templateUsageTracker[ts.Name()]; !found { - t.templateUsageTracker[ts.Name()] = ts.info - } - - if !ts.baseInfo.IsZero() { - if _, found := t.templateUsageTracker[ts.baseInfo.name]; !found { - t.templateUsageTracker[ts.baseInfo.name] = ts.baseInfo - } - } - t.templateUsageTrackerMu.Unlock() - } - } - - execErr := t.executor.ExecuteWithContext(ctx, templ, wr, data) - if execErr != nil { - owner := templ - if ts, ok := templ.(*templateState); ok && ts.owner != nil { - owner = ts.owner - } - execErr = t.addFileContext(owner, execErr) - } - return execErr -} - -func (t *templateExec) UnusedTemplates() []tpl.FileInfo { - if t.templateUsageTracker == nil { - return nil - } - var unused []tpl.FileInfo - - for _, ti := range t.needsBaseof { - if _, found := t.templateUsageTracker[ti.name]; !found { - unused = append(unused, ti) - } - } - - for _, ti := range t.baseof { - if _, found := t.templateUsageTracker[ti.name]; !found { - unused = append(unused, ti) - } - } - - for _, ts := range t.main.templates { - ti := ts.info - if strings.HasPrefix(ti.name, "_internal/") || ti.meta == nil { - continue - } - - if _, found := t.templateUsageTracker[ti.name]; !found { - unused = append(unused, ti) - } - } - - sort.Slice(unused, func(i, j int) bool { - return unused[i].Name() < unused[j].Name() - }) - - return unused -} - -func (t *templateExec) GetFunc(name string) (reflect.Value, bool) { - v, found := t.funcs[name] - return v, found -} - -type templateHandler struct { - main *templateNamespace - needsBaseof map[string]templateInfo - baseof map[string]templateInfo - - // This is the filesystem to load the templates from. All the templates are - // stored in the root of this filesystem. - layoutsFs afero.Fs - - layoutHandler *layouts.LayoutHandler - - layoutTemplateCache map[layoutCacheKey]layoutCacheEntry - layoutTemplateCacheMu sync.RWMutex - - *deps.Deps - - // Used to get proper filenames in errors - nameBaseTemplateName map[string]string - - // Holds name and source of template definitions not found during the first - // AST transformation pass. - transformNotFound map[string]*templateState - - // shortcodes maps shortcode name to template variants - // (language, output format etc.) of that shortcode. - shortcodes map[string]*shortcodeTemplates - - // templateInfo maps template name to some additional information about that template. - // Note that for shortcodes that same information is embedded in the - // shortcodeTemplates type. - templateInfo map[string]tpl.Info - - // May be nil. - templateUsageTracker map[string]templateInfo - templateUsageTrackerMu sync.Mutex -} - -type layoutCacheEntry struct { - found bool - templ tpl.Template - err error -} - -// AddTemplate parses and adds a template to the collection. -// Templates with name prefixed with "_text" will be handled as plain -// text templates. -func (t *templateHandler) AddTemplate(name, tpl string) error { - templ, err := t.addTemplateTo(t.newTemplateInfo(name, tpl), t.main) - if err == nil { - _, err = t.applyTemplateTransformers(t.main, templ) - } - return err -} - -func (t *templateHandler) Lookup(name string) (tpl.Template, bool) { - templ, found := t.main.Lookup(name) - if found { - return templ, true - } - - return nil, false -} - -func (t *templateHandler) LookupLayout(d layouts.LayoutDescriptor, f output.Format) (tpl.Template, bool, error) { - key := layoutCacheKey{d, f.Name} - t.layoutTemplateCacheMu.RLock() - if cacheVal, found := t.layoutTemplateCache[key]; found { - t.layoutTemplateCacheMu.RUnlock() - return cacheVal.templ, cacheVal.found, cacheVal.err - } - - t.layoutTemplateCacheMu.RUnlock() - - t.layoutTemplateCacheMu.Lock() - defer t.layoutTemplateCacheMu.Unlock() - - templ, found, err := t.findLayout(d, f) - cacheVal := layoutCacheEntry{found: found, templ: templ, err: err} - t.layoutTemplateCache[key] = cacheVal - return cacheVal.templ, cacheVal.found, cacheVal.err -} - -// This currently only applies to shortcodes and what we get here is the -// shortcode name. -func (t *templateHandler) LookupVariant(name string, variants tpl.TemplateVariants) (tpl.Template, bool, bool) { - name = templateBaseName(templateShortcode, name) - s, found := t.shortcodes[name] - if !found { - return nil, false, false - } - - sv, found := s.fromVariants(variants) - if !found { - return nil, false, false - } - - more := len(s.variants) > 1 - - return sv.ts, true, more -} - -// LookupVariants returns all variants of name, nil if none found. -func (t *templateHandler) LookupVariants(name string) []tpl.Template { - name = templateBaseName(templateShortcode, name) - s, found := t.shortcodes[name] - if !found { - return nil - } - - variants := make([]tpl.Template, len(s.variants)) - for i := range variants { - variants[i] = s.variants[i].ts - } - - return variants -} - -func (t *templateHandler) HasTemplate(name string) bool { - if _, found := t.baseof[name]; found { - return true - } - - if _, found := t.needsBaseof[name]; found { - return true - } - - _, found := t.Lookup(name) - return found -} - -func (t *templateHandler) GetIdentity(name string) (identity.Identity, bool) { - if _, found := t.needsBaseof[name]; found { - return identity.StringIdentity(name), true - } - - if _, found := t.baseof[name]; found { - return identity.StringIdentity(name), true - } - - tt, found := t.Lookup(name) - if !found { - return nil, false - } - return tt.(identity.IdentityProvider).GetIdentity(), found -} - -func (t *templateHandler) findLayout(d layouts.LayoutDescriptor, f output.Format) (tpl.Template, bool, error) { - d.OutputFormatName = f.Name - d.Suffix = f.MediaType.FirstSuffix.Suffix - layouts, _ := t.layoutHandler.For(d) - for _, name := range layouts { - templ, found := t.main.Lookup(name) - if found { - return templ, true, nil - } - - overlay, found := t.needsBaseof[name] - - if !found { - continue - } - - d.Baseof = true - baseLayouts, _ := t.layoutHandler.For(d) - var base templateInfo - found = false - for _, l := range baseLayouts { - base, found = t.baseof[l] - if found { - break - } - } - - templ, err := t.applyBaseTemplate(overlay, base) - if err != nil { - return nil, false, err - } - - ts := newTemplateState(nil, templ, overlay, identity.Or(base, overlay)) - - if found { - ts.baseInfo = base - } - - if _, err := t.applyTemplateTransformers(t.main, ts); err != nil { - return nil, false, err - } - - if err := t.extractPartials(ts.Template); err != nil { - return nil, false, err - } - - return ts, true, nil - - } - - return nil, false, nil -} - -func (t *templateHandler) newTemplateInfo(name, tpl string) templateInfo { - var isText bool - var isEmbedded bool - - if strings.HasPrefix(name, embeddedPathPrefix) { - isEmbedded = true - name = strings.TrimPrefix(name, embeddedPathPrefix) - } - - name, isText = t.nameIsText(name) - return templateInfo{ - name: name, - isText: isText, - isEmbedded: isEmbedded, - template: tpl, - } -} - -func (t *templateHandler) addFileContext(templ tpl.Template, inerr error) error { - if strings.HasPrefix(templ.Name(), "_internal") { - return inerr - } - - ts, ok := templ.(*templateState) - if !ok { - return inerr - } - - identifiers := t.extractIdentifiers(inerr.Error()) - - checkFilename := func(info templateInfo, inErr error) (error, bool) { - if info.meta == nil { - return inErr, false - } - - lineMatcher := func(m herrors.LineMatcher) int { - if m.Position.LineNumber != m.LineNumber { - return -1 - } - - for _, id := range identifiers { - if strings.Contains(m.Line, id) { - // We found the line, but return a 0 to signal to - // use the column from the error message. - return 0 - } - } - return -1 - } - - f, err := info.meta.Open() - if err != nil { - return inErr, false - } - defer f.Close() - - fe := herrors.NewFileErrorFromName(inErr, info.meta.Filename) - fe.UpdateContent(f, lineMatcher) - - if !fe.ErrorContext().Position.IsValid() { - return inErr, false - } - return fe, true - } - - inerr = fmt.Errorf("execute of template failed: %w", inerr) - - if err, ok := checkFilename(ts.info, inerr); ok { - return err - } - - err, _ := checkFilename(ts.baseInfo, inerr) - - return err -} - -func (t *templateHandler) extractIdentifiers(line string) []string { - m := identifiersRe.FindAllStringSubmatch(line, -1) - identifiers := make([]string, len(m)) - for i := range m { - identifiers[i] = m[i][1] - } - return identifiers -} - -func (t *templateHandler) addShortcodeVariant(ts *templateState) { - name := ts.Name() - base := templateBaseName(templateShortcode, name) - - shortcodename, variants := templateNameAndVariants(base) - - templs, found := t.shortcodes[shortcodename] - if !found { - templs = &shortcodeTemplates{} - t.shortcodes[shortcodename] = templs - } - - sv := shortcodeVariant{variants: variants, ts: ts} - - i := templs.indexOf(variants) - - if i != -1 { - // Only replace if it's an override of an internal template. - if !isInternal(name) { - templs.variants[i] = sv - } - } else { - templs.variants = append(templs.variants, sv) - } -} - -func (t *templateHandler) addTemplateFile(name string, fim hugofs.FileMetaInfo) error { - getTemplate := func(fim hugofs.FileMetaInfo) (templateInfo, error) { - meta := fim.Meta() - f, err := meta.Open() - if err != nil { - return templateInfo{meta: meta}, err - } - defer f.Close() - b, err := io.ReadAll(f) - if err != nil { - return templateInfo{meta: meta}, err - } - - s := removeLeadingBOM(string(b)) - - var isText bool - name, isText = t.nameIsText(name) - - return templateInfo{ - name: name, - isText: isText, - template: s, - meta: meta, - }, nil - } - - tinfo, err := getTemplate(fim) - if err != nil { - return err - } - - if isBaseTemplatePath(name) { - // Store it for later. - t.baseof[name] = tinfo - return nil - } - - needsBaseof := !t.noBaseNeeded(name) && needsBaseTemplate(tinfo.template) - if needsBaseof { - t.needsBaseof[name] = tinfo - return nil - } - - templ, err := t.addTemplateTo(tinfo, t.main) - if err != nil { - return tinfo.errWithFileContext("parse failed", err) - } - - if _, err = t.applyTemplateTransformers(t.main, templ); err != nil { - return tinfo.errWithFileContext("transform failed", err) - } - - return nil -} - -func (t *templateHandler) addTemplateTo(info templateInfo, to *templateNamespace) (*templateState, error) { - return to.parse(info) -} - -func (t *templateHandler) applyBaseTemplate(overlay, base templateInfo) (tpl.Template, error) { - if overlay.isText { - var ( - templ = t.main.getPrototypeText(prototypeCloneIDBaseof).New(overlay.name) - err error - ) - - if !base.IsZero() { - templ, err = templ.Parse(base.template) - if err != nil { - return nil, base.errWithFileContext("text: base: parse failed", err) - } - } - - templ, err = texttemplate.Must(templ.Clone()).Parse(overlay.template) - if err != nil { - return nil, overlay.errWithFileContext("text: overlay: parse failed", err) - } - - // The extra lookup is a workaround, see - // * https://github.com/golang/go/issues/16101 - // * https://github.com/gohugoio/hugo/issues/2549 - // templ = templ.Lookup(templ.Name()) - - return templ, nil - } - - var ( - templ = t.main.getPrototypeHTML(prototypeCloneIDBaseof).New(overlay.name) - err error - ) - - if !base.IsZero() { - templ, err = templ.Parse(base.template) - if err != nil { - return nil, base.errWithFileContext("html: base: parse failed", err) - } - } - - templ, err = htmltemplate.Must(templ.Clone()).Parse(overlay.template) - if err != nil { - return nil, overlay.errWithFileContext("html: overlay: parse failed", err) - } - - // The extra lookup is a workaround, see - // * https://github.com/golang/go/issues/16101 - // * https://github.com/gohugoio/hugo/issues/2549 - templ = templ.Lookup(templ.Name()) - - return templ, err -} - -func (t *templateHandler) applyTemplateTransformers(ns *templateNamespace, ts *templateState) (*templateContext, error) { - c, err := applyTemplateTransformers(ts, ns.newTemplateLookup(ts)) - if err != nil { - return nil, err - } - - for k := range c.templateNotFound { - t.transformNotFound[k] = ts - } - - for k, v := range c.deferNodes { - if err = t.main.addDeferredTemplate(ts, k, v); err != nil { - return nil, err - } - } - - return c, err -} - -//go:embed all:embedded/templates/* -//go:embed embedded/templates/_default/* -//go:embed embedded/templates/_server/* -var embeddedTemplatesFs embed.FS - -func (t *templateHandler) loadEmbedded() error { - return fs.WalkDir(embeddedTemplatesFs, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d == nil || d.IsDir() { - return nil - } - - templb, err := embeddedTemplatesFs.ReadFile(path) - if err != nil { - return err - } - - // Get the newlines on Windows in line with how we had it back when we used Go Generate - // to write the templates to Go files. - templ := string(bytes.ReplaceAll(templb, []byte("\r\n"), []byte("\n"))) - name := strings.TrimPrefix(filepath.ToSlash(path), "embedded/templates/") - templateName := name - - // For the render hooks and the server templates it does not make sense to preserve the - // double _internal double book-keeping, - // just add it if its now provided by the user. - if !strings.Contains(path, "_default/_markup") && !strings.HasPrefix(name, "_server/") && !strings.HasPrefix(name, "partials/_funcs/") { - templateName = internalPathPrefix + name - } - - if _, found := t.Lookup(templateName); !found { - if err := t.AddTemplate(embeddedPathPrefix+templateName, templ); err != nil { - return err - } - } - - if aliases, found := embeddedTemplatesAliases[name]; found { - // TODO(bep) avoid reparsing these aliases - for _, alias := range aliases { - alias = internalPathPrefix + alias - if err := t.AddTemplate(embeddedPathPrefix+alias, templ); err != nil { - return err - } - } - } - - return nil - }) -} - -func (t *templateHandler) loadTemplates() error { - walker := func(path string, fi hugofs.FileMetaInfo) error { - if fi.IsDir() { - return nil - } - - if isDotFile(path) || isBackupFile(path) { - return nil - } - - name := strings.TrimPrefix(filepath.ToSlash(path), "/") - filename := filepath.Base(path) - outputFormats := t.Conf.GetConfigSection("outputFormats").(output.Formats) - outputFormat, found := outputFormats.FromFilename(filename) - - if found && outputFormat.IsPlainText { - name = textTmplNamePrefix + name - } - - if err := t.addTemplateFile(name, fi); err != nil { - return err - } - - return nil - } - - if err := helpers.Walk(t.Layouts.Fs, "", walker); err != nil { - if !herrors.IsNotExist(err) { - return err - } - return nil - } - - return nil -} - -func (t *templateHandler) nameIsText(name string) (string, bool) { - isText := strings.HasPrefix(name, textTmplNamePrefix) - if isText { - name = strings.TrimPrefix(name, textTmplNamePrefix) - } - return name, isText -} - -func (t *templateHandler) noBaseNeeded(name string) bool { - if strings.HasPrefix(name, "shortcodes/") || strings.HasPrefix(name, "partials/") { - return true - } - return strings.Contains(name, "_markup/") -} - -func (t *templateHandler) extractPartials(templ tpl.Template) error { - templs := templates(templ) - for _, templ := range templs { - if templ.Name() == "" || !strings.HasPrefix(templ.Name(), "partials/") { - continue - } - - ts := newTemplateState(nil, templ, templateInfo{name: templ.Name()}, nil) - ts.typ = templatePartial - - t.main.mu.RLock() - _, found := t.main.templates[templ.Name()] - t.main.mu.RUnlock() - - if !found { - t.main.mu.Lock() - // This is a template defined inline. - _, err := applyTemplateTransformers(ts, t.main.newTemplateLookup(ts)) - if err != nil { - t.main.mu.Unlock() - return err - } - t.main.templates[templ.Name()] = ts - t.main.mu.Unlock() - - } - } - - return nil -} - -func (t *templateHandler) postTransform() error { - defineCheckedHTML := false - defineCheckedText := false - - for _, v := range t.main.templates { - if v.typ == templateShortcode { - t.addShortcodeVariant(v) - } - - if defineCheckedHTML && defineCheckedText { - continue - } - - isText := isText(v.Template) - if isText { - if defineCheckedText { - continue - } - defineCheckedText = true - } else { - if defineCheckedHTML { - continue - } - defineCheckedHTML = true - } - - if err := t.extractPartials(v.Template); err != nil { - return err - } - } - - for name, source := range t.transformNotFound { - lookup := t.main.newTemplateLookup(source) - templ := lookup(name) - if templ != nil { - _, err := applyTemplateTransformers(templ, lookup) - if err != nil { - return err - } - } - } - - for _, v := range t.shortcodes { - sort.Slice(v.variants, func(i, j int) bool { - v1, v2 := v.variants[i], v.variants[j] - name1, name2 := v1.ts.Name(), v2.ts.Name() - isHTMl1, isHTML2 := strings.HasSuffix(name1, "html"), strings.HasSuffix(name2, "html") - - // There will be a weighted selection later, but make - // sure these are sorted to get a stable selection for - // output formats missing specific templates. - // Prefer HTML. - if isHTMl1 || isHTML2 && !(isHTMl1 && isHTML2) { - return isHTMl1 - } - - return name1 < name2 - }) - } - - return nil -} - -type prototypeCloneID uint16 - -const ( - prototypeCloneIDBaseof prototypeCloneID = iota + 1 - prototypeCloneIDDefer -) - -type templateNamespace struct { - prototypeText *texttemplate.Template - prototypeHTML *htmltemplate.Template - - prototypeHTMLCloneCache *maps.Cache[prototypeCloneID, *htmltemplate.Template] - prototypeTextCloneCache *maps.Cache[prototypeCloneID, *texttemplate.Template] - - *templateStateMap -} - -func (t *templateNamespace) getPrototypeText(id prototypeCloneID) *texttemplate.Template { - v, ok := t.prototypeTextCloneCache.Get(id) - if !ok { - return t.prototypeText - } - return v -} - -func (t *templateNamespace) getPrototypeHTML(id prototypeCloneID) *htmltemplate.Template { - v, ok := t.prototypeHTMLCloneCache.Get(id) - if !ok { - return t.prototypeHTML - } - return v -} - -func (t *templateNamespace) Lookup(name string) (tpl.Template, bool) { - t.mu.RLock() - defer t.mu.RUnlock() - - templ, found := t.templates[name] - if !found { - return nil, false - } - - return templ, found -} - -func (t *templateNamespace) createPrototypes() error { - for _, id := range []prototypeCloneID{prototypeCloneIDBaseof, prototypeCloneIDDefer} { - t.prototypeHTMLCloneCache.Set(id, htmltemplate.Must(t.prototypeHTML.Clone())) - t.prototypeTextCloneCache.Set(id, texttemplate.Must(t.prototypeText.Clone())) - } - return nil -} - -func (t *templateNamespace) newTemplateLookup(in *templateState) func(name string) *templateState { - return func(name string) *templateState { - if templ, found := t.templates[name]; found { - if templ.isText() != in.isText() { - return nil - } - return templ - } - if templ, found := findTemplateIn(name, in); found { - return newTemplateState(nil, templ, templateInfo{name: templ.Name()}, nil) - } - return nil - } -} - -func (t *templateNamespace) addDeferredTemplate(owner *templateState, name string, n *parse.ListNode) error { - t.mu.Lock() - defer t.mu.Unlock() - - if _, found := t.templates[name]; found { - return nil - } - - var templ tpl.Template - - if owner.isText() { - prototype := t.getPrototypeText(prototypeCloneIDDefer) - tt, err := prototype.New(name).Parse("") - if err != nil { - return fmt.Errorf("failed to parse empty text template %q: %w", name, err) - } - tt.Tree.Root = n - templ = tt - } else { - prototype := t.getPrototypeHTML(prototypeCloneIDDefer) - tt, err := prototype.New(name).Parse("") - if err != nil { - return fmt.Errorf("failed to parse empty HTML template %q: %w", name, err) - } - tt.Tree.Root = n - templ = tt - } - - dts := newTemplateState(owner, templ, templateInfo{name: name}, nil) - t.templates[name] = dts - - return nil -} - -func (t *templateNamespace) parse(info templateInfo) (*templateState, error) { - t.mu.Lock() - defer t.mu.Unlock() - - if info.isText { - prototype := t.prototypeText - - templ, err := prototype.New(info.name).Parse(info.template) - if err != nil { - return nil, err - } - - ts := newTemplateState(nil, templ, info, nil) - - t.templates[info.name] = ts - - return ts, nil - } - - prototype := t.prototypeHTML - - templ, err := prototype.New(info.name).Parse(info.template) - if err != nil { - return nil, err - } - - ts := newTemplateState(nil, templ, info, nil) - - t.templates[info.name] = ts - - return ts, nil -} - -var _ tpl.IsInternalTemplateProvider = (*templateState)(nil) - -type templateState struct { - tpl.Template - - // Set for deferred templates. - owner *templateState - - typ templateType - parseInfo tpl.ParseInfo - id identity.Identity - - info templateInfo - baseInfo templateInfo // Set when a base template is used. -} - -func (t *templateState) IsInternalTemplate() bool { - return t.info.isEmbedded -} - -func (t *templateState) GetIdentity() identity.Identity { - return t.id -} - -func (t *templateState) ParseInfo() tpl.ParseInfo { - return t.parseInfo -} - -func (t *templateState) isText() bool { - return isText(t.Template) -} - -func (t *templateState) String() string { - return t.Name() -} - -func isText(templ tpl.Template) bool { - _, isText := templ.(*texttemplate.Template) - return isText -} - -type templateStateMap struct { - mu sync.RWMutex - templates map[string]*templateState -} - -type textTemplateWrapperWithLock struct { - *sync.RWMutex - *texttemplate.Template -} - -func (t *textTemplateWrapperWithLock) Lookup(name string) (tpl.Template, bool) { - t.RLock() - templ := t.Template.Lookup(name) - t.RUnlock() - if templ == nil { - return nil, false - } - return &textTemplateWrapperWithLock{ - RWMutex: t.RWMutex, - Template: templ, - }, true -} - -func (t *textTemplateWrapperWithLock) LookupVariant(name string, variants tpl.TemplateVariants) (tpl.Template, bool, bool) { - panic("not supported") -} - -func (t *textTemplateWrapperWithLock) LookupVariants(name string) []tpl.Template { - panic("not supported") -} - -func (t *textTemplateWrapperWithLock) Parse(name, tpl string) (tpl.Template, error) { - t.Lock() - defer t.Unlock() - return t.Template.New(name).Parse(tpl) -} - -func isBackupFile(path string) bool { - return path[len(path)-1] == '~' -} - -func isBaseTemplatePath(path string) bool { - return strings.Contains(filepath.Base(path), baseFileBase) -} - -func isDotFile(path string) bool { - return filepath.Base(path)[0] == '.' -} - -func removeLeadingBOM(s string) string { - const bom = '\ufeff' - - for i, r := range s { - if i == 0 && r != bom { - return s - } - if i > 0 { - return s[i:] - } - } - - return s -} - -// resolves _internal/shortcodes/param.html => param.html etc. -func templateBaseName(typ templateType, name string) string { - name = strings.TrimPrefix(name, internalPathPrefix) - switch typ { - case templateShortcode: - return strings.TrimPrefix(name, shortcodesPathPrefix) - default: - panic("not implemented") - } -} - -func unwrap(templ tpl.Template) tpl.Template { - if ts, ok := templ.(*templateState); ok { - return ts.Template - } - return templ -} - -func templates(in tpl.Template) []tpl.Template { - var templs []tpl.Template - in = unwrap(in) - if textt, ok := in.(*texttemplate.Template); ok { - for _, t := range textt.Templates() { - templs = append(templs, t) - } - } - - if htmlt, ok := in.(*htmltemplate.Template); ok { - for _, t := range htmlt.Templates() { - templs = append(templs, t) - } - } - - return templs -} diff --git a/tpl/tplimpl/templateFuncster.go b/tpl/tplimpl/templateFuncster.go deleted file mode 100644 index 96404f51b..000000000 --- a/tpl/tplimpl/templateFuncster.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tplimpl diff --git a/tpl/tplimpl/templateProvider.go b/tpl/tplimpl/templateProvider.go deleted file mode 100644 index 435868964..000000000 --- a/tpl/tplimpl/templateProvider.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2017-present The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tplimpl - -import ( - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/tpl" -) - -// TemplateProvider manages templates. -type TemplateProvider struct{} - -// DefaultTemplateProvider is a globally available TemplateProvider. -var DefaultTemplateProvider *TemplateProvider - -// Update updates the Hugo Template System in the provided Deps -// with all the additional features, templates & functions. -func (*TemplateProvider) NewResource(dst *deps.Deps) error { - handlers, err := newTemplateHandlers(dst) - if err != nil { - return err - } - dst.SetTempl(handlers) - return nil -} - -// Clone clones. -func (*TemplateProvider) CloneResource(dst, src *deps.Deps) error { - t := src.Tmpl().(*templateExec) - c := t.Clone(dst) - funcMap := make(map[string]any) - for k, v := range c.funcs { - funcMap[k] = v.Interface() - } - dst.SetTempl(&tpl.TemplateHandlers{ - Tmpl: c, - TxtTmpl: newStandaloneTextTemplate(funcMap), - }) - return nil -} diff --git a/tpl/tplimpl/template_ast_transformers_test.go b/tpl/tplimpl/template_ast_transformers_test.go deleted file mode 100644 index 630415dac..000000000 --- a/tpl/tplimpl/template_ast_transformers_test.go +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -package tplimpl - -import ( - "testing" - - template "github.com/gohugoio/hugo/tpl/internal/go_templates/htmltemplate" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/tpl" -) - -// Issue #2927 -func TestTransformRecursiveTemplate(t *testing.T) { - c := qt.New(t) - - recursive := ` -{{ define "menu-nodes" }} -{{ template "menu-node" }} -{{ end }} -{{ define "menu-node" }} -{{ template "menu-node" }} -{{ end }} -{{ template "menu-nodes" }} -` - - templ, err := template.New("foo").Parse(recursive) - c.Assert(err, qt.IsNil) - ts := newTestTemplate(templ) - - ctx := newTemplateContext( - ts, - newTestTemplateLookup(ts), - ) - ctx.applyTransformations(templ.Tree.Root) -} - -func newTestTemplate(templ tpl.Template) *templateState { - return newTemplateState(nil, - templ, - templateInfo{ - name: templ.Name(), - }, - nil, - ) -} - -func newTestTemplateLookup(in *templateState) func(name string) *templateState { - m := make(map[string]*templateState) - return func(name string) *templateState { - if in.Name() == name { - return in - } - - if ts, found := m[name]; found { - return ts - } - - if templ, found := findTemplateIn(name, in); found { - ts := newTestTemplate(templ) - m[name] = ts - return ts - } - - return nil - } -} - -func TestCollectInfo(t *testing.T) { - configStr := `{ "version": 42 }` - - tests := []struct { - name string - tplString string - expected tpl.ParseInfo - }{ - {"Basic Inner", `{{ .Inner }}`, tpl.ParseInfo{IsInner: true, Config: tpl.DefaultParseConfig}}, - {"Basic config map", "{{ $_hugo_config := `" + configStr + "` }}", tpl.ParseInfo{Config: tpl.ParseConfig{Version: 42}}}, - } - - echo := func(in any) any { - return in - } - - funcs := template.FuncMap{ - "highlight": echo, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - c := qt.New(t) - - templ, err := template.New("foo").Funcs(funcs).Parse(test.tplString) - c.Assert(err, qt.IsNil) - ts := newTestTemplate(templ) - ts.typ = templateShortcode - ctx := newTemplateContext( - ts, - newTestTemplateLookup(ts), - ) - ctx.applyTransformations(templ.Tree.Root) - c.Assert(ctx.t.parseInfo, qt.DeepEquals, test.expected) - }) - } -} - -func TestPartialReturn(t *testing.T) { - tests := []struct { - name string - tplString string - expected bool - }{ - {"Basic", ` -{{ $a := "Hugo Rocks!" }} -{{ return $a }} -`, true}, - {"Expression", ` -{{ return add 32 }} -`, true}, - } - - echo := func(in any) any { - return in - } - - funcs := template.FuncMap{ - "return": echo, - "add": echo, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - c := qt.New(t) - - templ, err := template.New("foo").Funcs(funcs).Parse(test.tplString) - c.Assert(err, qt.IsNil) - ts := newTestTemplate(templ) - ctx := newTemplateContext( - ts, - newTestTemplateLookup(ts), - ) - - _, err = ctx.applyTransformations(templ.Tree.Root) - - // Just check that it doesn't fail in this test. We have functional tests - // in hugoblib. - c.Assert(err, qt.IsNil) - }) - } -} diff --git a/tpl/tplimpl/template_errors.go b/tpl/tplimpl/template_errors.go deleted file mode 100644 index a9d259220..000000000 --- a/tpl/tplimpl/template_errors.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tplimpl - -import ( - "fmt" - - "github.com/gohugoio/hugo/common/herrors" - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/identity" -) - -var _ identity.Identity = (*templateInfo)(nil) - -type templateInfo struct { - name string - template string - isText bool // HTML or plain text template. - isEmbedded bool - - meta *hugofs.FileMeta -} - -func (t templateInfo) IdentifierBase() string { - return t.name -} - -func (t templateInfo) Name() string { - return t.name -} - -func (t templateInfo) Filename() string { - return t.meta.Filename -} - -func (t templateInfo) IsZero() bool { - return t.name == "" -} - -func (t templateInfo) resolveType() templateType { - return resolveTemplateType(t.name) -} - -func (info templateInfo) errWithFileContext(what string, err error) error { - err = fmt.Errorf(what+": %w", err) - fe := herrors.NewFileErrorFromName(err, info.meta.Filename) - f, err := info.meta.Open() - if err != nil { - return err - } - defer f.Close() - return fe.UpdateContent(f, nil) -} diff --git a/tpl/tplimpl/template_funcs.go b/tpl/tplimpl/template_funcs.go index b181db061..18d4d4a27 100644 --- a/tpl/tplimpl/template_funcs.go +++ b/tpl/tplimpl/template_funcs.go @@ -1,4 +1,4 @@ -// Copyright 2017-present The Hugo Authors. All rights reserved. +// Copyright 2025 The Hugo Authors. All rights reserved. // // Portions Copyright The Go Authors. @@ -25,46 +25,7 @@ import ( "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/tpl" - template "github.com/gohugoio/hugo/tpl/internal/go_templates/htmltemplate" texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate" - - "github.com/gohugoio/hugo/deps" - - "github.com/gohugoio/hugo/tpl/internal" - - // Init the namespaces - _ "github.com/gohugoio/hugo/tpl/cast" - _ "github.com/gohugoio/hugo/tpl/collections" - _ "github.com/gohugoio/hugo/tpl/compare" - _ "github.com/gohugoio/hugo/tpl/crypto" - _ "github.com/gohugoio/hugo/tpl/css" - _ "github.com/gohugoio/hugo/tpl/data" - _ "github.com/gohugoio/hugo/tpl/debug" - _ "github.com/gohugoio/hugo/tpl/diagrams" - _ "github.com/gohugoio/hugo/tpl/encoding" - _ "github.com/gohugoio/hugo/tpl/fmt" - _ "github.com/gohugoio/hugo/tpl/hash" - _ "github.com/gohugoio/hugo/tpl/hugo" - _ "github.com/gohugoio/hugo/tpl/images" - _ "github.com/gohugoio/hugo/tpl/inflect" - _ "github.com/gohugoio/hugo/tpl/js" - _ "github.com/gohugoio/hugo/tpl/lang" - _ "github.com/gohugoio/hugo/tpl/math" - _ "github.com/gohugoio/hugo/tpl/openapi/openapi3" - _ "github.com/gohugoio/hugo/tpl/os" - _ "github.com/gohugoio/hugo/tpl/page" - _ "github.com/gohugoio/hugo/tpl/partials" - _ "github.com/gohugoio/hugo/tpl/path" - _ "github.com/gohugoio/hugo/tpl/reflect" - _ "github.com/gohugoio/hugo/tpl/resources" - _ "github.com/gohugoio/hugo/tpl/safe" - _ "github.com/gohugoio/hugo/tpl/site" - _ "github.com/gohugoio/hugo/tpl/strings" - _ "github.com/gohugoio/hugo/tpl/templates" - _ "github.com/gohugoio/hugo/tpl/time" - _ "github.com/gohugoio/hugo/tpl/transform" - _ "github.com/gohugoio/hugo/tpl/urls" - maps0 "maps" ) var ( @@ -212,89 +173,3 @@ func (t *templateExecHelper) trackDependencies(ctx context.Context, tmpl texttem return ctx } - -func newTemplateExecuter(d *deps.Deps) (texttemplate.Executer, map[string]reflect.Value) { - funcs := createFuncMap(d) - funcsv := make(map[string]reflect.Value) - - for k, v := range funcs { - vv := reflect.ValueOf(v) - funcsv[k] = vv - } - - // Duplicate Go's internal funcs here for faster lookups. - for k, v := range template.GoFuncs { - if _, exists := funcsv[k]; !exists { - vv, ok := v.(reflect.Value) - if !ok { - vv = reflect.ValueOf(v) - } - funcsv[k] = vv - } - } - - for k, v := range texttemplate.GoFuncs { - if _, exists := funcsv[k]; !exists { - funcsv[k] = v - } - } - - exeHelper := &templateExecHelper{ - watching: d.Conf.Watching(), - funcs: funcsv, - site: reflect.ValueOf(d.Site), - siteParams: reflect.ValueOf(d.Site.Params()), - } - - return texttemplate.NewExecuter( - exeHelper, - ), funcsv -} - -func createFuncMap(d *deps.Deps) map[string]any { - if d.TmplFuncMap != nil { - return d.TmplFuncMap - } - funcMap := template.FuncMap{} - - nsMap := make(map[string]any) - var onCreated []func(namespaces map[string]any) - - // Merge the namespace funcs - for _, nsf := range internal.TemplateFuncsNamespaceRegistry { - ns := nsf(d) - if _, exists := funcMap[ns.Name]; exists { - panic(ns.Name + " is a duplicate template func") - } - funcMap[ns.Name] = ns.Context - contextV, err := ns.Context(context.Background()) - if err != nil { - panic(err) - } - nsMap[ns.Name] = contextV - for _, mm := range ns.MethodMappings { - for _, alias := range mm.Aliases { - if _, exists := funcMap[alias]; exists { - panic(alias + " is a duplicate template func") - } - funcMap[alias] = mm.Method - } - } - - if ns.OnCreated != nil { - onCreated = append(onCreated, ns.OnCreated) - } - } - - for _, f := range onCreated { - f(nsMap) - } - - if d.OverloadedTemplateFuncs != nil { - maps0.Copy(funcMap, d.OverloadedTemplateFuncs) - } - - d.TmplFuncMap = funcMap - - return d.TmplFuncMap -} diff --git a/tpl/tplimpl/template_funcs_test.go b/tpl/tplimpl/template_funcs_test.go index 2c3cc541a..639db909a 100644 --- a/tpl/tplimpl/template_funcs_test.go +++ b/tpl/tplimpl/template_funcs_test.go @@ -1,4 +1,4 @@ -// Copyright 2019 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. diff --git a/tpl/template_info.go b/tpl/tplimpl/template_info.go similarity index 73% rename from tpl/template_info.go rename to tpl/tplimpl/template_info.go index fd126d80f..0bc807d91 100644 --- a/tpl/template_info.go +++ b/tpl/tplimpl/template_info.go @@ -1,4 +1,4 @@ -// Copyright 2019 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. @@ -11,24 +11,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -package tpl +package tplimpl // Increments on breaking changes. const TemplateVersion = 2 -type Info interface { - ParseInfo() ParseInfo -} - -type FileInfo interface { - Name() string - Filename() string -} - -type IsInternalTemplateProvider interface { - IsInternalTemplate() bool -} - +// ParseInfo holds information about a parsed ntemplate. type ParseInfo struct { // Set for shortcode templates with any {{ .Inner }} IsInner bool @@ -44,14 +32,15 @@ func (info ParseInfo) IsZero() bool { return info.Config.Version == 0 } +// ParseConfig holds configuration extracted from the template. type ParseConfig struct { Version int } -var DefaultParseConfig = ParseConfig{ +var defaultParseConfig = ParseConfig{ Version: TemplateVersion, } -var DefaultParseInfo = ParseInfo{ - Config: DefaultParseConfig, +var defaultParseInfo = ParseInfo{ + Config: defaultParseConfig, } diff --git a/tpl/tplimpl/template_test.go b/tpl/tplimpl/template_test.go deleted file mode 100644 index 5e372d986..000000000 --- a/tpl/tplimpl/template_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -package tplimpl - -import ( - "testing" - - qt "github.com/frankban/quicktest" -) - -func TestNeedsBaseTemplate(t *testing.T) { - c := qt.New(t) - - c.Assert(needsBaseTemplate(`{{ define "main" }}`), qt.Equals, true) - c.Assert(needsBaseTemplate(`{{define "main" }}`), qt.Equals, true) - c.Assert(needsBaseTemplate(`{{- define "main" }}`), qt.Equals, true) - c.Assert(needsBaseTemplate(`{{-define "main" }}`), qt.Equals, true) - c.Assert(needsBaseTemplate(` - - {{-define "main" }} - - `), qt.Equals, true) - c.Assert(needsBaseTemplate(` {{ define "main" }}`), qt.Equals, true) - c.Assert(needsBaseTemplate(` - {{ define "main" }}`), qt.Equals, true) - c.Assert(needsBaseTemplate(` A {{ define "main" }}`), qt.Equals, false) - c.Assert(needsBaseTemplate(` {{ printf "foo" }}`), qt.Equals, false) - c.Assert(needsBaseTemplate(`{{/* comment */}} {{ define "main" }}`), qt.Equals, true) - c.Assert(needsBaseTemplate(` {{/* comment */}} A {{ define "main" }}`), qt.Equals, false) -} diff --git a/tpl/tplimpl/templatedescriptor.go b/tpl/tplimpl/templatedescriptor.go new file mode 100644 index 000000000..21649b032 --- /dev/null +++ b/tpl/tplimpl/templatedescriptor.go @@ -0,0 +1,225 @@ +// 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. +// 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 ( + "github.com/gohugoio/hugo/resources/kinds" +) + +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. + + // Group 2. + OutputFormat string // rss, csv ... + MediaType string // text/html, text/plain, ... + Lang string // en, nn, fr, ... + + Variant1 string // contextual variant, e.g. "link" in render hooks." + 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. +} + +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.Kind == kinds.KindTemporary { + d.Kind = "" + } + + if d.Layout == d.Kind { + d.Layout = "" + } +} + +type descriptorHandler struct { + opts StoreOptions +} + +// 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 { + return weightNoMatch + } + + w := this.doCompare(category, other) + if w.w1 <= 0 { + if category == CategoryMarkup && (this.Variant1 == other.Variant1) && (this.Variant2 == other.Variant2 || this.Variant2 != "" && other.Variant2 == "") { + // See issue 13242. + if this.OutputFormat != other.OutputFormat && this.OutputFormat == s.opts.DefaultOutputFormat { + return w + } + + w.w1 = 1 + return w + } + } + + return w +} + +//lint:ignore ST1006 this vs other makes it easier to reason about. +func (this TemplateDescriptor) doCompare(category Category, other TemplateDescriptor) weight { + w := weightNoMatch + + // HTML in plain text is OK, but not the other way around. + if other.IsPlainText && !this.IsPlainText { + return w + } + 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 + } + } + + // Test again. + if other.Layout != this.Layout { + return w + } + } + if other.Lang != "" && other.Lang != this.Lang { + return w + } + + if other.OutputFormat != "" && other.OutputFormat != this.OutputFormat { + if this.MediaType != other.MediaType { + return w + } + + // 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)) { + 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 { + return w + } + + // If both are set and different, no match. + if other.Variant2 != "" && this.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. + + // We will use the values for group 2 and 3 + // if the distance up to the template is shorter than + // the one we're comparing with. + // E.g for a page in /posts/mypage.md with the + // two templates /layouts/posts/single.html and /layouts/page.html, + // the first one is the best match even if the second one + // has a higher w1 value. + weight2Group1 = 1 // kind, standardl layout (single,list,all) + weight2Group2 = 2 // custom layout (mylayout) + + weight3 = 1 // for media type, lang, output format. + ) + + // Now we now know that the other descriptor is a subset of this. + // Now calculate the weights. + w.w1++ + + if other.Kind != "" && other.Kind == this.Kind { + w.w1 += weightKind + 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.Lang != "" && other.Lang == this.Lang { + w.w1 += weightLang + w.w3 += weight3 + } + + if other.OutputFormat != "" && other.OutputFormat == this.OutputFormat { + w.w1 += weightOutputFormat + w.w3 += weight3 + } + + if other.MediaType != "" && other.MediaType == this.MediaType { + w.w1 += weightMediaType + w.w3 += weight3 + } + + if other.Variant1 != "" && other.Variant1 == this.Variant1 { + w.w1 += weightVariant1 + } + + if other.Variant2 != "" && other.Variant2 == this.Variant2 { + w.w1 += weightVariant2 + } + if other.Variant2 != "" && this.Variant2 == "" { + w.w1-- + } + + return w +} + +func (d TemplateDescriptor) IsZero() bool { + return d == TemplateDescriptor{} +} + +//lint:ignore ST1006 this vs other makes it easier to reason about. +func (this TemplateDescriptor) isKindInLayout(layout string) bool { + if this.Kind == "" { + return true + } + if this.Kind != kinds.KindPage { + return layout != layoutSingle + } + return layout != layoutList +} diff --git a/tpl/tplimpl/templatedescriptor_test.go b/tpl/tplimpl/templatedescriptor_test.go new file mode 100644 index 000000000..76201287d --- /dev/null +++ b/tpl/tplimpl/templatedescriptor_test.go @@ -0,0 +1,104 @@ +package tplimpl + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/resources/kinds" +) + +func TestTemplateDescriptorCompare(t *testing.T) { + c := qt.New(t) + + dh := descriptorHandler{ + opts: StoreOptions{ + OutputFormats: output.DefaultFormats, + DefaultOutputFormat: "html", + }, + } + + less := func(category Category, this, other1, other2 TemplateDescriptor) { + c.Helper() + result1 := dh.compareDescriptors(category, this, other1) + result2 := dh.compareDescriptors(category, 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) + if less { + c.Assert(result.w1 < 0, qt.IsTrue, qt.Commentf("%d", result)) + } else { + c.Assert(result.w1 >= 0, qt.IsTrue, qt.Commentf("%d", result)) + } + } + + check( + + CategoryBaseof, + TemplateDescriptor{Kind: "", Layout: "", Lang: "", OutputFormat: "404", MediaType: "text/html"}, + TemplateDescriptor{Kind: "", Layout: "", 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"}, + true, + ) + + less( + CategoryLayout, + TemplateDescriptor{Kind: kinds.KindHome, Layout: "list", OutputFormat: "html"}, + TemplateDescriptor{Layout: "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"}, + false, + ) +} + +// INFO timer: name resolveTemplate count 779 duration 5.482274ms average 7.037µs median 4µs +func BenchmarkCompareDescriptors(b *testing.B) { + dh := descriptorHandler{ + opts: StoreOptions{ + OutputFormats: output.DefaultFormats, + DefaultOutputFormat: "html", + }, + } + + pairs := []struct { + 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: "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", 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", 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}, + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, pair := range pairs { + _ = dh.compareDescriptors(CategoryLayout, pair.d1, pair.d2) + } + } +} diff --git a/tpl/tplimpl/templates.go b/tpl/tplimpl/templates.go new file mode 100644 index 000000000..57012f6e3 --- /dev/null +++ b/tpl/tplimpl/templates.go @@ -0,0 +1,331 @@ +package tplimpl + +import ( + "io" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/tpl" + htmltemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/htmltemplate" + texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate" +) + +func (t *templateNamespace) readTemplateInto(templ *TemplInfo) error { + if err := func() error { + meta := templ.Fi.Meta() + f, err := meta.Open() + if err != nil { + return err + } + defer f.Close() + b, err := io.ReadAll(f) + if err != nil { + return err + } + templ.Content = removeLeadingBOM(string(b)) + if !templ.NoBaseOf { + templ.NoBaseOf = !needsBaseTemplate(templ.Content) + } + return nil + }(); err != nil { + return err + } + return nil +} + +// The tweet and twitter shortcodes were deprecated in favor of the x shortcode +// in v0.141.0. We can remove these aliases in v0.155.0 or later. +var embeddedTemplatesAliases = map[string][]string{ + "_shortcodes/twitter.html": {"_shortcodes/tweet.html"}, +} + +func (t *templateNamespace) parseTemplate(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 + err error + ) + + if ti.D.IsPlainText { + prototype := t.parseText + templ, err = prototype.New(name).Parse(ti.Content) + if err != nil { + return err + } + } else { + prototype := t.parseHTML + templ, err = prototype.New(name).Parse(ti.Content) + if err != nil { + return err + } + + if ti.SubCategory == SubCategoryEmbedded { + // In Hugo 0.146.0 we moved the internal templates around. + // For the "_internal/twitter_cards.html" style templates, they + // were moved to the _partials directory. + // But we need to make them accessible from the old path for a while. + if pi.Type() == paths.TypePartial { + aliasName := strings.TrimPrefix(name, "_partials/") + aliasName = "_internal/" + aliasName + _, err = prototype.AddParseTree(aliasName, templ.(*htmltemplate.Template).Tree) + if err != nil { + return err + } + } + + // This was also possible before Hugo 0.146.0, but this should be deprecated. + if pi.Type() == paths.TypeShortcode { + aliasName := strings.TrimPrefix(name, "_shortcodes/") + aliasName = "_internal/shortcodes/" + aliasName + _, err = prototype.AddParseTree(aliasName, templ.(*htmltemplate.Template).Tree) + if err != nil { + return err + } + } + + } + } + + ti.Template = templ + + return nil +} + +func (t *templateNamespace) applyBaseTemplate(overlay *TemplInfo, base keyTemplateInfo) error { + tb := &TemplWithBaseApplied{ + Overlay: overlay, + Base: base.Info, + } + + base.Info.Overlays = append(base.Info.Overlays, overlay) + + var templ tpl.Template + if overlay.D.IsPlainText { + tt := texttemplate.Must(t.parseText.Clone()).New(overlay.PathInfo.PathNoLeadingSlash()) + var err error + tt, err = tt.Parse(base.Info.Content) + if err != nil { + return err + } + tt, err = tt.Parse(overlay.Content) + if err != nil { + return err + } + templ = tt + t.baseofTextClones = append(t.baseofTextClones, tt) + } else { + tt := htmltemplate.Must(t.parseHTML.CloneShallow()).New(overlay.PathInfo.PathNoLeadingSlash()) + var err error + tt, err = tt.Parse(base.Info.Content) + if err != nil { + return err + } + tt, err = tt.Parse(overlay.Content) + if err != nil { + return err + } + templ = tt + + t.baseofHtmlClones = append(t.baseofHtmlClones, tt) + + } + + tb.Template = &TemplInfo{ + Template: templ, + Base: base.Info, + PathInfo: overlay.PathInfo, + Fi: overlay.Fi, + D: overlay.D, + NoBaseOf: true, + } + + variants := overlay.BaseVariants.Get(base.Key) + if variants == nil { + variants = make(map[TemplateDescriptor]*TemplWithBaseApplied) + overlay.BaseVariants.Insert(base.Key, variants) + } + variants[base.Info.D] = tb + return nil +} + +func (t *templateNamespace) templatesIn(in tpl.Template) []tpl.Template { + var templs []tpl.Template + if textt, ok := in.(*texttemplate.Template); ok { + for _, t := range textt.Templates() { + templs = append(templs, t) + } + } + if htmlt, ok := in.(*htmltemplate.Template); ok { + for _, t := range htmlt.Templates() { + templs = append(templs, t) + } + } + return templs +} + +/* + + +func (t *templateHandler) applyBaseTemplate(overlay, base templateInfo) (tpl.Template, error) { + if overlay.isText { + var ( + templ = t.main.getPrototypeText(prototypeCloneIDBaseof).New(overlay.name) + err error + ) + + if !base.IsZero() { + templ, err = templ.Parse(base.template) + if err != nil { + return nil, base.errWithFileContext("text: base: parse failed", err) + } + } + + templ, err = texttemplate.Must(templ.Clone()).Parse(overlay.template) + if err != nil { + return nil, overlay.errWithFileContext("text: overlay: parse failed", err) + } + + // The extra lookup is a workaround, see + // * https://github.com/golang/go/issues/16101 + // * https://github.com/gohugoio/hugo/issues/2549 + // templ = templ.Lookup(templ.Name()) + + return templ, nil + } + + var ( + templ = t.main.getPrototypeHTML(prototypeCloneIDBaseof).New(overlay.name) + err error + ) + + if !base.IsZero() { + templ, err = templ.Parse(base.template) + if err != nil { + return nil, base.errWithFileContext("html: base: parse failed", err) + } + } + + templ, err = htmltemplate.Must(templ.Clone()).Parse(overlay.template) + if err != nil { + return nil, overlay.errWithFileContext("html: overlay: parse failed", err) + } + + // The extra lookup is a workaround, see + // * https://github.com/golang/go/issues/16101 + // * https://github.com/gohugoio/hugo/issues/2549 + templ = templ.Lookup(templ.Name()) + + return templ, err +} + +*/ + +var baseTemplateDefineRe = regexp.MustCompile(`^{{-?\s*define`) + +// needsBaseTemplate returns true if the first non-comment template block is a +// define block. +func needsBaseTemplate(templ string) bool { + idx := -1 + inComment := false + for i := 0; i < len(templ); { + if !inComment && strings.HasPrefix(templ[i:], "{{/*") { + inComment = true + i += 4 + } else if !inComment && strings.HasPrefix(templ[i:], "{{- /*") { + inComment = true + i += 6 + } else if inComment && strings.HasPrefix(templ[i:], "*/}}") { + inComment = false + i += 4 + } else if inComment && strings.HasPrefix(templ[i:], "*/ -}}") { + inComment = false + i += 6 + } else { + r, size := utf8.DecodeRuneInString(templ[i:]) + if !inComment { + if strings.HasPrefix(templ[i:], "{{") { + idx = i + break + } else if !unicode.IsSpace(r) { + break + } + } + i += size + } + } + + if idx == -1 { + return false + } + + return baseTemplateDefineRe.MatchString(templ[idx:]) +} + +func removeLeadingBOM(s string) string { + const bom = '\ufeff' + + for i, r := range s { + if i == 0 && r != bom { + return s + } + if i > 0 { + return s[i:] + } + } + + return s +} + +type templateNamespace struct { + parseText *texttemplate.Template + parseHTML *htmltemplate.Template + prototypeText *texttemplate.Template + prototypeHTML *htmltemplate.Template + + standaloneText *texttemplate.Template + + baseofTextClones []*texttemplate.Template + baseofHtmlClones []*htmltemplate.Template +} + +func (t *templateNamespace) createPrototypesParse() error { + if t.prototypeHTML == nil { + panic("prototypeHTML not set") + } + t.parseHTML = htmltemplate.Must(t.prototypeHTML.Clone()) + t.parseText = texttemplate.Must(t.prototypeText.Clone()) + return nil +} + +func (t *templateNamespace) createPrototypes(init bool) error { + if init { + t.prototypeHTML = htmltemplate.Must(t.parseHTML.Clone()) + t.prototypeText = texttemplate.Must(t.parseText.Clone()) + } + // t.execHTML = htmltemplate.Must(t.parseHTML.Clone()) + // t.execText = texttemplate.Must(t.parseText.Clone()) + + return nil +} + +func newTemplateNamespace(funcs map[string]any) *templateNamespace { + return &templateNamespace{ + parseHTML: htmltemplate.New("").Funcs(funcs), + parseText: texttemplate.New("").Funcs(funcs), + standaloneText: texttemplate.New("").Funcs(funcs), + } +} diff --git a/tpl/tplimpl/templatestore.go b/tpl/tplimpl/templatestore.go new file mode 100644 index 000000000..b0c30b98a --- /dev/null +++ b/tpl/tplimpl/templatestore.go @@ -0,0 +1,1854 @@ +package tplimpl + +import ( + "bytes" + "context" + "embed" + "fmt" + "io" + "io/fs" + "iter" + "os" + "path" + "path/filepath" + "reflect" + "regexp" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugofs/files" + "github.com/gohugoio/hugo/hugolib/doctree" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/metrics" + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/tpl" + htmltemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/htmltemplate" + texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate" + "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate/parse" + "github.com/spf13/afero" +) + +const ( + CategoryLayout Category = iota + 1 + CategoryBaseof + CategoryMarkup + CategoryShortcode + CategoryPartial + // Internal categories + CategoryServer + CategoryHugo +) + +const ( + SubCategoryMain SubCategory = iota + SubCategoryEmbedded // Internal Hugo templates + SubCategoryInline // Inline partials +) + +const ( + containerMarkup = "_markup" + containerShortcodes = "_shortcodes" + shortcodesPathIdentifier = "/_shortcodes/" + containerPartials = "_partials" +) + +const ( + layoutAll = "all" + layoutList = "list" + layoutSingle = "single" +) + +var ( + _ identity.IdentityProvider = (*TemplInfo)(nil) + _ identity.IsProbablyDependentProvider = (*TemplInfo)(nil) + _ identity.IsProbablyDependencyProvider = (*TemplInfo)(nil) +) + +const ( + processingStateInitial processingState = iota + processingStateTransformed +) + +// The identifiers may be truncated in the log, e.g. +// "executing "main" at <$scaled.SRelPermalin...>: can't evaluate field SRelPermalink in type *resource.Image" +// We need this to identify position in templates with base templates applied. +var identifiersRe = regexp.MustCompile(`at \<(.*?)(\.{3})?\>:`) + +var weightNoMatch = weight{w1: -1} + +// +//go:embed all:embedded/templates/* +var embeddedTemplatesFs embed.FS + +func NewStore(opts StoreOptions, siteOpts SiteOptions) (*TemplateStore, error) { + html, ok := opts.OutputFormats.GetByName("html") + if !ok { + panic("HTML output format not found") + } + s := &TemplateStore{ + opts: opts, + siteOpts: siteOpts, + optsOrig: opts, + siteOptsOrig: siteOpts, + htmlFormat: html, + storeSite: configureSiteStorage(siteOpts, opts.Watching), + treeMain: doctree.NewSimpleTree[map[nodeKey]*TemplInfo](), + treeShortcodes: doctree.NewSimpleTree[map[string]map[TemplateDescriptor]*TemplInfo](), + templatesByPath: maps.NewCache[string, *TemplInfo](), + + // Note that the funcs passed below is just for name validation. + tns: newTemplateNamespace(siteOpts.TemplateFuncs), + + dh: descriptorHandler{ + opts: opts, + }, + } + + if err := s.init(); err != nil { + return nil, err + } + 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 + } + return s, nil +} + +//go:generate stringer -type Category + +type Category int + +type SiteOptions struct { + Site page.Site + TemplateFuncs map[string]any +} + +type StoreOptions struct { + // The filesystem to use. + Fs afero.Fs + + // The path parser to use. + PathParser *paths.PathParser + + // Set when --enableTemplateMetrics is set. + Metrics metrics.Provider + + // All configured output formats. + OutputFormats output.Formats + + // All configured media types. + MediaTypes media.Types + + // The default content language. + DefaultContentLanguage string + + // The default output format. + DefaultOutputFormat string + + // Taxonomy config. + TaxonomySingularPlural map[string]string + + // Whether we are in watch or server mode. + Watching bool + + // compiled. + legacyMappingTaxonomy map[string]legacyOrdinalMapping + legacyMappingTerm map[string]legacyOrdinalMapping + legacyMappingSection map[string]legacyOrdinalMapping +} + +//go:generate stringer -type SubCategory + +type SubCategory int + +type TemplInfo struct { + // The category of this template. + Category Category + + SubCategory SubCategory + + // PathInfo info. + PathInfo *paths.Path + + // Set when backed by a file. + Fi hugofs.FileMetaInfo + + // The template content with any leading BOM removed. + Content string + + // The parsed template. + // Note that any baseof template will be applied later. + Template tpl.Template + + // If no baseof is needed, this will be set to true. + // E.g. shortcode templates do not need a baseof. + NoBaseOf bool + + // If NoBaseOf is false, we will look for the final template in this tree. + BaseVariants *doctree.SimpleTree[map[TemplateDescriptor]*TemplWithBaseApplied] + + // The template variants that are based on this template. + Overlays []*TemplInfo + + // The base template used, if any. + Base *TemplInfo + + // The descriptior that this template represents. + D TemplateDescriptor + + // Parser state. + ParseInfo ParseInfo + + // The execution counter for this template. + ExecutionCounter atomic.Uint64 + + // processing state. + state processingState + isLegacyMapped bool +} + +func (ti *TemplInfo) BaseVariantsSeq() iter.Seq[*TemplWithBaseApplied] { + return func(yield func(*TemplWithBaseApplied) bool) { + ti.BaseVariants.Walk(func(key string, v map[TemplateDescriptor]*TemplWithBaseApplied) (bool, error) { + for _, vv := range v { + if !yield(vv) { + return true, nil + } + } + return false, nil + }) + } +} + +func (t *TemplInfo) IdentifierBase() string { + if t.PathInfo == nil { + return t.Name() + } + return t.PathInfo.IdentifierBase() +} + +func (t *TemplInfo) GetIdentity() identity.Identity { + return t +} + +func (ti *TemplInfo) Name() string { + return ti.Template.Name() +} + +func (ti *TemplInfo) Prepare() (*texttemplate.Template, error) { + return ti.Template.Prepare() +} + +func (t *TemplInfo) IsProbablyDependency(other identity.Identity) bool { + return t.isProbablyTheSameIDAs(other) +} + +func (t *TemplInfo) IsProbablyDependent(other identity.Identity) bool { + for _, overlay := range t.Overlays { + if overlay.isProbablyTheSameIDAs(other) { + return true + } + } + return t.isProbablyTheSameIDAs(other) +} + +func (ti *TemplInfo) String() string { + if ti == nil { + return "" + } + return ti.PathInfo.String() +} + +func (ti *TemplInfo) findBestMatchBaseof(s *TemplateStore, k1 string, slashCountK1 int, best *bestMatch) { + if ti.BaseVariants == nil { + return + } + + ti.BaseVariants.WalkPath(k1, func(k2 string, v map[TemplateDescriptor]*TemplWithBaseApplied) (bool, error) { + slashCountK2 := strings.Count(k2, "/") + distance := slashCountK1 - slashCountK2 + + for d, vv := range v { + weight := s.dh.compareDescriptors(CategoryBaseof, ti.D, d) + weight.distance = distance + if best.isBetter(weight, vv.Template) { + best.updateValues(weight, k2, d, vv.Template) + } + } + return false, nil + }) +} + +func (t *TemplInfo) isProbablyTheSameIDAs(other identity.Identity) bool { + if t.IdentifierBase() == other.IdentifierBase() { + return true + } + + if t.Fi != nil && t.Fi.Meta().PathInfo != t.PathInfo { + return other.IdentifierBase() == t.Fi.Meta().PathInfo.IdentifierBase() + } + + return false +} + +type TemplWithBaseApplied struct { + // The template that's overlaid on top of the base template. + Overlay *TemplInfo + // The base template. + Base *TemplInfo + // This is the final template that can be used to render a page. + Template *TemplInfo +} + +// TemplateQuery is used in LookupPagesLayout to find the best matching template. +type TemplateQuery struct { + // The path to walk down to. + Path string + + // The name to look for. Used for shortcode queries. + Name string + + // The category to look in. + Category Category + + // The template descriptor to match against. + Desc TemplateDescriptor + + // Whether to even consider this candidate. + Consider func(candidate *TemplInfo) bool +} + +func (q *TemplateQuery) init() { + if q.Desc.Kind == kinds.KindTemporary { + q.Desc.Kind = "" + } else if kinds.GetKindMain(q.Desc.Kind) == "" { + q.Desc.Kind = "" + } + if q.Desc.Layout == "" && q.Desc.Kind != "" { + if q.Desc.Kind == kinds.KindPage { + q.Desc.Layout = layoutSingle + } else { + q.Desc.Layout = layoutList + } + } + + if q.Consider == nil { + q.Consider = func(match *TemplInfo) bool { + return true + } + } + + q.Name = strings.ToLower(q.Name) + + if q.Category == 0 { + panic("category not set") + } +} + +type TemplateStore struct { + opts StoreOptions + siteOpts SiteOptions + htmlFormat output.Format + + treeMain *doctree.SimpleTree[map[nodeKey]*TemplInfo] + treeShortcodes *doctree.SimpleTree[map[string]map[TemplateDescriptor]*TemplInfo] + templatesByPath *maps.Cache[string, *TemplInfo] + + dh descriptorHandler + + // The template namespace. + tns *templateNamespace + + // Site specific state. + // All above this is reused. + storeSite *storeSite + + // For testing benchmarking. + optsOrig StoreOptions + siteOptsOrig SiteOptions +} + +// NewFromOpts creates a new store with the same configuration as the original. +// Used for testing/benchmarking. +func (s *TemplateStore) NewFromOpts() (*TemplateStore, error) { + return NewStore(s.optsOrig, s.siteOptsOrig) +} + +// In the previous implementation of base templates in Hugo, we parsed and applied these base templates on +// request, e.g. in the middle of rendering. The idea was that we coulnd't know upfront which layoyt/base template +// combination that would be used. +// This, however, added a lot of complexity involving a careful dance of template cloning and parsing +// (Go HTML tenplates cannot be parsed after any of the templates in the tree have been executed). +// FindAllBaseTemplateCandidates finds all base template candidates for the given descriptor so we can apply them upfront. +// In this setup we may end up with unused base templates, but not having to do the cloning should more than make up for that. +func (s *TemplateStore) FindAllBaseTemplateCandidates(overlayKey string, desc TemplateDescriptor) []keyTemplateInfo { + var result []keyTemplateInfo + descBaseof := desc + s.treeMain.Walk(func(k string, v map[nodeKey]*TemplInfo) (bool, error) { + for _, vv := range v { + if vv.Category != CategoryBaseof { + continue + } + + if vv.D.isKindInLayout(desc.Layout) && s.dh.compareDescriptors(CategoryBaseof, descBaseof, vv.D).w1 > 0 { + result = append(result, keyTemplateInfo{Key: k, Info: vv}) + } + } + return false, nil + }) + + return result +} + +func (t *TemplateStore) ExecuteWithContext(ctx context.Context, ti *TemplInfo, wr io.Writer, data any) error { + defer func() { + ti.ExecutionCounter.Add(1) + if ti.Base != nil { + ti.Base.ExecutionCounter.Add(1) + } + }() + + templ := ti.Template + + 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 nil +} + +func (t *TemplateStore) GetFunc(name string) (reflect.Value, bool) { + v, found := t.storeSite.execHelper.funcs[name] + return v, found +} + +func (s *TemplateStore) GetIdentity(p string) identity.Identity { + p = paths.AddLeadingSlash(p) + v, found := s.templatesByPath.Get(p) + if !found { + return nil + } + return v.GetIdentity() +} + +func (t *TemplateStore) LookupByPath(templatePath string) *TemplInfo { + v, _ := t.templatesByPath.Get(templatePath) + return v +} + +var bestPool = sync.Pool{ + New: func() any { + return &bestMatch{} + }, +} + +func (s *TemplateStore) getBest() *bestMatch { + v := bestPool.Get() + b := v.(*bestMatch) + b.defaultOutputformat = s.opts.DefaultOutputFormat + return b +} + +func (s *TemplateStore) putBest(b *bestMatch) { + b.reset() + bestPool.Put(b) +} + +func (s *TemplateStore) LookupPagesLayout(q TemplateQuery) *TemplInfo { + q.init() + key := s.key(q.Path) + + slashCountKey := strings.Count(key, "/") + best1 := s.getBest() + defer s.putBest(best1) + s.findBestMatchWalkPath(q, key, slashCountKey, best1) + if best1.w.w1 <= 0 { + return nil + } + m := best1.templ + if m.NoBaseOf { + return m + } + best1.reset() + m.findBestMatchBaseof(s, key, slashCountKey, best1) + if best1.w.w1 <= 0 { + return nil + } + return best1.templ +} + +func (s *TemplateStore) LookupPartial(pth string, desc TemplateDescriptor) *TemplInfo { + if desc.Layout != "" { + panic("shortcode template descriptor must not have a layout") + } + best := s.getBest() + defer s.putBest(best) + s.findBestMatchGet(s.key(path.Join(containerPartials, pth)), CategoryPartial, nil, desc, best) + return best.templ +} + +func (s *TemplateStore) LookupShortcode(q TemplateQuery) *TemplInfo { + q.init() + k1 := s.key(q.Path) + + slashCountK1 := strings.Count(k1, "/") + + best := s.getBest() + defer s.putBest(best) + + s.treeShortcodes.WalkPath(k1, func(k2 string, m map[string]map[TemplateDescriptor]*TemplInfo) (bool, error) { + slashCountK2 := strings.Count(k2, "/") + distance := slashCountK1 - slashCountK2 + + v, found := m[q.Name] + if !found { + return false, nil + } + + for k, vv := range v { + if !q.Consider(vv) { + continue + } + + weight := s.dh.compareDescriptors(q.Category, q.Desc, k) + weight.distance = distance + if best.isBetter(weight, vv) { + best.updateValues(weight, k2, k, vv) + } + } + + return false, nil + }) + + // Any match will do. + return best.templ +} + +// PrintDebug is for testing/debugging only. +func (s *TemplateStore) PrintDebug(prefix string, category Category, w io.Writer) { + if w == nil { + w = os.Stdout + } + + printOne := func(key string, vv *TemplInfo) { + level := strings.Count(key, "/") + if category != vv.Category { + 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) + 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) { + for _, vv := range v { + printOne(key, vv) + } + return false, nil + }) + s.treeShortcodes.WalkPrefix(prefix, func(key string, v map[string]map[TemplateDescriptor]*TemplInfo) (bool, error) { + for _, vv := range v { + for _, vv2 := range vv { + printOne(key, vv2) + } + } + return false, nil + }) +} + +// RefreshFiles refreshes this store for the files matching the given predicate. +func (s *TemplateStore) RefreshFiles(include func(fi hugofs.FileMetaInfo) bool) error { + if err := s.tns.createPrototypesParse(); err != nil { + return err + } + if err := s.insertTemplates(include, true); err != nil { + return err + } + if err := s.parseTemplates(); err != nil { + return err + } + if err := s.extractInlinePartials(); err != nil { + return err + } + if err := s.transformTemplates(); err != nil { + return err + } + if err := s.tns.createPrototypes(false); err != nil { + return err + } + if err := s.prepareTemplates(); err != nil { + return err + } + return nil +} + +func (s *TemplateStore) HasTemplate(templatePath string) bool { + templatePath = paths.AddLeadingSlash(templatePath) + return s.templatesByPath.Contains(templatePath) +} + +func (t *TemplateStore) TextLookup(name string) *TemplInfo { + templ := t.tns.standaloneText.Lookup(name) + if templ == nil { + return nil + } + return &TemplInfo{ + Template: templ, + } +} + +func (t *TemplateStore) TextParse(name, tpl string) (*TemplInfo, error) { + templ, err := t.tns.standaloneText.New(name).Parse(tpl) + if err != nil { + return nil, err + } + return &TemplInfo{ + Template: templ, + }, nil +} + +func (t *TemplateStore) UnusedTemplates() []*TemplInfo { + var unused []*TemplInfo + + for vv := range t.templates() { + if vv.SubCategory != SubCategoryMain { + // 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) + } + } + } + } + + sort.Sort(byPath(unused)) + return unused +} + +// WithSiteOpts creates a new store with the given site options. +// This is used to create per site template store, all sharing the same templates, +// but with a different template function execution context. +func (s TemplateStore) WithSiteOpts(opts SiteOptions) *TemplateStore { + s.siteOpts = opts + s.storeSite = configureSiteStorage(opts, s.opts.Watching) + return &s +} + +func (s *TemplateStore) findBestMatchGet(key string, category Category, consider func(candidate *TemplInfo) bool, desc TemplateDescriptor, best *bestMatch) { + key = strings.ToLower(key) + + v := s.treeMain.Get(key) + if v == nil { + return + } + + for k, vv := range v { + if vv.Category != category { + continue + } + + if consider != nil && !consider(vv) { + continue + } + + weight := s.dh.compareDescriptors(category, desc, k.d) + if best.isBetter(weight, vv) { + best.updateValues(weight, key, k.d, vv) + } + } +} + +func (s *TemplateStore) findBestMatchWalkPath(q TemplateQuery, k1 string, slashCountK1 int, best *bestMatch) { + s.treeMain.WalkPath(k1, func(k2 string, v map[nodeKey]*TemplInfo) (bool, error) { + slashCountK2 := strings.Count(k2, "/") + distance := slashCountK1 - slashCountK2 + + for k, vv := range v { + if vv.Category != q.Category { + continue + } + + if !q.Consider(vv) { + continue + } + + weight := s.dh.compareDescriptors(q.Category, q.Desc, k.d) + + weight.distance = distance + isBetter := best.isBetter(weight, vv) + + if isBetter { + best.updateValues(weight, k2, k.d, vv) + } + } + + return false, nil + }) +} + +func (t *TemplateStore) addDeferredTemplate(owner *TemplInfo, name string, n *parse.ListNode) error { + if _, found := t.templatesByPath.Get(name); found { + return nil + } + + var templ tpl.Template + + if owner.D.IsPlainText { + prototype := t.tns.parseText + tt, err := prototype.New(name).Parse("") + if err != nil { + return fmt.Errorf("failed to parse empty text template %q: %w", name, err) + } + tt.Tree.Root = n + templ = tt + } else { + prototype := t.tns.parseHTML + tt, err := prototype.New(name).Parse("") + if err != nil { + return fmt.Errorf("failed to parse empty HTML template %q: %w", name, err) + } + tt.Tree.Root = n + templ = tt + } + + t.templatesByPath.Set(name, &TemplInfo{ + Fi: owner.Fi, + PathInfo: owner.PathInfo, + D: owner.D, + Template: templ, + }) + + return nil +} + +func (s *TemplateStore) addFileContext(ti *TemplInfo, inerr error) error { + if ti.Fi == nil { + return inerr + } + + identifiers := s.extractIdentifiers(inerr.Error()) + + checkFilename := func(fi hugofs.FileMetaInfo, inErr error) (error, bool) { + lineMatcher := func(m herrors.LineMatcher) int { + if m.Position.LineNumber != m.LineNumber { + return -1 + } + + for _, id := range identifiers { + if strings.Contains(m.Line, id) { + // We found the line, but return a 0 to signal to + // use the column from the error message. + return 0 + } + } + return -1 + } + + f, err := fi.Meta().Open() + if err != nil { + return inErr, false + } + defer f.Close() + + fe := herrors.NewFileErrorFromName(inErr, fi.Meta().Filename) + fe.UpdateContent(f, lineMatcher) + + if !fe.ErrorContext().Position.IsValid() { + return inErr, false + } + return fe, true + } + + inerr = fmt.Errorf("execute of template failed: %w", inerr) + + if err, ok := checkFilename(ti.Fi, inerr); ok { + return err + } + + if ti.Base != nil { + if err, ok := checkFilename(ti.Base.Fi, inerr); ok { + return err + } + } + + return inerr +} + +func (s *TemplateStore) extractIdentifiers(line string) []string { + m := identifiersRe.FindAllStringSubmatch(line, -1) + identifiers := make([]string, len(m)) + for i := range m { + identifiers[i] = m[i][1] + } + return identifiers +} + +func (s *TemplateStore) extractInlinePartials() error { + isPartialName := func(s string) bool { + return strings.HasPrefix(s, "partials/") || strings.HasPrefix(s, "_partials/") + } + + p := s.tns + // We may find both inline and external partials in the current template namespaces, + // so only add the ones we have not seen before. + addIfNotSeen := func(isText bool, templs ...tpl.Template) error { + for _, templ := range templs { + if templ.Name() == "" || !isPartialName(templ.Name()) { + continue + } + name := templ.Name() + if !paths.HasExt(name) { + // Assume HTML. This in line with how the lookup works. + name = name + ".html" + } + if !strings.HasPrefix(name, "_") { + name = "_" + name + } + pi := s.opts.PathParser.Parse(files.ComponentFolderLayouts, name) + ti, err := s.insertTemplate(pi, nil, false, s.treeMain) + if err != nil { + return err + } + + if ti != nil { + ti.Template = templ + ti.NoBaseOf = true + ti.SubCategory = SubCategoryInline + ti.D.IsPlainText = isText + } + + } + return nil + } + addIfNotSeen(false, p.templatesIn(p.parseHTML)...) + addIfNotSeen(true, p.templatesIn(p.parseText)...) + + for _, t := range p.baseofHtmlClones { + if err := addIfNotSeen(false, p.templatesIn(t)...); err != nil { + return err + } + } + for _, t := range p.baseofTextClones { + if err := addIfNotSeen(true, p.templatesIn(t)...); err != nil { + return err + } + } + return nil +} + +func (s *TemplateStore) insertEmbedded() error { + return fs.WalkDir(embeddedTemplatesFs, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d == nil || d.IsDir() || strings.HasPrefix(d.Name(), ".") { + return nil + } + + templb, err := embeddedTemplatesFs.ReadFile(path) + if err != nil { + return err + } + + // Get the newlines on Windows in line with how we had it back when we used Go Generate + // to write the templates to Go files. + templ := string(bytes.ReplaceAll(templb, []byte("\r\n"), []byte("\n"))) + name := strings.TrimPrefix(filepath.ToSlash(path), "embedded/templates/") + + insertOne := func(name, content string) error { + pi := s.opts.PathParser.Parse(files.ComponentFolderLayouts, name) + var ( + ti *TemplInfo + err error + ) + if pi.Section() == containerShortcodes { + ti, err = s.insertShortcode(pi, nil, false, s.treeShortcodes) + if err != nil { + return err + } + } else { + ti, err = s.insertTemplate(pi, nil, false, s.treeMain) + if err != nil { + return err + } + } + + if ti != nil { + // Currently none of the embedded templates need a baseof template. + ti.NoBaseOf = true + ti.Content = content + ti.SubCategory = SubCategoryEmbedded + } + + return nil + } + + if err := insertOne(name, templ); err != nil { + return err + } + + if aliases, found := embeddedTemplatesAliases[name]; found { + for _, alias := range aliases { + if err := insertOne(alias, templ); err != nil { + return err + } + } + } + + return nil + }) +} + +func (s *TemplateStore) setTemplateByPath(p string, ti *TemplInfo) { + s.templatesByPath.Set(p, ti) +} + +func (s *TemplateStore) insertShortcode(pi *paths.Path, fi hugofs.FileMetaInfo, replace bool, tree doctree.Tree[map[string]map[TemplateDescriptor]*TemplInfo]) (*TemplInfo, error) { + k1, k2, _, d := s.toKeyCategoryAndDescriptor(pi) + m := tree.Get(k1) + if m == nil { + m = make(map[string]map[TemplateDescriptor]*TemplInfo) + tree.Insert(k1, m) + } + + m1, found := m[k2] + if found { + if _, found := m1[d]; found { + if !replace { + return nil, nil + } + } + } else { + m1 = make(map[TemplateDescriptor]*TemplInfo) + m[k2] = m1 + } + + ti := &TemplInfo{ + PathInfo: pi, + Fi: fi, + D: d, + Category: CategoryShortcode, + NoBaseOf: true, + } + + m1[d] = ti + + s.setTemplateByPath(pi.Path(), ti) + + if fi != nil { + if pi2 := fi.Meta().PathInfo; pi2 != pi { + s.setTemplateByPath(pi2.Path(), ti) + } + } + + return ti, nil +} + +func (s *TemplateStore) insertTemplate(pi *paths.Path, fi hugofs.FileMetaInfo, replace bool, tree doctree.Tree[map[nodeKey]*TemplInfo]) (*TemplInfo, error) { + key, _, category, d := s.toKeyCategoryAndDescriptor(pi) + + return s.insertTemplate2(pi, fi, key, category, d, replace, false, tree) +} + +func (s *TemplateStore) insertTemplate2( + pi *paths.Path, + fi hugofs.FileMetaInfo, + key string, + category Category, + d TemplateDescriptor, + replace, isLegacyMapped bool, + tree doctree.Tree[map[nodeKey]*TemplInfo], +) (*TemplInfo, error) { + if category == 0 { + panic("category not set") + } + + m := tree.Get(key) + nk := nodeKey{c: category, d: d} + + if m == nil { + m = make(map[nodeKey]*TemplInfo) + tree.Insert(key, m) + } + + if !replace { + if v, found := m[nk]; found { + if len(pi.IdentifiersUnknown()) >= len(v.PathInfo.IdentifiersUnknown()) { + // e.g. /pages/home.foo.html and /pages/home.html where foo may be a valid language name in another site. + return nil, nil + } + } + } + + ti := &TemplInfo{ + PathInfo: pi, + Fi: fi, + D: d, + Category: category, + NoBaseOf: category > CategoryLayout, + isLegacyMapped: isLegacyMapped, + } + + m[nk] = ti + + if !isLegacyMapped { + s.setTemplateByPath(pi.Path(), ti) + if fi != nil { + if pi2 := fi.Meta().PathInfo; pi2 != pi { + s.setTemplateByPath(pi2.Path(), ti) + } + } + } + + return ti, nil +} + +func (s *TemplateStore) insertTemplates(include func(fi hugofs.FileMetaInfo) bool, replace bool) error { + if include == nil { + include = func(fi hugofs.FileMetaInfo) bool { + return true + } + } + + // Set if we need to reset the base variants. + var ( + resetBaseVariants bool + ) + + legacyOrdinalMappings := map[legacyTargetPathIdentifiers]legacyOrdinalMappingFi{} + + walker := func(pth string, fi hugofs.FileMetaInfo) error { + piOrig := fi.Meta().PathInfo + if fi.IsDir() { + return nil + } + + if !include(fi) { + return nil + } + + // Convert any legacy value to new format. + fromLegacyPath := func(pi *paths.Path) *paths.Path { + p := pi.Path() + p = strings.TrimPrefix(p, "/_default") + if strings.HasPrefix(p, "/shortcodes") || strings.HasPrefix(p, "/partials") { + // Insert an underscore so it becomes /_shortcodes or /_partials. + p = "/_" + p[1:] + } + + if strings.Contains(p, "-"+baseNameBaseof) { + // Before Hugo 0.146.0 we prepended one identifier (layout, type or kind) in front of the baseof keyword, + // and then separated with a hyphen before the baseof keyword. + // This identifier needs to be moved right after the baseof keyword and the hyphen removed, e.g. + // /docs/list-baseof.html => /docs/baseof.list.html. + dir, name := path.Split(p) + hyphenIdx := strings.Index(name, "-") + if hyphenIdx > 0 { + id := name[:hyphenIdx] + name = name[hyphenIdx+1+len(baseNameBaseof):] + if !strings.HasPrefix(name, ".") { + name = "." + name + } + p = path.Join(dir, baseNameBaseof+"."+id+name) + } + } + if p == pi.Path() { + return pi + } + return s.opts.PathParser.Parse(files.ComponentFolderLayouts, p) + } + + pi := piOrig + var applyLegacyMapping bool + switch pi.Section() { + case containerPartials, containerShortcodes, containerMarkup: + // OK. + default: + applyLegacyMapping = true + pi = fromLegacyPath(pi) + } + + if applyLegacyMapping { + handleMapping := func(m1 legacyOrdinalMapping) { + key := legacyTargetPathIdentifiers{ + targetPath: m1.mapping.targetPath, + targetCategory: m1.mapping.targetCategory, + kind: m1.mapping.targetDesc.Kind, + lang: pi.Lang(), + ext: pi.Ext(), + outputFormat: pi.OutputFormat(), + } + if m2, ok := legacyOrdinalMappings[key]; ok { + if m1.ordinal < m2.m.ordinal { + // Higher up == better match. + legacyOrdinalMappings[key] = legacyOrdinalMappingFi{m1, fi} + } + } else { + legacyOrdinalMappings[key] = legacyOrdinalMappingFi{m1, fi} + } + } + + if m1, ok := s.opts.legacyMappingTaxonomy[piOrig.PathBeforeLangAndOutputFormatAndExt()]; ok { + handleMapping(m1) + } + + if m1, ok := s.opts.legacyMappingTerm[piOrig.PathBeforeLangAndOutputFormatAndExt()]; ok { + handleMapping(m1) + } + + const ( + sectionKindToken = "SECTIONKIND" + sectionToken = "THESECTION" + ) + + 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 + } + + for _, section := range identifiers { + if section == baseNameBaseof { + 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) + } + } + + } + + if replace && pi.NameNoIdentifier() == baseNameBaseof { + // A baseof file has changed. + resetBaseVariants = true + } + + var ti *TemplInfo + var err error + if pi.Type() == paths.TypeShortcode { + ti, err = s.insertShortcode(pi, fi, replace, s.treeShortcodes) + if err != nil || ti == nil { + return err + } + } else { + ti, err = s.insertTemplate(pi, fi, replace, s.treeMain) + if err != nil || ti == nil { + return err + } + } + + if err := s.tns.readTemplateInto(ti); err != nil { + return err + } + + return nil + } + + if err := helpers.Walk(s.opts.Fs, "", walker); err != nil { + if !herrors.IsNotExist(err) { + return err + } + return nil + } + + for k, v := range legacyOrdinalMappings { + targetPath := k.targetPath + m := v.m.mapping + fi := v.fi + pi := fi.Meta().PathInfo + outputFormat, mediaType := s.resolveOutputFormatAndOrMediaType(k.outputFormat, k.ext) + category := m.targetCategory + desc := m.targetDesc + desc.Kind = k.kind + desc.Lang = k.lang + desc.OutputFormat = outputFormat.Name + desc.IsPlainText = outputFormat.IsPlainText + desc.MediaType = mediaType.Type + + ti, err := s.insertTemplate2(pi, fi, targetPath, category, desc, true, true, s.treeMain) + if err != nil { + return err + } + if ti == nil { + continue + } + ti.isLegacyMapped = true + if err := s.tns.readTemplateInto(ti); err != nil { + return err + } + } + + if resetBaseVariants { + s.tns.baseofHtmlClones = nil + s.tns.baseofTextClones = nil + s.treeMain.Walk(func(key string, v map[nodeKey]*TemplInfo) (bool, error) { + for _, vv := range v { + if !vv.NoBaseOf { + vv.state = processingStateInitial + } + } + return false, nil + }) + } + + return nil +} + +func (s *TemplateStore) key(dir string) string { + dir = paths.AddLeadingSlash(dir) + if dir == "/" { + return "" + } + return paths.TrimTrailing(dir) +} + +func (s *TemplateStore) parseTemplates() error { + if err := func() error { + // Read and parse all templates. + for _, v := range s.treeMain.All() { + for _, vv := range v { + if vv.state == processingStateTransformed { + continue + } + if err := s.tns.parseTemplate(vv); err != nil { + return err + } + } + } + + // Lookup and apply base templates where needed. + for key, v := range s.treeMain.All() { + for _, vv := range v { + if vv.state == processingStateTransformed { + continue + } + if !vv.NoBaseOf { + d := vv.D + // Find all compatible base templates. + baseTemplates := s.FindAllBaseTemplateCandidates(key, d) + if len(baseTemplates) == 0 { + // 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 { + return err + } + continue + } + vv.BaseVariants = doctree.NewSimpleTree[map[TemplateDescriptor]*TemplWithBaseApplied]() + + for _, base := range baseTemplates { + if err := s.tns.applyBaseTemplate(vv, base); err != nil { + return err + } + } + + } + } + } + + return nil + }(); err != nil { + return err + } + + // Prese shortcodes. + for _, v := range s.treeShortcodes.All() { + for _, vv := range v { + for _, vvv := range vv { + if vvv.state == processingStateTransformed { + continue + } + if err := s.tns.parseTemplate(vvv); err != nil { + return err + } + } + } + } + + return nil +} + +// prepareTemplates prepares all templates for execution. +func (s *TemplateStore) prepareTemplates() error { + for t := range s.templates() { + if t.Category == CategoryBaseof { + continue + } + if _, err := t.Prepare(); err != nil { + return err + } + } + return nil +} + +// TemplateDescriptorFromPath returns a template descriptor from the given path. +// This is currently used in partial lookups only. +func (s *TemplateStore) TemplateDescriptorFromPath(pth string) (string, TemplateDescriptor) { + 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 pth, 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 +// based on the other suffix. If both are empty, the function will return zero values. +func (s *TemplateStore) resolveOutputFormatAndOrMediaType(ofs, mns string) (output.Format, media.Type) { + var outputFormat output.Format + var mediaType media.Type + + if ofs != "" { + if of, found := s.opts.OutputFormats.GetByName(ofs); found { + outputFormat = of + mediaType = of.MediaType + } + } + + if mns != "" && mediaType.IsZero() { + if of, found := s.opts.OutputFormats.GetBySuffix(mns); found { + outputFormat = of + mediaType = of.MediaType + } else { + if mt, _, found := s.opts.MediaTypes.GetFirstBySuffix(mns); found { + mediaType = mt + if outputFormat.IsZero() { + // For e.g. index.xml we will in the default confg now have the application/rss+xml media type. + // Try a last time to find the output format using the SubType as the name. + // As to template resolution, this value is currently only used to + // decide if this is a text or HTML template. + outputFormat, _ = s.opts.OutputFormats.GetByName(mt.SubType) + } + } + } + } + + return outputFormat, mediaType +} + +func (s *TemplateStore) templates() iter.Seq[*TemplInfo] { + return func(yield func(*TemplInfo) bool) { + for _, v := range s.treeMain.All() { + for _, vv := range v { + if !vv.NoBaseOf { + for vvv := range vv.BaseVariantsSeq() { + if !yield(vvv.Template) { + return + } + } + } else { + if !yield(vv) { + return + } + } + } + } + for _, v := range s.treeShortcodes.All() { + for _, vv := range v { + for _, vvv := range vv { + if !yield(vvv) { + return + } + } + } + } + } +} + +func (s *TemplateStore) toKeyCategoryAndDescriptor(p *paths.Path) (string, string, Category, TemplateDescriptor) { + k1 := p.Dir() + k2 := "" + + 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, + } + + d.normalizeFromFile() + + section := p.Section() + + var category Category + switch p.Type() { + case paths.TypeShortcode: + category = CategoryShortcode + case paths.TypePartial: + category = CategoryPartial + case paths.TypeMarkup: + category = CategoryMarkup + } + + if category == 0 { + if nameNoIdentifier == baseNameBaseof { + category = CategoryBaseof + } else { + switch section { + case "_hugo": + category = CategoryHugo + case "_server": + category = CategoryServer + default: + category = CategoryLayout + } + } + } + + if category == CategoryPartial { + d.Layout = "" + k1 = p.PathNoIdentifier() + } + + if category == CategoryShortcode { + k1 = p.PathNoIdentifier() + parts := strings.Split(k1, "/"+containerShortcodes+"/") + k1 = parts[0] + if len(parts) > 1 { + k2 = parts[1] + } + k1 = s.key(k1) + } + + // Legacy layout for home page. + if d.Layout == "index" { + if d.Kind == "" { + d.Kind = kinds.KindHome + } + d.Layout = "" + } + + if d.Layout == d.Kind { + d.Layout = "" + } + + k1 = strings.TrimPrefix(k1, "/_default") + if k1 == "/" { + k1 = "" + } + + 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, "-") + if len(parts) < 2 { + panic("markup template must have at least 2 parts") + } + // Either 2 or 3 parts, e.g. render-codeblock-go. + d.Variant1 = parts[1] + if len(parts) > 2 { + d.Variant2 = parts[2] + } + d.Layout = "" // This allows using page layout as part of the key for lookups. + } + + return k1, k2, category, d +} + +func (s *TemplateStore) transformTemplates() error { + lookup := func(name string, in *TemplInfo) *TemplInfo { + if in.D.IsPlainText { + templ := in.Template.(*texttemplate.Template).Lookup(name) + if templ != nil { + return &TemplInfo{ + Template: templ, + } + } + } else { + templ := in.Template.(*htmltemplate.Template).Lookup(name) + if templ != nil { + return &TemplInfo{ + Template: templ, + } + } + } + + return nil + } + + for vv := range s.templates() { + if vv.state == processingStateTransformed { + continue + } + vv.state = processingStateTransformed + 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 { + return err + } + + for name, node := range tctx.deferNodes { + if err := s.addDeferredTemplate(vv, name, node); err != nil { + return err + } + } + } + } + + return nil +} + +func (s *TemplateStore) init() error { + // Before Hugo 0.146 we had a very elaborate template lookup system, especially for + // terms and taxonomies. This is a way of preserving backwards compatibility + // by mapping old paths into the new tree. + s.opts.legacyMappingTaxonomy = make(map[string]legacyOrdinalMapping) + s.opts.legacyMappingTerm = make(map[string]legacyOrdinalMapping) + s.opts.legacyMappingSection = make(map[string]legacyOrdinalMapping) + + // Placeholders. + const singular = "SINGULAR" + const plural = "PLURAL" + + replaceTokens := func(s, singularv, pluralv string) string { + s = strings.Replace(s, singular, singularv, -1) + s = strings.Replace(s, plural, pluralv, -1) + return s + } + + hasSingularOrPlural := func(s string) bool { + return strings.Contains(s, singular) || strings.Contains(s, plural) + } + + expand := func(v layoutLegacyMapping) []layoutLegacyMapping { + var result []layoutLegacyMapping + + if hasSingularOrPlural(v.sourcePath) || hasSingularOrPlural(v.target.targetPath) { + for s, p := range s.opts.TaxonomySingularPlural { + target := v.target + target.targetPath = replaceTokens(target.targetPath, s, p) + vv := replaceTokens(v.sourcePath, s, p) + result = append(result, layoutLegacyMapping{sourcePath: vv, target: target}) + } + } else { + result = append(result, v) + } + return result + } + + expandSections := func(v layoutLegacyMapping) []layoutLegacyMapping { + var result []layoutLegacyMapping + result = append(result, v) + baseofVariant := v + baseofVariant.sourcePath += "-" + baseNameBaseof + baseofVariant.target.targetCategory = CategoryBaseof + result = append(result, baseofVariant) + return result + } + + var terms []layoutLegacyMapping + for _, v := range legacyTermMappings { + terms = append(terms, expand(v)...) + } + var taxonomies []layoutLegacyMapping + for _, v := range legacyTaxonomyMappings { + taxonomies = append(taxonomies, expand(v)...) + } + var sections []layoutLegacyMapping + for _, v := range legacySectionMappings { + sections = append(sections, expandSections(v)...) + } + + for i, m := range terms { + s.opts.legacyMappingTerm[m.sourcePath] = legacyOrdinalMapping{ordinal: i, mapping: m.target} + } + for i, m := range taxonomies { + s.opts.legacyMappingTaxonomy[m.sourcePath] = legacyOrdinalMapping{ordinal: i, mapping: m.target} + } + for i, m := range sections { + s.opts.legacyMappingSection[m.sourcePath] = legacyOrdinalMapping{ordinal: i, mapping: m.target} + } + + return nil +} + +type TemplateStoreProvider interface { + GetTemplateStore() *TemplateStore +} + +type TextTemplatHandler interface { + ExecuteWithContext(ctx context.Context, ti *TemplInfo, wr io.Writer, data any) error + TextLookup(name string) *TemplInfo + TextParse(name, tpl string) (*TemplInfo, error) +} + +type bestMatch struct { + templ *TemplInfo + desc TemplateDescriptor + w weight + key string + + // settings. + defaultOutputformat string +} + +func (best *bestMatch) reset() { + best.templ = nil + best.w = weight{} + best.desc = TemplateDescriptor{} + best.key = "" +} + +func (best *bestMatch) isBetter(w weight, ti *TemplInfo) bool { + if best.templ == nil { + // 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 { + currentBestIsEmbedded := best.templ.SubCategory == SubCategoryEmbedded + if currentBestIsEmbedded { + if ti.SubCategory != SubCategoryEmbedded { + return true + } + } else { + if ti.SubCategory == SubCategoryEmbedded { + // Prefer user provided template. + return false + } + } + } + + if w.distance < best.w.distance { + if w.w2 < best.w.w2 { + return false + } + if w.w3 < best.w.w3 { + return false + } + } else { + if w.w1 < best.w.w1 { + return false + } + } + + if w.isEqualWeights(best.w) { + // Tie breakers. + if w.distance < best.w.distance { + 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 true +} + +func (best *bestMatch) updateValues(w weight, key string, k TemplateDescriptor, vv *TemplInfo) { + best.w = w + best.templ = vv + best.desc = k + best.key = key +} + +type byPath []*TemplInfo + +func (a byPath) Len() int { return len(a) } +func (a byPath) Less(i, j int) bool { + return a[i].PathInfo.Path() < a[j].PathInfo.Path() +} + +func (a byPath) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +type keyTemplateInfo struct { + Key string + Info *TemplInfo +} + +type nodeKey struct { + c Category + d TemplateDescriptor +} + +type processingState int + +// the parts of a template store that's set per site. +type storeSite struct { + opts SiteOptions + execHelper *templateExecHelper + executer texttemplate.Executer +} + +type weight struct { + w1 int + w2 int + w3 int + 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: + return true + default: + return false + } +} + +func configureSiteStorage(opts SiteOptions, watching bool) *storeSite { + funcsv := make(map[string]reflect.Value) + + for k, v := range opts.TemplateFuncs { + vv := reflect.ValueOf(v) + funcsv[k] = vv + } + + // Duplicate Go's internal funcs here for faster lookups. + for k, v := range htmltemplate.GoFuncs { + if _, exists := funcsv[k]; !exists { + vv, ok := v.(reflect.Value) + if !ok { + vv = reflect.ValueOf(v) + } + funcsv[k] = vv + } + } + + for k, v := range texttemplate.GoFuncs { + if _, exists := funcsv[k]; !exists { + funcsv[k] = v + } + } + + s := &storeSite{ + opts: opts, + execHelper: &templateExecHelper{ + watching: watching, + funcs: funcsv, + site: reflect.ValueOf(opts.Site), + siteParams: reflect.ValueOf(opts.Site.Params()), + }, + } + + s.executer = texttemplate.NewExecuter(s.execHelper) + + return s +} diff --git a/tpl/tplimpl/templatestore_integration_test.go b/tpl/tplimpl/templatestore_integration_test.go new file mode 100644 index 000000000..c109959ce --- /dev/null +++ b/tpl/tplimpl/templatestore_integration_test.go @@ -0,0 +1,842 @@ +package tplimpl_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/tpl/tplimpl" +) + +// Old as in before Hugo v0.146.0. +func TestLayoutsOldSetup(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +title = "Title in English" +weight = 1 +[languages.nn] +title = "Tittel på nynorsk" +weight = 2 +-- layouts/index.html -- +Home. +{{ template "_internal/twitter_cards.html" . }} +-- layouts/_default/single.html -- +Single. +-- layouts/_default/single.nn.html -- +Single NN. +-- layouts/_default/list.html -- +List HTML. +-- layouts/docs/list-baseof.html -- +Docs Baseof List HTML. +{{ block "main" . }}Docs Baseof List HTML main block.{{ end }} +-- layouts/docs/list.section.html -- +{{ define "main" }} +Docs List HTML. +{{ end }} +-- layouts/_default/list.json -- +List JSON. +-- layouts/_default/list.rss.xml -- +List RSS. +-- layouts/_default/list.nn.rss.xml -- +List NN RSS. +-- layouts/_default/baseof.html -- +Base. +-- layouts/partials/mypartial.html -- +Partial. +-- layouts/shortcodes/myshortcode.html -- +Shortcode. +-- content/docs/p1.md -- +--- +title: "P1" +--- + + ` + + b := hugolib.Test(t, files) + + // b.DebugPrint("", tplimpl.CategoryBaseof) + + b.AssertFileContent("public/en/docs/index.html", "Docs Baseof List HTML.\n\nDocs List HTML.") +} + +func TestLayoutsOldSetupBaseofPrefix(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/_default/layout1-baseof.html -- +Baseof layout1. {{ block "main" . }}{{ end }} +-- layouts/_default/layout2-baseof.html -- +Baseof layout2. {{ block "main" . }}{{ end }} +-- layouts/_default/layout1.html -- +{{ define "main" }}Layout1. {{ .Title }}{{ end }} +-- layouts/_default/layout2.html -- +{{ define "main" }}Layout2. {{ .Title }}{{ end }} +-- content/p1.md -- +--- +title: "P1" +layout: "layout1" +--- +-- content/p2.md -- +--- +title: "P2" +layout: "layout2" +--- +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", "Baseof layout1. Layout1. P1") + b.AssertFileContent("public/p2/index.html", "Baseof layout2. Layout2. P2") +} + +func TestLayoutsOldSetupTaxonomyAndTerm(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +[taxonomies] +cat = 'cats' +dog = 'dogs' +# Templates for term taxonomy, old setup. +-- layouts/dogs/terms.html -- +Dogs Terms. Most specific taxonomy template. +-- layouts/taxonomy/terms.html -- +Taxonomy Terms. Down the list. +# Templates for term term, old setup. +-- layouts/dogs/term.html -- +Dogs Term. Most specific term template. +-- layouts/term/term.html -- +Term Term. Down the list. +-- layouts/dogs/max/list.html -- +max: {{ .Title }} +-- layouts/_default/list.html -- +Default list. +-- layouts/_default/single.html -- +Default single. +-- content/p1.md -- +--- +title: "P1" +dogs: ["luna", "daisy", "max"] +--- + +` + b := hugolib.Test(t, files, hugolib.TestOptWarn()) + + b.AssertLogContains("! WARN") + + b.AssertFileContent("public/dogs/index.html", "Dogs Terms. Most specific taxonomy template.") + b.AssertFileContent("public/dogs/luna/index.html", "Dogs Term. Most specific term template.") + b.AssertFileContent("public/dogs/max/index.html", "max: Max") // layouts/dogs/max/list.html wins over layouts/term/term.html because of distance. +} + +func TestLayoutsOldSetupCustomRSS(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "page"] +[outputs] +home = ["rss"] +-- layouts/_default/list.rss.xml -- +List RSS. +` + b := hugolib.Test(t, files) + b.AssertFileContent("public/index.xml", "List RSS.") +} + +var newSetupTestSites = ` +-- hugo.toml -- +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +title = "Title in English" +weight = 1 +[languages.nn] +title = "Tittel på nynorsk" +weight = 2 +[languages.fr] +title = "Titre en français" +weight = 3 + +[outputs] +home = ["html", "rss", "redir"] + +[outputFormats] +[outputFormats.redir] +mediatype = "text/plain" +baseName = "_redirects" +isPlainText = true +-- layouts/404.html -- +{{ define "main" }} +404. +{{ end }} +-- layouts/home.html -- +{{ define "main" }} +Home: {{ .Title }}|{{ .Content }}| +Inline Partial: {{ partial "my-inline-partial.html" . }} +{{ end }} +{{ define "hero" }} +Home hero. +{{ end }} +{{ define "partials/my-inline-partial.html" }} +{{ $value := 32 }} +{{ return $value }} +{{ end }} +-- layouts/index.redir -- +Redir. +-- layouts/single.html -- +{{ define "main" }} +Single needs base. +{{ end }} +-- layouts/foo/bar/single.html -- +{{ define "main" }} +Single sub path. +{{ end }} +-- layouts/_markup/render-codeblock.html -- +Render codeblock. +-- layouts/_markup/render-blockquote.html -- +Render blockquote. +-- layouts/_markup/render-codeblock-go.html -- + Render codeblock go. +-- layouts/_markup/render-link.html -- +Link: {{ .Destination | safeURL }} +-- layouts/foo/baseof.html -- +Base sub path.{{ block "main" . }}{{ end }} +-- layouts/foo/bar/baseof.page.html -- +Base sub path.{{ block "main" . }}{{ end }} +-- layouts/list.html -- +{{ define "main" }} +List needs base. +{{ end }} +-- layouts/section.html -- +Section. +-- layouts/mysectionlayout.section.fr.amp.html -- +Section with layout. +-- layouts/baseof.html -- +Base.{{ block "main" . }}{{ end }} +Hero:{{ block "hero" . }}{{ end }}: +{{ with (templates.Defer (dict "key" "global")) }} +Defer Block. +{{ end }} +-- layouts/baseof.fr.html -- +Base fr.{{ block "main" . }}{{ end }} +-- layouts/baseof.term.html -- +Base term. +-- layouts/baseof.section.fr.amp.html -- +Base with identifiers.{{ block "main" . }}{{ end }} +-- layouts/partials/mypartial.html -- +Partial. {{ partial "_inline/my-inline-partial-in-partial-with-no-ext" . }} +{{ define "partials/_inline/my-inline-partial-in-partial-with-no-ext" }} +Partial in partial. +{{ end }} +-- layouts/partials/returnfoo.html -- +{{ $v := "foo" }} +{{ return $v }} +-- layouts/shortcodes/myshortcode.html -- +Shortcode. {{ partial "mypartial.html" . }}|return:{{ partial "returnfoo.html" . }}| +-- content/_index.md -- +--- +title: Home sweet home! +--- + +{{< myshortcode >}} + +> My blockquote. + + +Markdown link: [Foo](/foo) +-- content/p1.md -- +--- +title: "P1" +--- +-- content/foo/bar/index.md -- +--- +title: "Foo Bar" +--- + +{{< myshortcode >}} + +-- content/single-list.md -- +--- +title: "Single List" +layout: "list" +--- + +` + +func TestLayoutsType(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +-- layouts/list.html -- +List. +-- layouts/mysection/single.html -- +mysection/single|{{ .Title }} +-- layouts/mytype/single.html -- +mytype/single|{{ .Title }} +-- content/mysection/_index.md -- +-- content/mysection/mysubsection/_index.md -- +-- content/mysection/mysubsection/p1.md -- +--- +title: "P1" +--- +-- content/mysection/mysubsection/p2.md -- +--- +title: "P2" +type: "mytype" +--- + +` + + b := hugolib.Test(t, files, hugolib.TestOptWarn()) + + b.AssertLogContains("! WARN") + + b.AssertFileContent("public/mysection/mysubsection/p1/index.html", "mysection/single|P1") + b.AssertFileContent("public/mysection/mysubsection/p2/index.html", "mytype/single|P2") +} + +// New, as in from Hugo v0.146.0. +func TestLayoutsNewSetup(t *testing.T) { + const numIterations = 1 + for range numIterations { + + b := hugolib.Test(t, newSetupTestSites, hugolib.TestOptWarn()) + + b.AssertLogContains("! WARN") + + b.AssertFileContent("public/en/index.html", + "Base.\nHome: Home sweet home!|", + "|Shortcode.\n|", + "

Markdown link: Link: /foo

", + "|return:foo|", + "Defer Block.", + "Home hero.", + "Render blockquote.", + ) + + b.AssertFileContent("public/en/p1/index.html", "Base.\nSingle needs base.\n\nHero::\n\nDefer Block.") + b.AssertFileContent("public/en/404.html", "404.") + b.AssertFileContent("public/nn/404.html", "404.") + b.AssertFileContent("public/fr/404.html", "404.") + + } +} + +func TestHomeRSSAndHTMLWithHTMLOnlyShortcode(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +[outputs] +home = ["html", "rss"] +-- layouts/home.html -- +Home: {{ .Title }}|{{ .Content }}| +-- layouts/single.html -- +Single: {{ .Title }}|{{ .Content }}| +-- layouts/shortcodes/myshortcode.html -- +Myshortcode: Count: {{ math.Counter }}| +-- content/p1.md -- +--- +title: "P1" +--- + +{{< myshortcode >}} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", "Single: P1|Myshortcode: Count: 1|") + b.AssertFileContent("public/index.xml", "Myshortcode: Count: 1") +} + +func TestHomeRSSAndHTMLWithHTMLOnlyRenderHook(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +[outputs] +home = ["html", "rss"] +-- layouts/home.html -- +Home: {{ .Title }}|{{ .Content }}| +-- layouts/single.html -- +Single: {{ .Title }}|{{ .Content }}| +-- layouts/_markup/render-link.html -- +Render Link: {{ math.Counter }}| +-- content/p1.md -- +--- +title: "P1" +--- + +Link: [Foo](/foo) +` + + for range 2 { + b := hugolib.Test(t, files) + b.AssertFileContent("public/index.xml", "Link: Render Link: 1|") + b.AssertFileContent("public/p1/index.html", "Single: P1|

Link: Render Link: 1|<") + } +} + +func TestRenderCodeblockSpecificity(t *testing.T) { + files := ` +-- hugo.toml -- +-- layouts/_markup/render-codeblock.html -- +Render codeblock.|{{ .Inner }}| +-- layouts/_markup/render-codeblock-go.html -- +Render codeblock go.|{{ .Inner }}| +-- layouts/single.html -- +{{ .Title }}|{{ .Content }}| +-- content/p1.md -- +--- +title: "P1" +--- + +§§§ +Basic +§§§ + +§§§ go +Go +§§§ + +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", "P1|Render codeblock.|Basic|Render codeblock go.|Go|") +} + +func TestPrintUnusedTemplates(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +baseURL = 'http://example.com/' +printUnusedTemplates=true +-- content/p1.md -- +--- +title: "P1" +--- +{{< usedshortcode >}} +-- layouts/baseof.html -- +{{ block "main" . }}{{ end }} +-- layouts/baseof.json -- +{{ block "main" . }}{{ end }} +-- layouts/index.html -- +{{ define "main" }}FOO{{ end }} +-- layouts/_default/single.json -- +-- layouts/_default/single.html -- +{{ define "main" }}MAIN{{ end }} +-- layouts/post/single.html -- +{{ define "main" }}MAIN{{ end }} +-- layouts/_partials/usedpartial.html -- +-- layouts/_partials/unusedpartial.html -- +-- layouts/_shortcodes/usedshortcode.html -- +{{ partial "usedpartial.html" }} +-- layouts/shortcodes/unusedshortcode.html -- + + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }, + ) + b.Build() + + unused := b.H.GetTemplateStore().UnusedTemplates() + var names []string + for _, tmpl := range unused { + if fi := tmpl.Fi; fi != nil { + 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"}) +} + +func TestCreateManyTemplateStores(t *testing.T) { + t.Parallel() + b := hugolib.Test(t, newSetupTestSites) + store := b.H.TemplateStore + + for range 70 { + newStore, err := store.NewFromOpts() + b.Assert(err, qt.IsNil) + b.Assert(newStore, qt.Not(qt.IsNil)) + } +} + +func BenchmarkLookupPagesLayout(b *testing.B) { + files := ` +-- hugo.toml -- +-- layouts/single.html -- +{{ define "main" }} + Main. +{{ end }} +-- layouts/baseof.html -- +baseof: {{ block "main" . }}{{ end }} +-- layouts/foo/bar/single.html -- +{{ define "main" }} + Main. +{{ end }} + +` + bb := hugolib.Test(b, files) + store := bb.H.TemplateStore + + b.ResetTimer() + b.Run("Single root", func(b *testing.B) { + q := tplimpl.TemplateQuery{ + Path: "/baz", + Category: tplimpl.CategoryLayout, + Desc: tplimpl.TemplateDescriptor{Kind: kinds.KindPage, Layout: "single", OutputFormat: "html"}, + } + for i := 0; i < b.N; i++ { + store.LookupPagesLayout(q) + } + }) + + b.Run("Single sub folder", func(b *testing.B) { + q := tplimpl.TemplateQuery{ + Path: "/foo/bar", + Category: tplimpl.CategoryLayout, + Desc: tplimpl.TemplateDescriptor{Kind: kinds.KindPage, Layout: "single", OutputFormat: "html"}, + } + for i := 0; i < b.N; i++ { + store.LookupPagesLayout(q) + } + }) +} + +func BenchmarkNewTemplateStore(b *testing.B) { + bb := hugolib.Test(b, newSetupTestSites) + store := bb.H.TemplateStore + + b.ResetTimer() + for i := 0; i < b.N; i++ { + newStore, err := store.NewFromOpts() + if err != nil { + b.Fatal(err) + } + if newStore == nil { + b.Fatal("newStore is nil") + } + } +} + +func TestLayoutsLookupVariants(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[outputs] +home = ["html", "rss"] +page = ["html", "rss", "amp"] +section = ["html", "rss"] + +[languages] +[languages.en] +title = "Title in English" +weight = 1 +[languages.nn] +title = "Tittel på nynorsk" +weight = 2 +-- layouts/list.xml -- +layouts/list.xml +-- layouts/_shortcodes/myshortcode.html -- +layouts/shortcodes/myshortcode.html +-- layouts/foo/bar/_shortcodes/myshortcode.html -- +layouts/foo/bar/_shortcodes/myshortcode.html +-- layouts/_markup/render-codeblock.html -- +layouts/_markup/render-codeblock.html|{{ .Type }}| +-- layouts/_markup/render-codeblock-go.html -- +layouts/_markup/render-codeblock-go.html|{{ .Type }}| +-- layouts/single.xml -- +layouts/single.xml +-- layouts/single.rss.xml -- +layouts/single.rss.xml +-- layouts/single.nn.rss.xml -- +layouts/single.nn.rss.xml +-- layouts/list.html -- +layouts/list.html +-- layouts/single.html -- +layouts/single.html +{{ .Content }} +-- layouts/mylayout.html -- +layouts/mylayout.html +-- layouts/mylayout.nn.html -- +layouts/mylayout.nn.html +-- layouts/foo/single.rss.xml -- +layouts/foo/single.rss.xml +-- layouts/foo/single.amp.html -- +layouts/foo/single.amp.html +-- layouts/foo/bar/page.html -- +layouts/foo/bar/page.html +-- layouts/foo/bar/baz/single.html -- +layouts/foo/bar/baz/single.html +{{ .Content }} +-- layouts/qux/mylayout.html -- +layouts/qux/mylayout.html +-- layouts/qux/single.xml -- +layouts/qux/single.xml +-- layouts/qux/mylayout.section.html -- +layouts/qux/mylayout.section.html +-- content/p.md -- +--- +--- +§§§ +code +§§§ + +§§§ go +code +§§§ + +{{< myshortcode >}} +-- content/foo/p.md -- +-- content/foo/p.nn.md -- +-- content/foo/bar/p.md -- +-- content/foo/bar/withmylayout.md -- +--- +layout: mylayout +--- +-- content/foo/bar/_index.md -- +-- content/foo/bar/baz/p.md -- +--- +--- +{{< myshortcode >}} +-- content/qux/p.md -- +-- content/qux/_index.md -- +--- +layout: mylayout +--- +-- content/qux/quux/p.md -- +-- content/qux/quux/withmylayout.md -- +--- +layout: mylayout +--- +-- content/qux/quux/withmylayout.nn.md -- +--- +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. + // output format: html. + b.AssertFileContent("public/en/p/index.html", "layouts/single.html", + "layouts/_markup/render-codeblock.html|", + "layouts/_markup/render-codeblock-go.html|go|", + "layouts/shortcodes/myshortcode.html", + ) + b.AssertFileContent("public/en/foo/p/index.html", "layouts/single.html") + b.AssertFileContent("public/en/foo/bar/p/index.html", "layouts/foo/bar/page.html") + b.AssertFileContent("public/en/foo/bar/withmylayout/index.html", "layouts/mylayout.html") + b.AssertFileContent("public/en/foo/bar/baz/p/index.html", "layouts/foo/bar/baz/single.html", "layouts/foo/bar/_shortcodes/myshortcode.html") + b.AssertFileContent("public/en/qux/quux/withmylayout/index.html", "layouts/qux/mylayout.html") + // output format: amp. + b.AssertFileContent("public/en/amp/p/index.html", "layouts/single.html") + b.AssertFileContent("public/en/amp/foo/p/index.html", "layouts/foo/single.amp.html") + // output format: rss. + b.AssertFileContent("public/en/p/index.xml", "layouts/single.rss.xml") + b.AssertFileContent("public/en/foo/p/index.xml", "layouts/foo/single.rss.xml") + b.AssertFileContent("public/nn/foo/p/index.xml", "layouts/single.nn.rss.xml") + + // Note: There is qux/single.xml that's closer, but the one in the root is used becaulse of the output format match. + b.AssertFileContent("public/en/qux/p/index.xml", "layouts/single.rss.xml") + + // Note. + b.AssertFileContent("public/nn/qux/quux/withmylayout/index.html", "layouts/mylayout.nn.html") + + // Section pages. + // output format: html. + b.AssertFileContent("public/en/foo/index.html", "layouts/list.html") + b.AssertFileContent("public/en/qux/index.html", "layouts/qux/mylayout.section.html") + // output format: rss. + b.AssertFileContent("public/en/foo/index.xml", "layouts/list.xml") +} + +func TestLookupShortcodeDepth(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/_shortcodes/myshortcode.html -- +layouts/_shortcodes/myshortcode.html +-- layouts/foo/_shortcodes/myshortcode.html -- +layouts/foo/_shortcodes/myshortcode.html +-- layouts/single.html -- +{{ .Content }}| +-- content/p.md -- +--- +--- +{{< myshortcode >}} +-- content/foo/p.md -- +--- +--- +{{< myshortcode >}} +-- content/foo/bar/p.md -- +--- +--- +{{< myshortcode >}} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p/index.html", "layouts/_shortcodes/myshortcode.html") + b.AssertFileContent("public/foo/p/index.html", "layouts/foo/_shortcodes/myshortcode.html") + b.AssertFileContent("public/foo/bar/p/index.html", "layouts/foo/_shortcodes/myshortcode.html") +} + +func TestLookupShortcodeLayout(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/_shortcodes/myshortcode.single.html -- +layouts/_shortcodes/myshortcode.single.html +-- layouts/_shortcodes/myshortcode.list.html -- +layouts/_shortcodes/myshortcode.list.html +-- layouts/single.html -- +{{ .Content }}| +-- layouts/list.html -- +{{ .Content }}| +-- content/_index.md -- +--- +--- +{{< myshortcode >}} +-- content/p.md -- +--- +--- +{{< myshortcode >}} +-- content/foo/p.md -- +--- +--- +{{< myshortcode >}} +-- content/foo/bar/p.md -- +--- +--- +{{< myshortcode >}} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p/index.html", "layouts/_shortcodes/myshortcode.single.html") + b.AssertFileContent("public/index.html", "layouts/_shortcodes/myshortcode.list.html") +} + +func TestLayoutAll(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/single.html -- +Single. +-- layouts/all.html -- +All. +-- content/p1.md -- + +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", "Single.") + b.AssertFileContent("public/index.html", "All.") +} + +func TestLayoutAllNested(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") + b.AssertFileContent("public/s1/p1/index.html", "layouts/s1/all.html") + b.AssertFileContent("public/s2/index.html", "layouts/list.html") + b.AssertFileContent("public/s2/p2/index.html", "layouts/single.html") +} + +func TestPartialHTML(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/all.html -- + + +{{ partial "css.html" .}} + + +-- layouts/partials/css.html -- + +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "") +} + +// Issue #13515 +func TestPrintPathWarningOnDotRemoval(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +printPathWarnings = true +-- content/v0.124.0.md -- +-- content/v0.123.0.md -- +-- layouts/all.html -- +All. +-- layouts/_default/single.html -- +{{ .Title }}| +` + + b := hugolib.Test(t, files, hugolib.TestOptWarn()) + + b.AssertLogContains("Duplicate content path") +} diff --git a/tpl/tplimpl/template_ast_transformers.go b/tpl/tplimpl/templatetransform.go similarity index 74% rename from tpl/tplimpl/template_ast_transformers.go rename to tpl/tplimpl/templatetransform.go index 4deadd052..f9ce298de 100644 --- a/tpl/tplimpl/template_ast_transformers.go +++ b/tpl/tplimpl/templatetransform.go @@ -1,48 +1,27 @@ -// Copyright 2016 The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - package tplimpl import ( "errors" "fmt" + "slices" "strings" + "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate/parse" + htmltemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/htmltemplate" texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate" - "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate/parse" - "github.com/gohugoio/hugo/common/hashing" "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/tpl" "github.com/mitchellh/mapstructure" - "slices" ) -type templateType int - -const ( - templateUndefined templateType = iota - templateShortcode - templatePartial -) - -type templateContext struct { +type templateTransformContext struct { visited map[string]bool templateNotFound map[string]bool deferNodes map[string]*parse.ListNode - lookupFn func(name string) *templateState + lookupFn func(name string, in *TemplInfo) *TemplInfo // The last error encountered. err error @@ -50,18 +29,18 @@ type templateContext struct { // Set when we're done checking for config header. configChecked bool - t *templateState + t *TemplInfo // Store away the return node in partials. returnNode *parse.CommandNode } -func (c templateContext) getIfNotVisited(name string) *templateState { +func (c templateTransformContext) getIfNotVisited(name string) *TemplInfo { if c.visited[name] { return nil } c.visited[name] = true - templ := c.lookupFn(name) + templ := c.lookupFn(name, c.t) if templ == nil { // This may be a inline template defined outside of this file // and not yet parsed. Unusual, but it happens. @@ -72,11 +51,11 @@ func (c templateContext) getIfNotVisited(name string) *templateState { return templ } -func newTemplateContext( - t *templateState, - lookupFn func(name string) *templateState, -) *templateContext { - return &templateContext{ +func newTemplateTransformContext( + t *TemplInfo, + lookupFn func(name string, in *TemplInfo) *TemplInfo, +) *templateTransformContext { + return &templateTransformContext{ t: t, lookupFn: lookupFn, visited: make(map[string]bool), @@ -86,21 +65,25 @@ func newTemplateContext( } func applyTemplateTransformers( - t *templateState, - lookupFn func(name string) *templateState, -) (*templateContext, error) { + t *TemplInfo, + lookupFn func(name string, in *TemplInfo) *TemplInfo, +) (*templateTransformContext, error) { if t == nil { return nil, errors.New("expected template, but none provided") } - c := newTemplateContext(t, lookupFn) + c := newTemplateTransformContext(t, lookupFn) + c.t.ParseInfo = defaultParseInfo tree := getParseTree(t.Template) + if tree == nil { + panic(fmt.Errorf("template %s not parsed", t)) + } _, err := c.applyTransformations(tree.Root) if err == nil && c.returnNode != nil { // This is a partial with a return statement. - c.t.parseInfo.HasReturn = true + c.t.ParseInfo.HasReturn = true tree.Root = c.wrapInPartialReturnWrapper(tree.Root) } @@ -108,7 +91,6 @@ func applyTemplateTransformers( } func getParseTree(templ tpl.Template) *parse.Tree { - templ = unwrap(templ) if text, ok := templ.(*texttemplate.Template); ok { return text.Tree } @@ -146,7 +128,7 @@ func init() { // wrapInPartialReturnWrapper copies and modifies the parsed nodes of a // predefined partial return wrapper to insert those of a user-defined partial. -func (c *templateContext) wrapInPartialReturnWrapper(n *parse.ListNode) *parse.ListNode { +func (c *templateTransformContext) wrapInPartialReturnWrapper(n *parse.ListNode) *parse.ListNode { wrapper := partialReturnWrapper.CopyList() rangeNode := wrapper.Nodes[2].(*parse.RangeNode) retn := rangeNode.List.Nodes[0] @@ -163,7 +145,7 @@ func (c *templateContext) wrapInPartialReturnWrapper(n *parse.ListNode) *parse.L // applyTransformations do 2 things: // 1) Parses partial return statement. // 2) Tracks template (partial) dependencies and some other info. -func (c *templateContext) applyTransformations(n parse.Node) (bool, error) { +func (c *templateTransformContext) applyTransformations(n parse.Node) (bool, error) { switch x := n.(type) { case *parse.ListNode: if x != nil { @@ -208,7 +190,7 @@ func (c *templateContext) applyTransformations(n parse.Node) (bool, error) { return true, c.err } -func (c *templateContext) handleDefer(withNode *parse.WithNode) { +func (c *templateTransformContext) handleDefer(withNode *parse.WithNode) { if len(withNode.Pipe.Cmds) != 1 { return } @@ -265,13 +247,13 @@ func (c *templateContext) handleDefer(withNode *parse.WithNode) { n.Pipe.Cmds[0].Args[2] = deferArg } -func (c *templateContext) applyTransformationsToNodes(nodes ...parse.Node) { +func (c *templateTransformContext) applyTransformationsToNodes(nodes ...parse.Node) { for _, node := range nodes { c.applyTransformations(node) } } -func (c *templateContext) hasIdent(idents []string, ident string) bool { +func (c *templateTransformContext) hasIdent(idents []string, ident string) bool { return slices.Contains(idents, ident) } @@ -280,8 +262,8 @@ func (c *templateContext) hasIdent(idents []string, ident string) bool { // on the form: // // {{ $_hugo_config:= `{ "version": 1 }` }} -func (c *templateContext) collectConfig(n *parse.PipeNode) { - if c.t.typ != templateShortcode { +func (c *templateTransformContext) collectConfig(n *parse.PipeNode) { + if c.t.Category != CategoryShortcode { return } if c.configChecked { @@ -313,7 +295,7 @@ func (c *templateContext) collectConfig(n *parse.PipeNode) { c.err = fmt.Errorf(errMsg, err) return } - if err := mapstructure.WeakDecode(m, &c.t.parseInfo.Config); err != nil { + if err := mapstructure.WeakDecode(m, &c.t.ParseInfo.Config); err != nil { c.err = fmt.Errorf(errMsg, err) } } @@ -321,11 +303,11 @@ func (c *templateContext) collectConfig(n *parse.PipeNode) { // collectInner determines if the given CommandNode represents a // shortcode call to its .Inner. -func (c *templateContext) collectInner(n *parse.CommandNode) { - if c.t.typ != templateShortcode { +func (c *templateTransformContext) collectInner(n *parse.CommandNode) { + if c.t.Category != CategoryShortcode { return } - if c.t.parseInfo.IsInner || len(n.Args) == 0 { + if c.t.ParseInfo.IsInner || len(n.Args) == 0 { return } @@ -339,14 +321,14 @@ func (c *templateContext) collectInner(n *parse.CommandNode) { } if c.hasIdent(idents, "Inner") || c.hasIdent(idents, "InnerDeindent") { - c.t.parseInfo.IsInner = true + c.t.ParseInfo.IsInner = true break } } } -func (c *templateContext) collectReturnNode(n *parse.CommandNode) bool { - if c.t.typ != templatePartial || c.returnNode != nil { +func (c *templateTransformContext) collectReturnNode(n *parse.CommandNode) bool { + if c.t.Category != CategoryPartial || c.returnNode != nil { return true } @@ -365,17 +347,3 @@ func (c *templateContext) collectReturnNode(n *parse.CommandNode) bool { return false } - -func findTemplateIn(name string, in tpl.Template) (tpl.Template, bool) { - in = unwrap(in) - if text, ok := in.(*texttemplate.Template); ok { - if templ := text.Lookup(name); templ != nil { - return templ, true - } - return nil, false - } - if templ := in.(*htmltemplate.Template).Lookup(name); templ != nil { - return templ, true - } - return nil, false -} diff --git a/tpl/tplimpl/tplimpl_integration_test.go b/tpl/tplimpl/tplimpl_integration_test.go index 9b30466e8..8b80d5b60 100644 --- a/tpl/tplimpl/tplimpl_integration_test.go +++ b/tpl/tplimpl/tplimpl_integration_test.go @@ -1,66 +1,13 @@ package tplimpl_test import ( - "path/filepath" "strings" "testing" qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/hugolib" - "github.com/gohugoio/hugo/tpl" ) -func TestPrintUnusedTemplates(t *testing.T) { - t.Parallel() - - files := ` --- config.toml -- -baseURL = 'http://example.com/' -printUnusedTemplates=true --- content/p1.md -- ---- -title: "P1" ---- -{{< usedshortcode >}} --- layouts/baseof.html -- -{{ block "main" . }}{{ end }} --- layouts/baseof.json -- -{{ block "main" . }}{{ end }} --- layouts/index.html -- -{{ define "main" }}FOO{{ end }} --- layouts/_default/single.json -- --- layouts/_default/single.html -- -{{ define "main" }}MAIN{{ end }} --- layouts/post/single.html -- -{{ define "main" }}MAIN{{ end }} --- layouts/partials/usedpartial.html -- --- layouts/partials/unusedpartial.html -- --- layouts/shortcodes/usedshortcode.html -- -{{ partial "usedpartial.html" }} --- layouts/shortcodes/unusedshortcode.html -- - - ` - - b := hugolib.NewIntegrationTestBuilder( - hugolib.IntegrationTestConfig{ - T: t, - TxtarString: files, - NeedsOsFS: true, - }, - ) - b.Build() - - unused := b.H.Tmpl().(tpl.UnusedTemplatesProvider).UnusedTemplates() - - var names []string - for _, tmpl := range unused { - names = append(names, tmpl.Name()) - } - - b.Assert(names, qt.DeepEquals, []string{"_default/single.json", "baseof.json", "partials/unusedpartial.html", "post/single.html", "shortcodes/unusedshortcode.html"}) - b.Assert(unused[0].Filename(), qt.Equals, filepath.Join(b.Cfg.WorkingDir, "layouts/_default/single.json")) -} - // Verify that the new keywords in Go 1.18 is available. func TestGo18Constructs(t *testing.T) { t.Parallel() @@ -627,9 +574,9 @@ Home! b := hugolib.TestRunning(t, files) b.AssertFileContent("public/index.html", "Home!") - b.EditFileReplaceAll("layouts/_default/baseof.html", "Baseof", "Baseof!").Build() + b.EditFileReplaceAll("layouts/_default/baseof.html", "baseof", "Baseof!").Build() b.BuildPartial("/") - b.AssertFileContent("public/index.html", "Baseof!!") + b.AssertFileContent("public/index.html", "Baseof!") b.BuildPartial("/mybundle1/") - b.AssertFileContent("public/mybundle1/index.html", "Baseof!!") + b.AssertFileContent("public/mybundle1/index.html", "Baseof!") } diff --git a/tpl/tplimplinit/tplimplinit.go b/tpl/tplimplinit/tplimplinit.go new file mode 100644 index 000000000..6316e8897 --- /dev/null +++ b/tpl/tplimplinit/tplimplinit.go @@ -0,0 +1,96 @@ +// 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 tplimplinit + +import ( + // Init the template funcs namespaces + "context" + "html/template" + + "github.com/gohugoio/hugo/deps" + _ "github.com/gohugoio/hugo/tpl/cast" + _ "github.com/gohugoio/hugo/tpl/collections" + _ "github.com/gohugoio/hugo/tpl/compare" + _ "github.com/gohugoio/hugo/tpl/crypto" + _ "github.com/gohugoio/hugo/tpl/css" + _ "github.com/gohugoio/hugo/tpl/data" + _ "github.com/gohugoio/hugo/tpl/debug" + _ "github.com/gohugoio/hugo/tpl/diagrams" + _ "github.com/gohugoio/hugo/tpl/encoding" + _ "github.com/gohugoio/hugo/tpl/fmt" + _ "github.com/gohugoio/hugo/tpl/hash" + _ "github.com/gohugoio/hugo/tpl/hugo" + _ "github.com/gohugoio/hugo/tpl/images" + _ "github.com/gohugoio/hugo/tpl/inflect" + "github.com/gohugoio/hugo/tpl/internal" + _ "github.com/gohugoio/hugo/tpl/js" + _ "github.com/gohugoio/hugo/tpl/lang" + _ "github.com/gohugoio/hugo/tpl/math" + _ "github.com/gohugoio/hugo/tpl/openapi/openapi3" + _ "github.com/gohugoio/hugo/tpl/os" + _ "github.com/gohugoio/hugo/tpl/page" + _ "github.com/gohugoio/hugo/tpl/partials" + _ "github.com/gohugoio/hugo/tpl/path" + _ "github.com/gohugoio/hugo/tpl/reflect" + _ "github.com/gohugoio/hugo/tpl/resources" + _ "github.com/gohugoio/hugo/tpl/safe" + _ "github.com/gohugoio/hugo/tpl/site" + _ "github.com/gohugoio/hugo/tpl/strings" + _ "github.com/gohugoio/hugo/tpl/templates" + _ "github.com/gohugoio/hugo/tpl/time" + _ "github.com/gohugoio/hugo/tpl/transform" + _ "github.com/gohugoio/hugo/tpl/urls" +) + +// CreateFuncMap creates a template.FuncMap with all of Hugo's template funcs, +// excluding the Go built-ins. +func CreateFuncMap(d *deps.Deps) map[string]any { + funcMap := template.FuncMap{} + nsMap := make(map[string]any) + var onCreated []func(namespaces map[string]any) + + // Merge the namespace funcs + for _, nsf := range internal.TemplateFuncsNamespaceRegistry { + ns := nsf(d) + if _, exists := funcMap[ns.Name]; exists { + panic(ns.Name + " is a duplicate template func") + } + funcMap[ns.Name] = ns.Context + contextV, err := ns.Context(context.Background()) + if err != nil { + panic(err) + } + nsMap[ns.Name] = contextV + for _, mm := range ns.MethodMappings { + for _, alias := range mm.Aliases { + if _, exists := funcMap[alias]; exists { + panic(alias + " is a duplicate template func") + } + funcMap[alias] = mm.Method + } + } + + if ns.OnCreated != nil { + onCreated = append(onCreated, ns.OnCreated) + } + } + + for _, f := range onCreated { + f(nsMap) + } + + return funcMap +}