diff --git a/hugolib/shortcode.go b/hugolib/shortcode.go index 1860a5e9089..e6c39423123 100644 --- a/hugolib/shortcode.go +++ b/hugolib/shortcode.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "html/template" + "path" "reflect" @@ -163,13 +164,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 +248,8 @@ type shortcodeHandler struct { placeholderID int placeholderFunc func() string + + enableInlineShortcodes bool } func (s *shortcodeHandler) nextPlaceholderID() int { @@ -259,11 +264,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 +319,26 @@ const innerNewlineRegexp = "\n" const innerCleanupRegexp = `\A

(.*)

\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 +356,31 @@ 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 { + return "", err + } + + } 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 @@ -417,7 +462,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 +550,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) } @@ -570,13 +615,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 +633,7 @@ Loop: // self-closing pt.Consume(1) } else { + sc.isClosing = true pt.Consume(2) } @@ -609,6 +655,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 diff --git a/hugolib/shortcode_test.go b/hugolib/shortcode_test.go index 30fdbead3b0..3a1656e262d 100644 --- a/hugolib/shortcode_test.go +++ b/hugolib/shortcode_test.go @@ -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", + ) + } + + }) + + } +} diff --git a/hugolib/site.go b/hugolib/site.go index fb32853e3ce..25eb34f05a6 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -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 diff --git a/parser/pageparser/item.go b/parser/pageparser/item.go index 0567bd8b9c2..644c20e8732 100644 --- a/parser/pageparser/item.go +++ b/parser/pageparser/item.go @@ -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 diff --git a/parser/pageparser/pagelexer.go b/parser/pageparser/pagelexer.go index 8106758a96e..94c1ff26bc9 100644 --- a/parser/pageparser/pagelexer.go +++ b/parser/pageparser/pagelexer.go @@ -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' } diff --git a/parser/pageparser/pageparser_shortcode_test.go b/parser/pageparser/pageparser_shortcode_test.go index efef6fca240..c52840b58e1 100644 --- a/parser/pageparser/pageparser_shortcode_test.go +++ b/parser/pageparser/pageparser_shortcode_test.go @@ -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", `{{}}*/`, []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) {