From 61317821e463f8c6e980de6d0c27188148464c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Fri, 16 May 2025 10:36:05 +0200 Subject: [PATCH] tpl: Narrow down the usage of plain text shortcodes when rendering HTML After this commit, if you want to resolve `layouts/_shortcodes/myshortcode.txt` when rendering HTML content, you need to use the `{{%` shortcode delimiter: ``` {{% myshortcode %}} ``` This should be what people would do anyway, but we have also as part of this improved the error message to inform about what needs to be done. Note that this is not relevant for partials. Fixes #13698 --- hugolib/shortcode.go | 9 ++-- hugolib/shortcode_test.go | 6 +-- tpl/tplimpl/shortcodes_integration_test.go | 33 +++++++++++++ tpl/tplimpl/templatedescriptor.go | 21 ++++++--- tpl/tplimpl/templatestore.go | 46 +++++++++++++++---- tpl/tplimpl/templatestore_integration_test.go | 36 ++++++++++++++- 6 files changed, 127 insertions(+), 24 deletions(-) diff --git a/hugolib/shortcode.go b/hugolib/shortcode.go index cc8a145d9..56bf1ff9e 100644 --- a/hugolib/shortcode.go +++ b/hugolib/shortcode.go @@ -398,6 +398,10 @@ func doRenderShortcode( return true } base, layoutDescriptor := po.GetInternalTemplateBasePathAndDescriptor() + + // With shortcodes/mymarkdown.md (only), this allows {{% mymarkdown %}} when rendering HTML, + // but will not resolve any template when doing {{< mymarkdown >}}. + layoutDescriptor.AlwaysAllowPlainText = sc.doMarkup q := tplimpl.TemplateQuery{ Path: base, Name: sc.name, @@ -405,10 +409,9 @@ func doRenderShortcode( Desc: layoutDescriptor, Consider: include, } - v := s.TemplateStore.LookupShortcode(q) + v, err := 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 + return zeroShortcode, err } tmpl = v hasVariants = hasVariants || len(ofCount) > 1 diff --git a/hugolib/shortcode_test.go b/hugolib/shortcode_test.go index f1d90e22e..a1f12e77a 100644 --- a/hugolib/shortcode_test.go +++ b/hugolib/shortcode_test.go @@ -918,7 +918,7 @@ func TestShortcodeMarkdownOutputFormat(t *testing.T) { --- title: "p1" --- -{{< foo >}} +{{% foo %}} # The below would have failed using the HTML template parser. -- layouts/shortcodes/foo.md -- ยงยงยง @@ -930,9 +930,7 @@ title: "p1" b := Test(t, files) - b.AssertFileContent("public/p1/index.html", ` -<x") } func TestShortcodePreserveIndentation(t *testing.T) { diff --git a/tpl/tplimpl/shortcodes_integration_test.go b/tpl/tplimpl/shortcodes_integration_test.go index 838dc16d7..e65f82eab 100644 --- a/tpl/tplimpl/shortcodes_integration_test.go +++ b/tpl/tplimpl/shortcodes_integration_test.go @@ -17,6 +17,7 @@ import ( "strings" "testing" + qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/htesting/hqt" "github.com/gohugoio/hugo/hugolib" ) @@ -696,3 +697,35 @@ title: p2 b.AssertFileContent("public/p1/index.html", "78eb19b5c6f3768f") b.AssertFileContent("public/p2/index.html", "a6db910a9cf54bc1") } + +func TestShortcodePlainTextVsHTMLTemplateIssue13698(t *testing.T) { + t.Parallel() + + filesTemplate := ` +-- hugo.toml -- +markup.goldmark.renderer.unsafe = true +-- layouts/all.html -- +Content: {{ .Content }}| +-- layouts/_shortcodes/mymarkdown.md -- +
Foo bar
+-- content/p1.md -- +--- +title: p1 +--- +## A shortcode + +SHORTCODE + +` + + files := strings.ReplaceAll(filesTemplate, "SHORTCODE", "{{% mymarkdown %}}") + b := hugolib.Test(t, files) + b.AssertFileContent("public/p1/index.html", "
Foo bar
") + + files = strings.ReplaceAll(filesTemplate, "SHORTCODE", "{{< mymarkdown >}}") + + var err error + b, err = hugolib.TestE(t, files) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `no compatible template found for shortcode "mymarkdown" in [/_shortcodes/mymarkdown.md]; note that to use plain text template shortcodes in HTML you need to use the shortcode {{% delimiter`) +} diff --git a/tpl/tplimpl/templatedescriptor.go b/tpl/tplimpl/templatedescriptor.go index ea47afc88..fd86f15fa 100644 --- a/tpl/tplimpl/templatedescriptor.go +++ b/tpl/tplimpl/templatedescriptor.go @@ -37,6 +37,7 @@ type TemplateDescriptor struct { // Misc. LayoutFromUserMustMatch bool // If set, we only look for the exact layout. IsPlainText bool // Whether this is a plain text template. + AlwaysAllowPlainText bool // Whether to e.g. allow plain text templates to be rendered in HTML. } func (d *TemplateDescriptor) normalizeFromFile() { @@ -64,7 +65,7 @@ func (s descriptorHandler) compareDescriptors(category Category, isEmbedded bool return weightNoMatch } - w := this.doCompare(category, isEmbedded, s.opts.DefaultContentLanguage, other) + w := this.doCompare(category, s.opts.DefaultContentLanguage, other) if w.w1 <= 0 { if category == CategoryMarkup && (this.Variant1 == other.Variant1) && (this.Variant2 == other.Variant2 || this.Variant2 != "" && other.Variant2 == "") { @@ -74,7 +75,12 @@ func (s descriptorHandler) compareDescriptors(category Category, isEmbedded bool } w.w1 = 1 - return w + } + + if category == CategoryShortcode { + if (this.IsPlainText == other.IsPlainText || !other.IsPlainText) || this.AlwaysAllowPlainText { + w.w1 = 1 + } } } @@ -82,13 +88,16 @@ func (s descriptorHandler) compareDescriptors(category Category, isEmbedded bool } //lint:ignore ST1006 this vs other makes it easier to reason about. -func (this TemplateDescriptor) doCompare(category Category, isEmbedded bool, defaultContentLanguage string, other TemplateDescriptor) weight { +func (this TemplateDescriptor) doCompare(category Category, defaultContentLanguage string, 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 !this.AlwaysAllowPlainText { + // 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 } diff --git a/tpl/tplimpl/templatestore.go b/tpl/tplimpl/templatestore.go index c6a6d4cd5..2ea337274 100644 --- a/tpl/tplimpl/templatestore.go +++ b/tpl/tplimpl/templatestore.go @@ -19,6 +19,7 @@ import ( "bytes" "context" "embed" + "errors" "fmt" "io" "io/fs" @@ -608,7 +609,7 @@ func (s *TemplateStore) LookupShortcodeByName(name string) *TemplInfo { return ti } -func (s *TemplateStore) LookupShortcode(q TemplateQuery) *TemplInfo { +func (s *TemplateStore) LookupShortcode(q TemplateQuery) (*TemplInfo, error) { q.init() k1 := s.key(q.Path) @@ -630,13 +631,15 @@ func (s *TemplateStore) LookupShortcode(q TemplateQuery) *TemplInfo { } for k, vv := range v { + best.candidates = append(best.candidates, vv) if !q.Consider(vv) { continue } weight := s.dh.compareDescriptors(q.Category, vv.subCategory == SubCategoryEmbedded, q.Desc, k) weight.distance = distance - if best.isBetter(weight, vv) { + isBetter := best.isBetter(weight, vv) + if isBetter { best.updateValues(weight, k2, k, vv) } } @@ -644,8 +647,21 @@ func (s *TemplateStore) LookupShortcode(q TemplateQuery) *TemplInfo { return false, nil }) - // Any match will do. - return best.templ + if best.w.w1 <= 0 { + var err error + if s := best.candidatesAsStringSlice(); s != nil { + msg := fmt.Sprintf("no compatible template found for shortcode %q in %s", q.Name, s) + if !q.Desc.IsPlainText { + msg += "; note that to use plain text template shortcodes in HTML you need to use the shortcode {{% delimiter" + } + err = errors.New(msg) + } else { + err = fmt.Errorf("no template found for shortcode %q", q.Name) + } + return nil, err + } + + return best.templ, nil } // PrintDebug is for testing/debugging only. @@ -1817,10 +1833,11 @@ type TextTemplatHandler interface { } type bestMatch struct { - templ *TemplInfo - desc TemplateDescriptor - w weight - key string + templ *TemplInfo + desc TemplateDescriptor + w weight + key string + candidates []*TemplInfo // settings. defaultOutputformat string @@ -1831,6 +1848,18 @@ func (best *bestMatch) reset() { best.w = weight{} best.desc = TemplateDescriptor{} best.key = "" + best.candidates = nil +} + +func (best *bestMatch) candidatesAsStringSlice() []string { + if len(best.candidates) == 0 { + return nil + } + candidates := make([]string, len(best.candidates)) + for i, v := range best.candidates { + candidates[i] = v.PathInfo.Path() + } + return candidates } func (best *bestMatch) isBetter(w weight, ti *TemplInfo) bool { @@ -1840,7 +1869,6 @@ func (best *bestMatch) isBetter(w weight, ti *TemplInfo) bool { } if w.w1 <= 0 { - if best.w.w1 <= 0 { return ti.PathInfo.Path() < best.templ.PathInfo.Path() } diff --git a/tpl/tplimpl/templatestore_integration_test.go b/tpl/tplimpl/templatestore_integration_test.go index e10d7149a..0b3ce7a56 100644 --- a/tpl/tplimpl/templatestore_integration_test.go +++ b/tpl/tplimpl/templatestore_integration_test.go @@ -920,6 +920,26 @@ func TestPartialHTML(t *testing.T) { b.AssertFileContent("public/index.html", "") } +func TestPartialPlainTextInHTML(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/all.html -- + + +{{ partial "mypartial.txt" . }} + + +-- layouts/partials/mypartial.txt -- +My
partial
. +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "My <div>partial</div>.") +} + // Issue #13593. func TestGoatAndNoGoat(t *testing.T) { t.Parallel() @@ -1103,6 +1123,18 @@ All. b.AssertLogContains("unrecognized render hook") } +func TestLayoutNotFound(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/single.html -- +Single. +` + b := hugolib.Test(t, files, hugolib.TestOptWarn()) + b.AssertLogContains("WARN found no layout file for \"html\" for kind \"home\"") +} + func TestLayoutOverrideThemeWhenThemeOnOldFormatIssue13715(t *testing.T) { t.Parallel() @@ -1214,8 +1246,8 @@ s2. Category: tplimpl.CategoryShortcode, Desc: desc, } - v := store.LookupShortcode(q) - if v == nil { + v, err := store.LookupShortcode(q) + if v == nil || err != nil { b.Fatal("not found") } }