hugo/hugolib/shortcode.go
Bjørn Erik Pedersen 61317821e4 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
2025-05-18 12:48:24 +02:00

795 lines
21 KiB
Go

// 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 hugolib
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"path"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"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"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/common/text"
"github.com/gohugoio/hugo/common/urls"
bp "github.com/gohugoio/hugo/bufferpool"
"github.com/gohugoio/hugo/tpl"
)
var (
_ urls.RefLinker = (*ShortcodeWithPage)(nil)
_ types.Unwrapper = (*ShortcodeWithPage)(nil)
_ text.Positioner = (*ShortcodeWithPage)(nil)
_ maps.StoreProvider = (*ShortcodeWithPage)(nil)
)
// ShortcodeWithPage is the "." context in a shortcode template.
type ShortcodeWithPage struct {
Params any
Inner template.HTML
Page page.Page
Parent *ShortcodeWithPage
Name string
IsNamedParams bool
// Zero-based ordinal in relation to its parent. If the parent is the page itself,
// this ordinal will represent the position of this shortcode in the page content.
Ordinal int
// Indentation before the opening shortcode in the source.
indentation string
innerDeindentInit sync.Once
innerDeindent template.HTML
// pos is the position in bytes in the source file. Used for error logging.
posInit sync.Once
posOffset int
pos text.Position
store *maps.Scratch
}
// InnerDeindent returns the (potentially de-indented) inner content of the shortcode.
func (scp *ShortcodeWithPage) InnerDeindent() template.HTML {
if scp.indentation == "" {
return scp.Inner
}
scp.innerDeindentInit.Do(func() {
b := bp.GetBuffer()
text.VisitLinesAfter(string(scp.Inner), func(s string) {
if strings.HasPrefix(s, scp.indentation) {
b.WriteString(strings.TrimPrefix(s, scp.indentation))
} else {
b.WriteString(s)
}
})
scp.innerDeindent = template.HTML(b.String())
bp.PutBuffer(b)
})
return scp.innerDeindent
}
// Position returns this shortcode's detailed position. Note that this information
// may be expensive to calculate, so only use this in error situations.
func (scp *ShortcodeWithPage) Position() text.Position {
scp.posInit.Do(func() {
if p, ok := mustUnwrapPage(scp.Page).(pageContext); ok {
scp.pos = p.posOffset(scp.posOffset)
}
})
return scp.pos
}
// Site returns information about the current site.
func (scp *ShortcodeWithPage) Site() page.Site {
return scp.Page.Site()
}
// Ref is a shortcut to the Ref method on Page. It passes itself as a context
// to get better error messages.
func (scp *ShortcodeWithPage) Ref(args map[string]any) (string, error) {
return scp.Page.RefFrom(args, scp)
}
// RelRef is a shortcut to the RelRef method on Page. It passes itself as a context
// to get better error messages.
func (scp *ShortcodeWithPage) RelRef(args map[string]any) (string, error) {
return scp.Page.RelRefFrom(args, scp)
}
// Store returns this shortcode's Store.
func (scp *ShortcodeWithPage) Store() *maps.Scratch {
if scp.store == nil {
scp.store = maps.NewScratch()
}
return scp.store
}
// Scratch returns a scratch-pad scoped for this shortcode. This can be used
// as a temporary storage for variables, counters etc.
// Deprecated: Use Store instead. Note that from the templates this should be considered a "soft deprecation".
func (scp *ShortcodeWithPage) Scratch() *maps.Scratch {
return scp.Store()
}
// Get is a convenience method to look up shortcode parameters by its key.
func (scp *ShortcodeWithPage) Get(key any) any {
if scp.Params == nil {
return nil
}
if reflect.ValueOf(scp.Params).Len() == 0 {
return nil
}
var x reflect.Value
switch key.(type) {
case int64, int32, int16, int8, int:
if reflect.TypeOf(scp.Params).Kind() == reflect.Map {
// We treat this as a non error, so people can do similar to
// {{ $myParam := .Get "myParam" | default .Get 0 }}
// Without having to do additional checks.
return nil
} else if reflect.TypeOf(scp.Params).Kind() == reflect.Slice {
idx := int(reflect.ValueOf(key).Int())
ln := reflect.ValueOf(scp.Params).Len()
if idx > ln-1 {
return ""
}
x = reflect.ValueOf(scp.Params).Index(idx)
}
case string:
if reflect.TypeOf(scp.Params).Kind() == reflect.Map {
x = reflect.ValueOf(scp.Params).MapIndex(reflect.ValueOf(key))
if !x.IsValid() {
return ""
}
} else if reflect.TypeOf(scp.Params).Kind() == reflect.Slice {
// We treat this as a non error, so people can do similar to
// {{ $myParam := .Get "myParam" | default .Get 0 }}
// Without having to do additional checks.
return nil
}
}
return x.Interface()
}
// For internal use only.
func (scp *ShortcodeWithPage) Unwrapv() any {
return scp.Page
}
// Note - this value must not contain any markup syntax
const shortcodePlaceholderPrefix = "HAHAHUGOSHORTCODE"
func createShortcodePlaceholder(sid string, id uint64, ordinal int) string {
return shortcodePlaceholderPrefix + strconv.FormatUint(id, 10) + sid + strconv.Itoa(ordinal) + "HBHB"
}
type shortcode struct {
name string
isInline bool // inline shortcode. Any inner will be a Go template.
isClosing bool // whether a closing tag was provided
inner []any // string or nested shortcode
params any // map or array
ordinal int
indentation string // indentation from source.
templ *tplimpl.TemplInfo
// If set, the rendered shortcode is sent as part of the surrounding content
// to Goldmark and similar.
// Before Hug0 0.55 we didn't send any shortcode output to the markup
// renderer, and this flag told Hugo to process the {{ .Inner }} content
// separately.
// The old behavior can be had by starting your shortcode template with:
// {{ $_hugo_config := `{ "version": 1 }`}}
doMarkup bool
// the placeholder in the source when passed to Goldmark etc.
// This also identifies the rendered shortcode.
placeholder string
pos int // the position in bytes in the source file
length int // the length in bytes in the source file
}
func (s shortcode) insertPlaceholder() bool {
return !s.doMarkup || s.configVersion() == 1
}
func (s shortcode) needsInner() bool {
return s.templ != nil && s.templ.ParseInfo.IsInner
}
func (s shortcode) configVersion() int {
if s.templ == nil {
// Not set for inline shortcodes.
return 2
}
return s.templ.ParseInfo.Config.Version
}
func (s shortcode) innerString() string {
var sb strings.Builder
for _, inner := range s.inner {
sb.WriteString(inner.(string))
}
return sb.String()
}
func (sc shortcode) String() string {
// for testing (mostly), so any change here will break tests!
var params any
switch v := sc.params.(type) {
case map[string]any:
// sort the keys so test assertions won't fail
var keys []string
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
tmp := make(map[string]any)
for _, k := range keys {
tmp[k] = v[k]
}
params = tmp
default:
// use it as is
params = sc.params
}
return fmt.Sprintf("%s(%q, %t){%s}", sc.name, params, sc.doMarkup, sc.inner)
}
type shortcodeHandler struct {
filename string
s *Site
// Ordered list of shortcodes for a page.
shortcodes []*shortcode
// All the shortcode names in this set.
nameSet map[string]bool
nameSetMu sync.RWMutex
// Configuration
enableInlineShortcodes bool
}
func newShortcodeHandler(filename string, s *Site) *shortcodeHandler {
sh := &shortcodeHandler{
filename: filename,
s: s,
enableInlineShortcodes: s.ExecHelper.Sec().EnableInlineShortcodes,
shortcodes: make([]*shortcode, 0, 4),
nameSet: make(map[string]bool),
}
return sh
}
const (
innerNewlineRegexp = "\n"
innerCleanupRegexp = `\A<p>(.*)</p>\n\z`
innerCleanupExpand = "$1"
)
func prepareShortcode(
ctx context.Context,
level int,
s *Site,
sc *shortcode,
parent *ShortcodeWithPage,
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)
}
// Allow the caller to delay the rendering of the shortcode if needed.
var fn shortcodeRenderFunc = func(ctx context.Context) ([]byte, bool, error) {
if p.m.pageConfig.ContentMediaType.IsMarkdown() && sc.doMarkup {
// Signal downwards that the content rendered will be
// parsed and rendered by Goldmark.
ctx = tpl.Context.IsInGoldmark.Set(ctx, true)
}
r, err := doRenderShortcode(ctx, level, s, sc, parent, po, isRenderString)
if err != nil {
return nil, false, toParseErr(err)
}
b, hasVariants, err := r.renderShortcode(ctx)
if err != nil {
return nil, false, toParseErr(err)
}
return b, hasVariants, nil
}
return fn, nil
}
func doRenderShortcode(
ctx context.Context,
level int,
s *Site,
sc *shortcode,
parent *ShortcodeWithPage,
po *pageOutput,
isRenderString bool,
) (shortcodeRenderer, error) {
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.
var hasVariants bool
if sc.isInline {
if !p.s.ExecHelper.Sec().EnableInlineShortcodes {
return zeroShortcode, nil
}
templatePath := path.Join("_inline_shortcode", p.Path(), sc.name)
if sc.isClosing {
templStr := sc.innerString()
var err error
tmpl, err = s.TemplateStore.TextParse(templatePath, templStr)
if err != nil {
if isRenderString {
return zeroShortcode, p.wrapError(err)
}
fe := herrors.NewFileErrorFromName(err, p.File().Filename())
pos := fe.Position()
pos.LineNumber += p.posOffset(sc.pos).LineNumber
fe = fe.UpdatePosition(pos)
return zeroShortcode, p.wrapError(fe)
}
} else {
// Re-use of shortcode defined earlier in the same page.
tmpl = s.TemplateStore.TextLookup(templatePath)
if tmpl == nil {
return zeroShortcode, fmt.Errorf("no earlier definition of shortcode %q found", sc.name)
}
}
} else {
ofCount := map[string]int{}
include := func(match *tplimpl.TemplInfo) bool {
ofCount[match.D.OutputFormat]++
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,
Category: tplimpl.CategoryShortcode,
Desc: layoutDescriptor,
Consider: include,
}
v, err := s.TemplateStore.LookupShortcode(q)
if v == nil {
return zeroShortcode, err
}
tmpl = v
hasVariants = hasVariants || len(ofCount) > 1
}
data := &ShortcodeWithPage{
Ordinal: sc.ordinal,
posOffset: sc.pos,
indentation: sc.indentation,
Params: sc.params,
Page: newPageForShortcode(p),
Parent: parent,
Name: sc.name,
}
if sc.params != nil {
data.IsNamedParams = reflect.TypeOf(sc.params).Kind() == reflect.Map
}
if len(sc.inner) > 0 {
var inner string
for _, innerData := range sc.inner {
switch innerData := innerData.(type) {
case string:
inner += innerData
case *shortcode:
s, err := prepareShortcode(ctx, level+1, s, innerData, data, po, isRenderString)
if err != nil {
return zeroShortcode, err
}
ss, more, err := s.renderShortcodeString(ctx)
hasVariants = hasVariants || more
if err != nil {
return zeroShortcode, err
}
inner += ss
default:
s.Log.Errorf("Illegal state on shortcode rendering of %q in page %q. Illegal type in inner data: %s ",
sc.name, p.File().Path(), reflect.TypeOf(innerData))
return zeroShortcode, nil
}
}
// Pre Hugo 0.55 this was the behavior even for the outer-most
// shortcode.
if sc.doMarkup && (level > 0 || sc.configVersion() == 1) {
var err error
b, err := p.pageOutput.contentRenderer.ParseAndRenderContent(ctx, []byte(inner), false)
if err != nil {
return zeroShortcode, err
}
newInner := b.Bytes()
// If the type is “” (unknown) or “markdown”, we assume the markdown
// generation has been performed. Given the input: `a line`, markdown
// specifies the HTML `<p>a line</p>\n`. When dealing with documents as a
// whole, this is OK. When dealing with an `{{ .Inner }}` block in Hugo,
// this is not so good. This code does two things:
//
// 1. Check to see if inner has a newline in it. If so, the Inner data is
// unchanged.
// 2 If inner does not have a newline, strip the wrapping <p> block and
// the newline.
switch p.m.pageConfig.Content.Markup {
case "", "markdown":
if match, _ := regexp.MatchString(innerNewlineRegexp, inner); !match {
cleaner, err := regexp.Compile(innerCleanupRegexp)
if err == nil {
newInner = cleaner.ReplaceAll(newInner, []byte(innerCleanupExpand))
}
}
}
// TODO(bep) we may have plain text inner templates.
data.Inner = template.HTML(newInner)
} else {
data.Inner = template.HTML(inner)
}
}
result, err := renderShortcodeWithPage(ctx, s.GetTemplateStore(), tmpl, data)
if err != nil && sc.isInline {
fe := herrors.NewFileErrorFromName(err, p.File().Filename())
pos := fe.Position()
pos.LineNumber += p.posOffset(sc.pos).LineNumber
fe = fe.UpdatePosition(pos)
return zeroShortcode, fe
}
if len(sc.inner) == 0 && len(sc.indentation) > 0 {
b := bp.GetBuffer()
i := 0
text.VisitLinesAfter(result, func(line string) {
// The first line is correctly indented.
if i > 0 {
b.WriteString(sc.indentation)
}
i++
b.WriteString(line)
})
result = b.String()
bp.PutBuffer(b)
}
return prerenderedShortcode{s: result, hasVariants: hasVariants}, err
}
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) prepareShortcodesForPage(ctx context.Context, po *pageOutput, isRenderString bool) (map[string]shortcodeRenderer, error) {
rendered := make(map[string]shortcodeRenderer)
for _, v := range s.shortcodes {
s, err := prepareShortcode(ctx, 0, s.s, v, nil, po, isRenderString)
if err != nil {
return nil, err
}
rendered[v.placeholder] = s
}
return rendered, nil
}
func posFromInput(filename string, input []byte, offset int) text.Position {
if offset < 0 {
return text.Position{
Filename: filename,
}
}
lf := []byte("\n")
input = input[:offset]
lineNumber := bytes.Count(input, lf) + 1
endOfLastLine := bytes.LastIndex(input, lf)
return text.Position{
Filename: filename,
LineNumber: lineNumber,
ColumnNumber: offset - endOfLastLine,
Offset: offset,
}
}
// pageTokens state:
// - before: positioned just before the shortcode start
// - after: shortcode(s) consumed (plural when they are nested)
func (s *shortcodeHandler) extractShortcode(ordinal, level int, source []byte, pt *pageparser.Iterator) (*shortcode, error) {
if s == nil {
panic("handler nil")
}
sc := &shortcode{ordinal: ordinal}
// Back up one to identify any indentation.
if pt.Pos() > 0 {
pt.Backup()
item := pt.Next()
if item.IsIndentation() {
sc.indentation = item.ValStr(source)
}
}
cnt := 0
nestedOrdinal := 0
nextLevel := level + 1
closed := false
const errorPrefix = "failed to extract shortcode"
Loop:
for {
currItem := pt.Next()
switch {
case currItem.IsLeftShortcodeDelim():
next := pt.Peek()
if next.IsRightShortcodeDelim() {
// no name: {{< >}} or {{% %}}
return sc, errors.New("shortcode has no name")
}
if next.IsShortcodeClose() {
continue
}
if cnt > 0 {
// nested shortcode; append it to inner content
pt.Backup()
nested, err := s.extractShortcode(nestedOrdinal, nextLevel, source, pt)
nestedOrdinal++
if nested != nil && nested.name != "" {
s.addName(nested.name)
}
if err == nil {
sc.inner = append(sc.inner, nested)
} else {
return sc, err
}
} else {
sc.doMarkup = currItem.IsShortcodeMarkupDelimiter()
}
cnt++
case currItem.IsRightShortcodeDelim():
// we trust the template on this:
// if there's no inner, we're done
if !sc.isInline {
if !sc.templ.ParseInfo.IsInner {
return sc, nil
}
}
case currItem.IsShortcodeClose():
closed = true
next := pt.Peek()
if !sc.isInline {
if !sc.needsInner() {
if next.IsError() {
// return that error, more specific
continue
}
name := sc.name
if name == "" {
name = next.ValStr(source)
}
return nil, fmt.Errorf("%s: shortcode %q does not evaluate .Inner or .InnerDeindent, yet a closing tag was provided", errorPrefix, name)
}
}
if next.IsRightShortcodeDelim() {
// self-closing
pt.Consume(1)
} else {
sc.isClosing = true
pt.Consume(2)
}
return sc, nil
case currItem.IsText():
sc.inner = append(sc.inner, currItem.ValStr(source))
case currItem.IsShortcodeName():
sc.name = currItem.ValStr(source)
// Used to check if the template expects inner content,
// so just pick one arbitrarily with the same name.
templ := s.s.TemplateStore.LookupShortcodeByName(sc.name)
if templ == nil {
return nil, fmt.Errorf("%s: template for shortcode %q not found", errorPrefix, sc.name)
}
sc.templ = templ
case currItem.IsInlineShortcodeName():
sc.name = currItem.ValStr(source)
sc.isInline = true
case currItem.IsShortcodeParam():
if !pt.IsValueNext() {
continue
} else if pt.Peek().IsShortcodeParamVal() {
// named params
if sc.params == nil {
params := make(map[string]any)
params[currItem.ValStr(source)] = pt.Next().ValTyped(source)
sc.params = params
} else {
if params, ok := sc.params.(map[string]any); ok {
params[currItem.ValStr(source)] = pt.Next().ValTyped(source)
} else {
return sc, fmt.Errorf("%s: invalid state: invalid param type %T for shortcode %q, expected a map", errorPrefix, params, sc.name)
}
}
} else {
// positional params
if sc.params == nil {
var params []any
params = append(params, currItem.ValTyped(source))
sc.params = params
} else {
if params, ok := sc.params.([]any); ok {
params = append(params, currItem.ValTyped(source))
sc.params = params
} else {
return sc, fmt.Errorf("%s: invalid state: invalid param type %T for shortcode %q, expected a slice", errorPrefix, params, sc.name)
}
}
}
case currItem.IsDone():
if !currItem.IsError() {
if !closed && sc.needsInner() {
return sc, fmt.Errorf("%s: shortcode %q must be closed or self-closed", errorPrefix, sc.name)
}
}
// handled by caller
pt.Backup()
break Loop
}
}
return sc, nil
}
// Replace prefixed shortcode tokens with the real content.
// Note: This function will rewrite the input slice.
func expandShortcodeTokens(
ctx context.Context,
source []byte,
tokenHandler func(ctx context.Context, token string) ([]byte, error),
) ([]byte, error) {
start := 0
pre := []byte(shortcodePlaceholderPrefix)
post := []byte("HBHB")
pStart := []byte("<p>")
pEnd := []byte("</p>")
k := bytes.Index(source[start:], pre)
for k != -1 {
j := start + k
postIdx := bytes.Index(source[j:], post)
if postIdx < 0 {
// this should never happen, but let the caller decide to panic or not
return nil, errors.New("illegal state in content; shortcode token missing end delim")
}
end := j + postIdx + 4
key := string(source[j:end])
newVal, err := tokenHandler(ctx, key)
if err != nil {
return nil, err
}
// Issue #1148: Check for wrapping p-tags <p>
if j >= 3 && bytes.Equal(source[j-3:j], pStart) {
if (k+4) < len(source) && bytes.Equal(source[end:end+4], pEnd) {
j -= 3
end += 4
}
}
// This and other cool slice tricks: https://github.com/golang/go/wiki/SliceTricks
source = append(source[:j], append(newVal, source[end:]...)...)
start = j
k = bytes.Index(source[start:], pre)
}
return source, nil
}
func renderShortcodeWithPage(ctx context.Context, h *tplimpl.TemplateStore, tmpl *tplimpl.TemplInfo, data *ShortcodeWithPage) (string, error) {
buffer := bp.GetBuffer()
defer bp.PutBuffer(buffer)
err := h.ExecuteWithContext(ctx, tmpl, buffer, data)
if err != nil {
return "", fmt.Errorf("failed to process shortcode: %w", err)
}
return buffer.String(), nil
}