diff options
Diffstat (limited to 'hugolib/shortcode.go')
-rw-r--r-- | hugolib/shortcode.go | 724 |
1 files changed, 724 insertions, 0 deletions
diff --git a/hugolib/shortcode.go b/hugolib/shortcode.go new file mode 100644 index 000000000..2951a1436 --- /dev/null +++ b/hugolib/shortcode.go @@ -0,0 +1,724 @@ +// Copyright 2019 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" + "fmt" + "html/template" + "path" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "sync" + + "github.com/gohugoio/hugo/helpers" + + "errors" + + "github.com/gohugoio/hugo/common/herrors" + + "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" + "github.com/gohugoio/hugo/output" + + bp "github.com/gohugoio/hugo/bufferpool" + "github.com/gohugoio/hugo/tpl" +) + +var ( + _ urls.RefLinker = (*ShortcodeWithPage)(nil) + _ pageWrapper = (*ShortcodeWithPage)(nil) + _ text.Positioner = (*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 + + scratch *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) +} + +// Scratch returns a scratch-pad scoped for this shortcode. This can be used +// as a temporary storage for variables, counters etc. +func (scp *ShortcodeWithPage) Scratch() *maps.Scratch { + if scp.scratch == nil { + scp.scratch = maps.NewScratch() + } + return scp.scratch +} + +// 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() +} + +func (scp *ShortcodeWithPage) page() page.Page { + return scp.Page +} + +// Note - this value must not contain any markup syntax +const shortcodePlaceholderPrefix = "HAHAHUGOSHORTCODE" + +func createShortcodePlaceholder(id string, ordinal int) string { + return shortcodePlaceholderPrefix + "-" + id + 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 + err error + + indentation string // indentation from source. + + info tpl.Info // One of the output formats (arbitrary) + templs []tpl.Template // All output formats + + // 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 behaviour 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) configVersion() int { + if s.info == nil { + // Not set for inline shortcodes. + return 2 + } + + return s.info.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 { + p *pageState + + 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(p *pageState, s *Site) *shortcodeHandler { + sh := &shortcodeHandler{ + p: p, + 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 renderShortcode( + level int, + s *Site, + tplVariants tpl.TemplateVariants, + sc *shortcode, + parent *ShortcodeWithPage, + p *pageState) (string, bool, error) { + var tmpl tpl.Template + + // 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, so we may get some false positives -- we + // should improve on that. + var hasVariants bool + + if sc.isInline { + if !p.s.ExecHelper.Sec().EnableInlineShortcodes { + return "", false, nil + } + templName := path.Join("_inline_shortcode", p.File().Path(), sc.name) + if sc.isClosing { + templStr := sc.innerString() + + var err error + tmpl, err = s.TextTmpl().Parse(templName, templStr) + if err != nil { + fe := herrors.NewFileErrorFromName(err, p.File().Filename()) + pos := fe.Position() + pos.LineNumber += p.posOffset(sc.pos).LineNumber + fe = fe.UpdatePosition(pos) + return "", false, p.wrapError(fe) + } + + } else { + // Re-use of shortcode defined earlier in the same page. + var found bool + tmpl, found = s.TextTmpl().Lookup(templName) + if !found { + return "", false, fmt.Errorf("no earlier definition of shortcode %q found", sc.name) + } + } + } else { + var found, more bool + tmpl, found, more = s.Tmpl().LookupVariant(sc.name, tplVariants) + if !found { + s.Log.Errorf("Unable to locate template for shortcode %q in page %q", sc.name, p.File().Path()) + return "", false, nil + } + hasVariants = hasVariants || more + } + + 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, more, err := renderShortcode(level+1, s, tplVariants, innerData, data, p) + if err != nil { + return "", false, err + } + hasVariants = hasVariants || more + inner += s + 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 "", false, nil + } + } + + // Pre Hugo 0.55 this was the behaviour even for the outer-most + // shortcode. + if sc.doMarkup && (level > 0 || sc.configVersion() == 1) { + var err error + b, err := p.pageOutput.contentRenderer.RenderContent([]byte(inner), false) + if err != nil { + return "", false, 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.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(s.Tmpl(), 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 "", false, 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 result, hasVariants, err +} + +func (s *shortcodeHandler) hasShortcodes() bool { + return s != nil && len(s.shortcodes) > 0 +} + +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) renderShortcodesForPage(p *pageState, f output.Format) (map[string]string, bool, error) { + rendered := make(map[string]string) + + tplVariants := tpl.TemplateVariants{ + Language: p.Language().Lang, + OutputFormat: f, + } + + var hasVariants bool + + for _, v := range s.shortcodes { + s, more, err := renderShortcode(0, s.s, tplVariants, v, nil, p) + if err != nil { + err = p.parseError(fmt.Errorf("failed to render shortcode %q: %w", v.name, err), p.source.parsed.Input(), v.pos) + return nil, false, err + } + hasVariants = hasVariants || more + rendered[v.placeholder] = s + + } + + return rendered, hasVariants, nil +} + +func (s *shortcodeHandler) parseError(err error, input []byte, pos int) error { + if s.p != nil { + return s.p.parseError(err, input, pos) + } + return err +} + +// 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 + const errorPrefix = "failed to extract shortcode" + + fail := func(err error, i pageparser.Item) error { + return s.parseError(fmt.Errorf("%s: %w", errorPrefix, err), source, i.Pos()) + } + +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.info == nil { + // This should not happen. + return sc, fail(errors.New("BUG: template info not set"), currItem) + } + if !sc.info.ParseInfo().IsInner { + return sc, nil + } + } + + case currItem.IsShortcodeClose(): + next := pt.Peek() + if !sc.isInline { + if sc.info == nil || !sc.info.ParseInfo().IsInner { + if next.IsError() { + // return that error, more specific + continue + } + return sc, fail(fmt.Errorf("shortcode %q has no .Inner, yet a closing tag was provided", next.ValStr(source)), next) + } + } + 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.Type == pageparser.TypeEmoji: + // TODO(bep) avoid the duplication of these "text cases", to prevent + // more of #6504 in the future. + val := currItem.ValStr(source) + if emoji := helpers.Emoji(val); emoji != nil { + sc.inner = append(sc.inner, string(emoji)) + } else { + sc.inner = append(sc.inner, val) + } + case currItem.IsShortcodeName(): + + sc.name = currItem.ValStr(source) + + // Used to check if the template expects inner content. + templs := s.s.Tmpl().LookupVariants(sc.name) + if templs == nil { + return nil, fmt.Errorf("%s: template for shortcode %q not found", errorPrefix, sc.name) + } + + sc.info = templs[0].(tpl.Info) + sc.templs = templs + 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(): + // 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 replaceShortcodeTokens(source []byte, replacements map[string]string) ([]byte, error) { + if len(replacements) == 0 { + return source, nil + } + + 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 + + newVal := []byte(replacements[string(source[j:end])]) + + // 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(h tpl.TemplateHandler, tmpl tpl.Template, data *ShortcodeWithPage) (string, error) { + buffer := bp.GetBuffer() + defer bp.PutBuffer(buffer) + + err := h.Execute(tmpl, buffer, data) + if err != nil { + return "", fmt.Errorf("failed to process shortcode: %w", err) + } + return buffer.String(), nil +} |