diff --git a/common/herrors/file_error.go b/common/herrors/file_error.go index 929cc800f..5af84adf5 100644 --- a/common/herrors/file_error.go +++ b/common/herrors/file_error.go @@ -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} } diff --git a/hugolib/config.go b/hugolib/config.go index 388069047..77ebb42ae 100644 --- a/hugolib/config.go +++ b/hugolib/config.go @@ -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 } diff --git a/hugolib/shortcode.go b/hugolib/shortcode.go index 1860a5e90..8be312f83 100644 --- a/hugolib/shortcode.go +++ b/hugolib/shortcode.go @@ -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

(.*)

\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 } diff --git a/hugolib/shortcode_test.go b/hugolib/shortcode_test.go index 30fdbead3..3a1656e26 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 fb32853e3..25eb34f05 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 0567bd8b9..644c20e87 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 8106758a9..94c1ff26b 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 efef6fca2..c52840b58 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) {