Make .RenderString render shortcodes

Fixes #6703
This commit is contained in:
Bjørn Erik Pedersen 2022-05-29 16:41:57 +02:00
parent d2cfaede5b
commit 9e904d756b
No known key found for this signature in database
GPG key ID: 330E6E2BD4859D8F
10 changed files with 315 additions and 111 deletions

View file

@ -64,11 +64,12 @@ func Puts(s string) string {
// VisitLinesAfter calls the given function for each line, including newlines, in the given string.
func VisitLinesAfter(s string, fn func(line string)) {
high := strings.Index(s, "\n")
high := strings.IndexRune(s, '\n')
for high != -1 {
fn(s[:high+1])
s = s[high+1:]
high = strings.Index(s, "\n")
high = strings.IndexRune(s, '\n')
}
if s != "" {

View file

@ -59,3 +59,18 @@ line 3`
c.Assert(collected, qt.DeepEquals, []string{"line 1\n", "line 2\n", "\n", "line 3"})
}
func BenchmarkVisitLinesAfter(b *testing.B) {
const lines = `line 1
line 2
line 3`
for i := 0; i < b.N; i++ {
VisitLinesAfter(lines, func(s string) {
})
}
}

View file

@ -163,7 +163,7 @@ func (m *pageMap) newPageFromContentNode(n *contentNode, parentBucket *pagesMapB
},
}
ps.shortcodeState = newShortcodeHandler(ps, ps.s, nil)
ps.shortcodeState = newShortcodeHandler(ps, ps.s)
if err := ps.mapContent(parentBucket, metaProvider); err != nil {
return nil, ps.wrapError(err)

View file

@ -18,7 +18,6 @@ import (
"testing"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/common/loggers"
)
func TestRenderHookEditNestedPartial(t *testing.T) {
@ -428,72 +427,3 @@ Image:
<p>html-image: image.jpg|Text: Hello<br> Goodbye|Plain: Hello GoodbyeEND</p>
`)
}
func TestRenderString(t *testing.T) {
b := newTestSitesBuilder(t)
b.WithTemplates("index.html", `
{{ $p := site.GetPage "p1.md" }}
{{ $optBlock := dict "display" "block" }}
{{ $optOrg := dict "markup" "org" }}
RSTART:{{ "**Bold Markdown**" | $p.RenderString }}:REND
RSTART:{{ "**Bold Block Markdown**" | $p.RenderString $optBlock }}:REND
RSTART:{{ "/italic org mode/" | $p.RenderString $optOrg }}:REND
RSTART:{{ "## Header2" | $p.RenderString }}:REND
`, "_default/_markup/render-heading.html", "Hook Heading: {{ .Level }}")
b.WithContent("p1.md", `---
title: "p1"
---
`,
)
b.Build(BuildCfg{})
b.AssertFileContent("public/index.html", `
RSTART:<strong>Bold Markdown</strong>:REND
RSTART:<p><strong>Bold Block Markdown</strong></p>
RSTART:<em>italic org mode</em>:REND
RSTART:Hook Heading: 2:REND
`)
}
// https://github.com/gohugoio/hugo/issues/6882
func TestRenderStringOnListPage(t *testing.T) {
renderStringTempl := `
{{ .RenderString "**Hello**" }}
`
b := newTestSitesBuilder(t)
b.WithContent("mysection/p1.md", `FOO`)
b.WithTemplates(
"index.html", renderStringTempl,
"_default/list.html", renderStringTempl,
"_default/single.html", renderStringTempl,
)
b.Build(BuildCfg{})
for _, filename := range []string{
"index.html",
"mysection/index.html",
"categories/index.html",
"tags/index.html",
"mysection/p1/index.html",
} {
b.AssertFileContent("public/"+filename, `<strong>Hello</strong>`)
}
}
// Issue 9433
func TestRenderStringOnPageNotBackedByAFile(t *testing.T) {
t.Parallel()
logger := loggers.NewWarningLogger()
b := newTestSitesBuilder(t).WithLogger(logger).WithConfigFile("toml", `
disableKinds = ["page", "section", "taxonomy", "term"]
`)
b.WithTemplates("index.html", `{{ .RenderString "**Hello**" }}`).WithContent("p1.md", "")
b.BuildE(BuildCfg{})
b.Assert(int(logger.LogCounters().WarnCounter.Count()), qt.Equals, 0)
}

View file

@ -336,7 +336,7 @@ func (p *pageState) HasShortcode(name string) bool {
return false
}
return p.shortcodeState.nameSet[name]
return p.shortcodeState.hasName(name)
}
func (p *pageState) Site() page.Site {
@ -610,13 +610,30 @@ func (p *pageState) getContentConverter() converter.Converter {
}
func (p *pageState) mapContent(bucket *pagesMapBucket, meta *pageMeta) error {
s := p.shortcodeState
rn := &pageContentMap{
p.cmap = &pageContentMap{
items: make([]any, 0, 20),
}
iter := p.source.parsed.Iterator()
return p.mapContentForResult(
p.source.parsed,
p.shortcodeState,
p.cmap,
meta.markup,
func(m map[string]interface{}) error {
return meta.setMetadata(bucket, p, m)
},
)
}
func (p *pageState) mapContentForResult(
result pageparser.Result,
s *shortcodeHandler,
rn *pageContentMap,
markup string,
withFrontMatter func(map[string]any) error,
) error {
iter := result.Iterator()
fail := func(err error, i pageparser.Item) error {
if fe, ok := err.(herrors.FileError); ok {
@ -660,8 +677,10 @@ Loop:
}
}
if err := meta.setMetadata(bucket, p, m); err != nil {
return err
if withFrontMatter != nil {
if err := withFrontMatter(m); err != nil {
return err
}
}
frontMatterSet = true
@ -697,7 +716,7 @@ Loop:
p.source.posBodyStart = posBody
p.source.hasSummaryDivider = true
if meta.markup != "html" {
if markup != "html" {
// The content will be rendered by Goldmark or similar,
// and we need to track the summary.
rn.AddReplacement(internalSummaryDividerPre, it)
@ -720,7 +739,7 @@ Loop:
}
if currShortcode.name != "" {
s.nameSet[currShortcode.name] = true
s.addName(currShortcode.name)
}
if currShortcode.params == nil {
@ -752,16 +771,14 @@ Loop:
}
}
if !frontMatterSet {
if !frontMatterSet && withFrontMatter != nil {
// Page content without front matter. Assign default front matter from
// cascades etc.
if err := meta.setMetadata(bucket, p, nil); err != nil {
if err := withFrontMatter(nil); err != nil {
return err
}
}
p.cmap = rn
return nil
}

View file

@ -39,12 +39,12 @@ type pageContent struct {
}
// returns the content to be processed by Goldmark or similar.
func (p pageContent) contentToRender(renderedShortcodes map[string]string) []byte {
source := p.source.parsed.Input()
func (p pageContent) contentToRender(parsed pageparser.Result, pm *pageContentMap, renderedShortcodes map[string]string) []byte {
source := parsed.Input()
c := make([]byte, 0, len(source)+(len(source)/10))
for _, it := range p.cmap.items {
for _, it := range pm.items {
switch v := it.(type) {
case pageparser.Item:
c = append(c, source[v.Pos:v.Pos+len(v.Val)]...)

View file

@ -25,9 +25,11 @@ import (
"errors"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/common/text"
"github.com/gohugoio/hugo/common/types/hstring"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/parser/pageparser"
"github.com/mitchellh/mapstructure"
"github.com/spf13/cast"
@ -117,7 +119,7 @@ func newPageContentOutput(p *pageState, po *pageOutput) (*pageContentOutput, err
p.pageOutputTemplateVariationsState.Store(2)
}
cp.workContent = p.contentToRender(cp.contentPlaceholders)
cp.workContent = p.contentToRender(p.source.parsed, p.cmap, cp.contentPlaceholders)
isHTML := cp.p.m.markup == "html"
@ -332,11 +334,12 @@ func (p *pageContentOutput) WordCount() int {
}
func (p *pageContentOutput) RenderString(args ...any) (template.HTML, error) {
defer herrors.Recover()
if len(args) < 1 || len(args) > 2 {
return "", errors.New("want 1 or 2 arguments")
}
var s string
var contentToRender string
opts := defaultRenderStringOpts
sidx := 1
@ -353,16 +356,16 @@ func (p *pageContentOutput) RenderString(args ...any) (template.HTML, error) {
}
}
contentToRender := args[sidx]
contentToRenderv := args[sidx]
if _, ok := contentToRender.(hstring.RenderedString); ok {
if _, ok := contentToRenderv.(hstring.RenderedString); ok {
// This content is already rendered, this is potentially
// a infinite recursion.
return "", errors.New("text is already rendered, repeating it may cause infinite recursion")
}
var err error
s, err = cast.ToStringE(contentToRender)
contentToRender, err = cast.ToStringE(contentToRenderv)
if err != nil {
return "", err
}
@ -381,20 +384,79 @@ func (p *pageContentOutput) RenderString(args ...any) (template.HTML, error) {
}
}
c, err := p.renderContentWithConverter(conv, []byte(s), false)
if err != nil {
return "", p.p.wrapError(err)
}
var rendered []byte
b := c.Bytes()
if strings.Contains(contentToRender, "{{") {
// Probably a shortcode.
parsed, err := pageparser.ParseMain(strings.NewReader(contentToRender), pageparser.Config{})
if err != nil {
return "", err
}
pm := &pageContentMap{
items: make([]any, 0, 20),
}
s := newShortcodeHandler(p.p, p.p.s)
if err := p.p.mapContentForResult(
parsed,
s,
pm,
opts.Markup,
nil,
); err != nil {
return "", err
}
placeholders, hasShortcodeVariants, err := s.renderShortcodesForPage(p.p, p.f)
if err != nil {
return "", err
}
if hasShortcodeVariants {
p.p.pageOutputTemplateVariationsState.Store(2)
}
b, err := p.renderContentWithConverter(conv, p.p.contentToRender(parsed, pm, placeholders), false)
if err != nil {
return "", p.p.wrapError(err)
}
rendered = b.Bytes()
if p.placeholdersEnabled {
// ToC was accessed via .Page.TableOfContents in the shortcode,
// at a time when the ToC wasn't ready.
if _, err := p.p.Content(); err != nil {
return "", err
}
placeholders[tocShortcodePlaceholder] = string(p.tableOfContents)
}
if pm.hasNonMarkdownShortcode || p.placeholdersEnabled {
rendered, err = replaceShortcodeTokens(rendered, placeholders)
if err != nil {
return "", err
}
}
// We need a consolidated view in $page.HasShortcode
p.p.shortcodeState.transferNames(s)
} else {
c, err := p.renderContentWithConverter(conv, []byte(contentToRender), false)
if err != nil {
return "", p.p.wrapError(err)
}
rendered = c.Bytes()
}
if opts.Display == "inline" {
// We may have to rethink this in the future when we get other
// renderers.
b = p.p.s.ContentSpec.TrimShortHTML(b)
rendered = p.p.s.ContentSpec.TrimShortHTML(rendered)
}
return template.HTML(string(b)), nil
return template.HTML(string(rendered)), nil
}
func (p *pageContentOutput) RenderWithTemplateInfo(info tpl.Info, layout ...string) (template.HTML, error) {

View file

@ -0,0 +1,162 @@
// Copyright 2022 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 requiredF by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package hugolib
import (
"testing"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/common/loggers"
)
func TestRenderString(t *testing.T) {
b := newTestSitesBuilder(t)
b.WithTemplates("index.html", `
{{ $p := site.GetPage "p1.md" }}
{{ $optBlock := dict "display" "block" }}
{{ $optOrg := dict "markup" "org" }}
RSTART:{{ "**Bold Markdown**" | $p.RenderString }}:REND
RSTART:{{ "**Bold Block Markdown**" | $p.RenderString $optBlock }}:REND
RSTART:{{ "/italic org mode/" | $p.RenderString $optOrg }}:REND
RSTART:{{ "## Header2" | $p.RenderString }}:REND
`, "_default/_markup/render-heading.html", "Hook Heading: {{ .Level }}")
b.WithContent("p1.md", `---
title: "p1"
---
`,
)
b.Build(BuildCfg{})
b.AssertFileContent("public/index.html", `
RSTART:<strong>Bold Markdown</strong>:REND
RSTART:<p><strong>Bold Block Markdown</strong></p>
RSTART:<em>italic org mode</em>:REND
RSTART:Hook Heading: 2:REND
`)
}
// https://github.com/gohugoio/hugo/issues/6882
func TestRenderStringOnListPage(t *testing.T) {
renderStringTempl := `
{{ .RenderString "**Hello**" }}
`
b := newTestSitesBuilder(t)
b.WithContent("mysection/p1.md", `FOO`)
b.WithTemplates(
"index.html", renderStringTempl,
"_default/list.html", renderStringTempl,
"_default/single.html", renderStringTempl,
)
b.Build(BuildCfg{})
for _, filename := range []string{
"index.html",
"mysection/index.html",
"categories/index.html",
"tags/index.html",
"mysection/p1/index.html",
} {
b.AssertFileContent("public/"+filename, `<strong>Hello</strong>`)
}
}
// Issue 9433
func TestRenderStringOnPageNotBackedByAFile(t *testing.T) {
t.Parallel()
logger := loggers.NewWarningLogger()
b := newTestSitesBuilder(t).WithLogger(logger).WithConfigFile("toml", `
disableKinds = ["page", "section", "taxonomy", "term"]
`)
b.WithTemplates("index.html", `{{ .RenderString "**Hello**" }}`).WithContent("p1.md", "")
b.BuildE(BuildCfg{})
b.Assert(int(logger.LogCounters().WarnCounter.Count()), qt.Equals, 0)
}
func TestRenderStringWithShortcode(t *testing.T) {
t.Parallel()
filesTemplate := `
-- config.toml --
title = "Hugo Rocks!"
enableInlineShortcodes = true
-- content/p1/index.md --
---
title: "P1"
---
## First
-- layouts/shortcodes/mark1.md --
{{ .Inner }}
-- layouts/shortcodes/mark2.md --
1. Item Mark2 1
1. Item Mark2 2
1. Item Mark2 2-1
1. Item Mark2 3
-- layouts/shortcodes/myhthml.html --
Title: {{ .Page.Title }}
TableOfContents: {{ .Page.TableOfContents }}
Page Type: {{ printf "%T" .Page }}
-- layouts/_default/single.html --
{{ .RenderString "Markdown: {{% mark2 %}}|HTML: {{< myhthml >}}|Inline: {{< foo.inline >}}{{ site.Title }}{{< /foo.inline >}}|" }}
HasShortcode: mark2:{{ .HasShortcode "mark2" }}:true
HasShortcode: foo:{{ .HasShortcode "foo" }}:false
`
t.Run("Basic", func(t *testing.T) {
b := NewIntegrationTestBuilder(
IntegrationTestConfig{
T: t,
TxtarString: filesTemplate,
},
).Build()
b.AssertFileContent("public/p1/index.html",
"<p>Markdown: 1. Item Mark2 1</p>\n<ol>\n<li>Item Mark2 2\n<ol>\n<li>Item Mark2 2-1</li>\n</ol>\n</li>\n<li>Item Mark2 3|",
"<a href=\"#first\">First</a>", // ToC
`
HTML: Title: P1
Inline: Hugo Rocks!
HasShortcode: mark2:true:true
HasShortcode: foo:false:false
Page Type: *hugolib.pageForShortcode`,
)
})
t.Run("Edit shortcode", func(t *testing.T) {
b := NewIntegrationTestBuilder(
IntegrationTestConfig{
T: t,
TxtarString: filesTemplate,
Running: true,
},
).Build()
b.EditFiles("layouts/shortcodes/myhthml.html", "Edit shortcode").Build()
b.AssertFileContent("public/p1/index.html",
`Edit shortcode`,
)
})
}

View file

@ -250,13 +250,14 @@ type shortcodeHandler struct {
shortcodes []*shortcode
// All the shortcode names in this set.
nameSet map[string]bool
nameSet map[string]bool
nameSetMu sync.RWMutex
// Configuration
enableInlineShortcodes bool
}
func newShortcodeHandler(p *pageState, s *Site, placeholderFunc func() string) *shortcodeHandler {
func newShortcodeHandler(p *pageState, s *Site) *shortcodeHandler {
sh := &shortcodeHandler{
p: p,
s: s,
@ -423,6 +424,28 @@ func (s *shortcodeHandler) hasShortcodes() bool {
return s != nil && len(s.shortcodes) > 0
}
func (s *shortcodeHandler) addName(name string) {
s.nameSetMu.Lock()
defer s.nameSetMu.Unlock()
s.nameSet[name] = true
}
func (s *shortcodeHandler) transferNames(in *shortcodeHandler) {
s.nameSetMu.Lock()
defer s.nameSetMu.Unlock()
for k := range in.nameSet {
s.nameSet[k] = true
}
}
func (s *shortcodeHandler) hasName(name string) bool {
s.nameSetMu.RLock()
defer s.nameSetMu.RUnlock()
_, ok := s.nameSet[name]
return ok
}
func (s *shortcodeHandler) renderShortcodesForPage(p *pageState, f output.Format) (map[string]string, bool, error) {
rendered := make(map[string]string)
@ -503,7 +526,7 @@ Loop:
nested, err := s.extractShortcode(nestedOrdinal, nextLevel, pt)
nestedOrdinal++
if nested != nil && nested.name != "" {
s.nameSet[nested.name] = true
s.addName(nested.name)
}
if err == nil {

View file

@ -107,15 +107,9 @@ title: "Shortcodes Galore!"
t.Parallel()
c := qt.New(t)
counter := 0
placeholderFunc := func() string {
counter++
return fmt.Sprintf("HAHA%s-%dHBHB", shortcodePlaceholderPrefix, counter)
}
p, err := pageparser.ParseMain(strings.NewReader(test.input), pageparser.Config{})
c.Assert(err, qt.IsNil)
handler := newShortcodeHandler(nil, s, placeholderFunc)
handler := newShortcodeHandler(nil, s)
iter := p.Iterator()
short, err := handler.extractShortcode(0, 0, iter)