Add inline shortcode support
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Mon, 26 Nov 2018 10:01:27 +0000 (11:01 +0100)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Tue, 27 Nov 2018 15:14:09 +0000 (16:14 +0100)
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

common/herrors/file_error.go
hugolib/config.go
hugolib/shortcode.go
hugolib/shortcode_test.go
hugolib/site.go
parser/pageparser/item.go
parser/pageparser/pagelexer.go
parser/pageparser/pageparser_shortcode_test.go

index 929cc800ff980c70ef2996298579e48dad52cfde..5af84adf5a376e38c6e3a99739d3dc28c0f75112 100644 (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}
 }
 
index 388069047771e3044e2a1e27164f4d98ab665044..77ebb42ae6f8764df831d7d3877410144757ddc9 100644 (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
 }
index 1860a5e908967fa4e82be52e2857f840ddee7086..8be312f83af815ead8b28b9c918c20eea2de02f8 100644 (file)
@@ -18,6 +18,9 @@ import (
        "errors"
        "fmt"
        "html/template"
+       "path"
+
+       "github.com/gohugoio/hugo/common/herrors"
 
        "reflect"
 
@@ -163,13 +166,15 @@ func (scp *ShortcodeWithPage) page() *Page {
 const shortcodePlaceholderPrefix = "HUGOSHORTCODE"
 
 type shortcode struct {
-       name     string
-       inner    []interface{} // string or nested shortcode
-       params   interface{}   // map or array
-       ordinal  int
-       err      error
-       doMarkup bool
-       pos      int // the position in bytes in the source file
+       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
+       err       error
+       doMarkup  bool
+       pos       int // the position in bytes in the source file
 }
 
 func (sc shortcode) String() string {
@@ -245,6 +250,8 @@ type shortcodeHandler struct {
 
        placeholderID   int
        placeholderFunc func() string
+
+       enableInlineShortcodes bool
 }
 
 func (s *shortcodeHandler) nextPlaceholderID() int {
@@ -259,11 +266,12 @@ func (s *shortcodeHandler) createShortcodePlaceholder() string {
 func newShortcodeHandler(p *Page) *shortcodeHandler {
 
        s := &shortcodeHandler{
-               p:                  p.withoutContent(),
-               contentShortcodes:  newOrderedMap(),
-               shortcodes:         newOrderedMap(),
-               nameSet:            make(map[string]bool),
-               renderedShortcodes: make(map[string]string),
+               p:                      p.withoutContent(),
+               enableInlineShortcodes: p.s.enableInlineShortcodes,
+               contentShortcodes:      newOrderedMap(),
+               shortcodes:             newOrderedMap(),
+               nameSet:                make(map[string]bool),
+               renderedShortcodes:     make(map[string]string),
        }
 
        placeholderFunc := p.s.shortcodePlaceholderFunc
@@ -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():
-                       sc.pos = currItem.Pos
+                       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
 }
index 30fdbead3b0a03fa9844d6c18c199da0518ae932..3a1656e262d441728f4508a39d00001677d39b50 100644 (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",
+                                       )
+                               }
+
+                       })
+
+       }
+}
index fb32853e3ce085f75874b3d91b389c393f944501..25eb34f05a66cd9d9ce19ff764112f5929755321 100644 (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.
@@ -194,21 +196,22 @@ func (s *Site) isEnabled(kind string) bool {
 // reset returns a new Site prepared for rebuild.
 func (s *Site) reset() *Site {
        return &Site{Deps: s.Deps,
-               layoutHandler:       output.NewLayoutHandler(),
-               disabledKinds:       s.disabledKinds,
-               titleFunc:           s.titleFunc,
-               relatedDocsHandler:  newSearchIndexHandler(s.relatedDocsHandler.cfg),
-               siteRefLinker:       s.siteRefLinker,
-               outputFormats:       s.outputFormats,
-               rc:                  s.rc,
-               outputFormatsConfig: s.outputFormatsConfig,
-               frontmatterHandler:  s.frontmatterHandler,
-               mediaTypesConfig:    s.mediaTypesConfig,
-               Language:            s.Language,
-               owner:               s.owner,
-               publisher:           s.publisher,
-               siteConfig:          s.siteConfig,
-               PageCollections:     newPageCollections()}
+               layoutHandler:          output.NewLayoutHandler(),
+               disabledKinds:          s.disabledKinds,
+               titleFunc:              s.titleFunc,
+               relatedDocsHandler:     newSearchIndexHandler(s.relatedDocsHandler.cfg),
+               siteRefLinker:          s.siteRefLinker,
+               outputFormats:          s.outputFormats,
+               rc:                     s.rc,
+               outputFormatsConfig:    s.outputFormatsConfig,
+               frontmatterHandler:     s.frontmatterHandler,
+               mediaTypesConfig:       s.mediaTypesConfig,
+               Language:               s.Language,
+               owner:                  s.owner,
+               publisher:              s.publisher,
+               siteConfig:             s.siteConfig,
+               enableInlineShortcodes: s.enableInlineShortcodes,
+               PageCollections:        newPageCollections()}
 
 }
 
@@ -282,17 +285,18 @@ func newSite(cfg deps.DepsCfg) (*Site, error) {
        }
 
        s := &Site{
-               PageCollections:     c,
-               layoutHandler:       output.NewLayoutHandler(),
-               Language:            cfg.Language,
-               disabledKinds:       disabledKinds,
-               titleFunc:           titleFunc,
-               relatedDocsHandler:  newSearchIndexHandler(relatedContentConfig),
-               outputFormats:       outputFormats,
-               rc:                  &siteRenderingContext{output.HTMLFormat},
-               outputFormatsConfig: siteOutputFormatsConfig,
-               mediaTypesConfig:    siteMediaTypesConfig,
-               frontmatterHandler:  frontMatterHandler,
+               PageCollections:        c,
+               layoutHandler:          output.NewLayoutHandler(),
+               Language:               cfg.Language,
+               disabledKinds:          disabledKinds,
+               titleFunc:              titleFunc,
+               relatedDocsHandler:     newSearchIndexHandler(relatedContentConfig),
+               outputFormats:          outputFormats,
+               rc:                     &siteRenderingContext{output.HTMLFormat},
+               outputFormatsConfig:    siteOutputFormatsConfig,
+               mediaTypesConfig:       siteMediaTypesConfig,
+               frontmatterHandler:     frontMatterHandler,
+               enableInlineShortcodes: cfg.Language.GetBool("enableInlineShortcodes"),
        }
 
        return s, nil
index 0567bd8b9c29dd1fc05ef4ebef609b4860d7a10d..644c20e873273ca44d1f43263efea20c42129a79 100644 (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
 
index 8106758a96e24afbfe2652d3a732b3bd188544c6..94c1ff26bc9db2a082ab786ac6471dc468ecef44 100644 (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++
-                       l.emit(tScName)
+                       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'
 }
index efef6fca2408987c5c6ac97f625eb48b282d49bc..c52840b58e12da7a9f29ac63a1d3cb375f7c3e93 100644 (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) {