Add inline shortcode support

An inline shortcode's name must end with `.inline`, all lowercase.

E.g.:

```bash
{{< time.inline >}}{{ now }}{{< /time.inline >}}
```

The above will print the current date and time.

Note that an inline shortcode's inner content is parsed and executed as a Go text template with the same context as a regular shortcode template.

This means that the current page can be accessed via `.Page.Title` etc. This also means that there are no concept of "nested inline shortcodes".

The same inline shortcode can be reused later in the same content file, with different params if needed, using the self-closing syntax:

```
{{< time.inline />}}
```

Fixes #4011
This commit is contained in:
Bjørn Erik Pedersen 2018-11-26 11:01:27 +01:00
parent 112461fded
commit bc337e6ab5
8 changed files with 244 additions and 54 deletions

View file

@ -92,7 +92,13 @@ func UnwrapFileError(err error) FileError {
// with the given offset from the original.
func ToFileErrorWithOffset(fe FileError, offset int) FileError {
pos := fe.Position()
pos.LineNumber = pos.LineNumber + offset
return ToFileErrorWithLineNumber(fe, pos.LineNumber+offset)
}
// ToFileErrorWithOffset will return a new FileError with the given line number.
func ToFileErrorWithLineNumber(fe FileError, lineNumber int) FileError {
pos := fe.Position()
pos.LineNumber = lineNumber
return &fileError{cause: fe, fileType: fe.Type(), position: pos}
}

View file

@ -482,6 +482,6 @@ func loadDefaultSettingsFor(v *viper.Viper) error {
v.SetDefault("debug", false)
v.SetDefault("disableFastRender", false)
v.SetDefault("timeout", 10000) // 10 seconds
v.SetDefault("enableInlineShortcodes", false)
return nil
}

View file

@ -18,6 +18,9 @@ import (
"errors"
"fmt"
"html/template"
"path"
"github.com/gohugoio/hugo/common/herrors"
"reflect"
@ -164,6 +167,8 @@ const shortcodePlaceholderPrefix = "HUGOSHORTCODE"
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 []interface{} // string or nested shortcode
params interface{} // map or array
ordinal int
@ -245,6 +250,8 @@ type shortcodeHandler struct {
placeholderID int
placeholderFunc func() string
enableInlineShortcodes bool
}
func (s *shortcodeHandler) nextPlaceholderID() int {
@ -260,6 +267,7 @@ func newShortcodeHandler(p *Page) *shortcodeHandler {
s := &shortcodeHandler{
p: p.withoutContent(),
enableInlineShortcodes: p.s.enableInlineShortcodes,
contentShortcodes: newOrderedMap(),
shortcodes: newOrderedMap(),
nameSet: make(map[string]bool),
@ -313,11 +321,26 @@ const innerNewlineRegexp = "\n"
const innerCleanupRegexp = `\A<p>(.*)</p>\n\z`
const innerCleanupExpand = "$1"
func prepareShortcodeForPage(placeholder string, sc *shortcode, parent *ShortcodeWithPage, p *PageWithoutContent) map[scKey]func() (string, error) {
func (s *shortcodeHandler) prepareShortcodeForPage(placeholder string, sc *shortcode, parent *ShortcodeWithPage, p *PageWithoutContent) map[scKey]func() (string, error) {
m := make(map[scKey]func() (string, error))
lang := p.Lang()
if sc.isInline {
key := newScKeyFromLangAndOutputFormat(lang, p.outputFormats[0], placeholder)
if !s.enableInlineShortcodes {
m[key] = func() (string, error) {
return "", nil
}
} else {
m[key] = func() (string, error) {
return renderShortcode(key, sc, nil, p)
}
}
return m
}
for _, f := range p.outputFormats {
// The most specific template will win.
key := newScKeyFromLangAndOutputFormat(lang, f, placeholder)
@ -335,7 +358,34 @@ func renderShortcode(
parent *ShortcodeWithPage,
p *PageWithoutContent) (string, error) {
tmpl := getShortcodeTemplateForTemplateKey(tmplKey, sc.name, p.s.Tmpl)
var tmpl tpl.Template
if sc.isInline {
templName := path.Join("_inline_shortcode", p.Path(), sc.name)
if sc.isClosing {
templStr := sc.inner[0].(string)
var err error
tmpl, err = p.s.TextTmpl.Parse(templName, templStr)
if err != nil {
fe := herrors.ToFileError("html", err)
l1, l2 := p.posFromPage(sc.pos).LineNumber, fe.Position().LineNumber
fe = herrors.ToFileErrorWithLineNumber(fe, l1+l2-1)
return "", p.errWithFileContext(fe)
}
} else {
// Re-use of shortcode defined earlier in the same page.
var found bool
tmpl, found = p.s.TextTmpl.Lookup(templName)
if !found {
return "", _errors.Errorf("no earlier definition of shortcode %q found", sc.name)
}
}
} else {
tmpl = getShortcodeTemplateForTemplateKey(tmplKey, sc.name, p.s.Tmpl)
}
if tmpl == nil {
p.s.Log.ERROR.Printf("Unable to locate template for shortcode %q in page %q", sc.name, p.Path())
return "", nil
@ -406,7 +456,16 @@ func renderShortcode(
}
return renderShortcodeWithPage(tmpl, data)
s, err := renderShortcodeWithPage(tmpl, data)
if err != nil && sc.isInline {
fe := herrors.ToFileError("html", err)
l1, l2 := p.posFromPage(sc.pos).LineNumber, fe.Position().LineNumber
fe = herrors.ToFileErrorWithLineNumber(fe, l1+l2-1)
return "", fe
}
return s, err
}
// The delta represents new output format-versions of the shortcodes,
@ -417,7 +476,7 @@ func renderShortcode(
// the content from the previous output format, if any.
func (s *shortcodeHandler) updateDelta() bool {
s.init.Do(func() {
s.contentShortcodes = createShortcodeRenderers(s.shortcodes, s.p.withoutContent())
s.contentShortcodes = s.createShortcodeRenderers(s.p.withoutContent())
})
if !s.p.shouldRenderTo(s.p.s.rc.Format) {
@ -505,13 +564,13 @@ func (s *shortcodeHandler) executeShortcodesForDelta(p *PageWithoutContent) erro
}
func createShortcodeRenderers(shortcodes *orderedMap, p *PageWithoutContent) *orderedMap {
func (s *shortcodeHandler) createShortcodeRenderers(p *PageWithoutContent) *orderedMap {
shortcodeRenderers := newOrderedMap()
for _, k := range shortcodes.Keys() {
v := shortcodes.getShortcode(k)
prepared := prepareShortcodeForPage(k.(string), v, nil, p)
for _, k := range s.shortcodes.Keys() {
v := s.shortcodes.getShortcode(k)
prepared := s.prepareShortcodeForPage(k.(string), v, nil, p)
for kk, vv := range prepared {
shortcodeRenderers.Add(kk, vv)
}
@ -541,7 +600,9 @@ Loop:
currItem := pt.Next()
switch {
case currItem.IsLeftShortcodeDelim():
if sc.pos == 0 {
sc.pos = currItem.Pos
}
next := pt.Peek()
if next.IsShortcodeClose() {
continue
@ -570,13 +631,13 @@ Loop:
case currItem.IsRightShortcodeDelim():
// we trust the template on this:
// if there's no inner, we're done
if !isInner {
if !sc.isInline && !isInner {
return sc, nil
}
case currItem.IsShortcodeClose():
next := pt.Peek()
if !isInner {
if !sc.isInline && !isInner {
if next.IsError() {
// return that error, more specific
continue
@ -588,6 +649,7 @@ Loop:
// self-closing
pt.Consume(1)
} else {
sc.isClosing = true
pt.Consume(2)
}
@ -609,6 +671,10 @@ Loop:
return sc, fail(_errors.Wrapf(err, "failed to handle template for shortcode %q", sc.name), currItem)
}
case currItem.IsInlineShortcodeName():
sc.name = currItem.ValStr()
sc.isInline = true
case currItem.IsShortcodeParam():
if !pt.IsValueNext() {
continue
@ -751,7 +817,7 @@ func renderShortcodeWithPage(tmpl tpl.Template, data *ShortcodeWithPage) (string
err := tmpl.Execute(buffer, data)
isInnerShortcodeCache.RUnlock()
if err != nil {
return "", data.Page.errorf(err, "failed to process shortcode")
return "", _errors.Wrap(err, "failed to process shortcode")
}
return buffer.String(), nil
}

View file

@ -1062,3 +1062,53 @@ String: {{ . | safeHTML }}
)
}
func TestInlineShortcodes(t *testing.T) {
for _, enableInlineShortcodes := range []bool{true, false} {
t.Run(fmt.Sprintf("enableInlineShortcodes=%t", enableInlineShortcodes),
func(t *testing.T) {
conf := fmt.Sprintf(`
baseURL = "https://example.com"
enableInlineShortcodes = %t
`, enableInlineShortcodes)
b := newTestSitesBuilder(t)
b.WithConfigFile("toml", conf)
b.WithContent("page-md-shortcode.md", `---
title: "Hugo"
---
FIRST:{{< myshort.inline "first" >}}
Page: {{ .Page.Title }}
Seq: {{ seq 3 }}
Param: {{ .Get 0 }}
{{< /myshort.inline >}}:END:
SECOND:{{< myshort.inline "second" />}}:END
`)
b.WithTemplatesAdded("layouts/_default/single.html", `
CONTENT:{{ .Content }}
`)
b.CreateSites().Build(BuildCfg{})
if enableInlineShortcodes {
b.AssertFileContent("public/page-md-shortcode/index.html",
"Page: Hugo",
"Seq: [1 2 3]",
"Param: first",
"Param: second",
)
} else {
b.AssertFileContent("public/page-md-shortcode/index.html",
"FIRST::END",
"SECOND::END",
)
}
})
}
}

View file

@ -124,6 +124,8 @@ type Site struct {
disabledKinds map[string]bool
enableInlineShortcodes bool
// Output formats defined in site config per Page Kind, or some defaults
// if not set.
// Output formats defined in Page front matter will override these.
@ -208,6 +210,7 @@ func (s *Site) reset() *Site {
owner: s.owner,
publisher: s.publisher,
siteConfig: s.siteConfig,
enableInlineShortcodes: s.enableInlineShortcodes,
PageCollections: newPageCollections()}
}
@ -293,6 +296,7 @@ func newSite(cfg deps.DepsCfg) (*Site, error) {
outputFormatsConfig: siteOutputFormatsConfig,
mediaTypesConfig: siteMediaTypesConfig,
frontmatterHandler: frontMatterHandler,
enableInlineShortcodes: cfg.Language.GetBool("enableInlineShortcodes"),
}
return s, nil

View file

@ -42,6 +42,10 @@ func (i Item) IsShortcodeName() bool {
return i.Type == tScName
}
func (i Item) IsInlineShortcodeName() bool {
return i.Type == tScNameInline
}
func (i Item) IsLeftShortcodeDelim() bool {
return i.Type == tLeftDelimScWithMarkup || i.Type == tLeftDelimScNoMarkup
}
@ -119,6 +123,7 @@ const (
tRightDelimScWithMarkup
tScClose
tScName
tScNameInline
tScParam
tScParamVal

View file

@ -32,6 +32,7 @@ type stateFunc func(*pageLexer) stateFunc
type lexerShortcodeState struct {
currLeftDelimItem ItemType
currRightDelimItem ItemType
isInline bool
currShortcodeName string // is only set when a shortcode is in opened state
closingState int // > 0 = on its way to be closed
elementStepNum int // step number in element
@ -224,6 +225,19 @@ func lexMainSection(l *pageLexer) stateFunc {
for {
if l.isShortCodeStart() {
if l.isInline {
// If we're inside an inline shortcode, the only valid shortcode markup is
// the markup which closes it.
b := l.input[l.pos+3:]
end := indexNonWhiteSpace(b, '/')
if end != len(l.input)-1 {
b = bytes.TrimSpace(b[end+1:])
if end == -1 || !bytes.HasPrefix(b, []byte(l.currShortcodeName+" ")) {
return l.errorf("inline shortcodes do not support nesting")
}
}
}
if l.pos > l.start {
l.emit(tText)
}
@ -266,6 +280,14 @@ func lexMainSection(l *pageLexer) stateFunc {
func (l *pageLexer) isShortCodeStart() bool {
return l.hasPrefix(leftDelimScWithMarkup) || l.hasPrefix(leftDelimScNoMarkup)
}
func (l *pageLexer) posFirstNonWhiteSpace() int {
f := func(c rune) bool {
return !unicode.IsSpace(c)
}
return bytes.IndexFunc(l.input[l.pos:], f)
}
func lexIntroSection(l *pageLexer) stateFunc {
@ -611,6 +633,9 @@ Loop:
return lexInsideShortcode
}
// Inline shortcodes has the form {{< myshortcode.inline >}}
var inlineIdentifier = []byte("inline ")
// scans an alphanumeric inside shortcode
func lexIdentifierInShortcode(l *pageLexer) stateFunc {
lookForEnd := false
@ -620,6 +645,11 @@ Loop:
case isAlphaNumericOrHyphen(r):
// Allow forward slash inside names to make it possible to create namespaces.
case r == '/':
case r == '.':
l.isInline = l.hasPrefix(inlineIdentifier)
if !l.isInline {
return l.errorf("period in shortcode name only allowed for inline identifiers")
}
default:
l.backup()
word := string(l.input[l.start:l.pos])
@ -634,7 +664,11 @@ Loop:
l.currShortcodeName = word
l.openShortcodes[word] = true
l.elementStepNum++
if l.isInline {
l.emit(tScNameInline)
} else {
l.emit(tScName)
}
break Loop
}
}
@ -646,6 +680,7 @@ Loop:
}
func lexEndOfShortcode(l *pageLexer) stateFunc {
l.isInline = false
if l.hasPrefix(l.currentRightShortcodeDelim()) {
return lexShortcodeRightDelim
}
@ -747,6 +782,22 @@ func minIndex(indices ...int) int {
return min
}
func indexNonWhiteSpace(s []byte, in rune) int {
idx := bytes.IndexFunc(s, func(r rune) bool {
return !unicode.IsSpace(r)
})
if idx == -1 {
return -1
}
r, _ := utf8.DecodeRune(s[idx:])
if r == in {
return idx
}
return -1
}
func isSpace(r rune) bool {
return r == ' ' || r == '\t'
}

View file

@ -23,12 +23,14 @@ var (
tstRightMD = nti(tRightDelimScWithMarkup, "%}}")
tstSCClose = nti(tScClose, "/")
tstSC1 = nti(tScName, "sc1")
tstSC1Inline = nti(tScNameInline, "sc1.inline")
tstSC2 = nti(tScName, "sc2")
tstSC3 = nti(tScName, "sc3")
tstSCSlash = nti(tScName, "sc/sub")
tstParam1 = nti(tScParam, "param1")
tstParam2 = nti(tScParam, "param2")
tstVal = nti(tScParamVal, "Hello World")
tstText = nti(tText, "Hello World")
)
var shortCodeLexerTests = []lexerTest{
@ -146,6 +148,12 @@ var shortCodeLexerTests = []lexerTest{
nti(tError, "comment must be closed")}},
{"commented out, misplaced close", `{{</* sc1 >}}*/`, []Item{
nti(tError, "comment must be closed")}},
// Inline shortcodes
{"basic inline", `{{< sc1.inline >}}Hello World{{< /sc1.inline >}}`, []Item{tstLeftNoMD, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSCClose, tstSC1Inline, tstRightNoMD, tstEOF}},
{"basic inline with space", `{{< sc1.inline >}}Hello World{{< / sc1.inline >}}`, []Item{tstLeftNoMD, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSCClose, tstSC1Inline, tstRightNoMD, tstEOF}},
{"inline self closing", `{{< sc1.inline >}}Hello World{{< /sc1.inline >}}Hello World{{< sc1.inline />}}`, []Item{tstLeftNoMD, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSCClose, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSC1Inline, tstSCClose, tstRightNoMD, tstEOF}},
{"inline with nested shortcode (not supported)", `{{< sc1.inline >}}Hello World{{< sc1 >}}{{< /sc1.inline >}}`, []Item{tstLeftNoMD, tstSC1Inline, tstRightNoMD, nti(tError, "inline shortcodes do not support nesting")}},
{"inline case mismatch", `{{< sc1.Inline >}}Hello World{{< /sc1.Inline >}}`, []Item{tstLeftNoMD, nti(tError, "period in shortcode name only allowed for inline identifiers")}},
}
func TestShortcodeLexer(t *testing.T) {