// Package render: shortcode expansion. I keep shortcodes intentionally limited
// -- only {{ note }}, {{ gist }}, {{ youtube }} and {{ figure }} -- because
// every shortcode is a future breakage. The parser is a tiny state machine
// that walks the raw markdown once before goldmark sees it.
package render
import (
"fmt"
"html"
"strings"
)
type shortcode struct {
Name string
Args map[string]string
Body string // for paired codes like {{ note }}...{{ /note }}
}
// Handler renders a shortcode to HTML (or raw markdown for inline codes that
// want to be re-parsed). Returning ("", false) means "unknown shortcode,
// leave the raw text alone so the author sees the mistake".
type Handler func(s shortcode) (string, bool)
var builtin = map[string]Handler{
"note": noteHandler,
"gist": gistHandler,
"youtube": youtubeHandler,
"figure": figureHandler,
}
// Expand walks src once and replaces each {{ name ... }} (and its paired
// closer if any) with the handler output. Unknown codes are left verbatim.
func Expand(src string) string {
var b strings.Builder
b.Grow(len(src))
i := 0
for i < len(src) {
start := strings.Index(src[i:], "{{")
if start < 0 {
b.WriteString(src[i:])
break
}
start += i
b.WriteString(src[i:start])
end := strings.Index(src[start:], "}}")
if end < 0 {
b.WriteString(src[start:])
break
}
end += start
raw := strings.TrimSpace(src[start+2 : end])
i = end + 2
if strings.HasPrefix(raw, "/") {
// Stray closer with no opener -- pass through.
b.WriteString(src[start : end+2])
continue
}
name, args := parseHead(raw)
handler, ok := builtin[name]
if !ok {
b.WriteString(src[start : end+2])
continue
}
body := ""
if closeTag := "{{ /" + name + " }}"; strings.Contains(src[i:], closeTag) {
rel := strings.Index(src[i:], closeTag)
body = src[i : i+rel]
i += rel + len(closeTag)
}
out, ok := handler(shortcode{Name: name, Args: args, Body: body})
if !ok {
b.WriteString(src[start : end+2])
continue
}
b.WriteString(out)
}
return b.String()
}
// parseHead splits "name key=value key2=\"two words\"" into parts.
func parseHead(raw string) (string, map[string]string) {
fields := splitArgs(raw)
if len(fields) == 0 {
return "", nil
}
name := fields[0]
args := map[string]string{}
for _, f := range fields[1:] {
eq := strings.IndexByte(f, '=')
if eq < 0 {
args[f] = ""
continue
}
key := f[:eq]
val := strings.Trim(f[eq+1:], `"'`)
args[key] = val
}
return name, args
}
func splitArgs(s string) []string {
var out []string
var cur strings.Builder
inQuote := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '"':
inQuote = !inQuote
cur.WriteByte(c)
case c == ' ' && !inQuote:
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteByte(c)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
func noteHandler(s shortcode) (string, bool) {
return fmt.Sprintf(`<aside class="note">%s</aside>`, html.EscapeString(strings.TrimSpace(s.Body))), true
}
func gistHandler(s shortcode) (string, bool) {
id, ok := s.Args["id"]
if !ok || id == "" {
return "", false
}
return fmt.Sprintf(`<code class="gist">%s</code>`, html.EscapeString(id)), true
}
func youtubeHandler(s shortcode) (string, bool) {
id, ok := s.Args["id"]
if !ok || id == "" {
return "", false
}
return fmt.Sprintf(
`<iframe class="youtube" loading="lazy" src="https://www.youtube-nocookie.com/embed/%s" allowfullscreen></iframe>`,
html.EscapeString(id),
), true
}
func figureHandler(s shortcode) (string, bool) {
src, ok := s.Args["src"]
if !ok || src == "" {
return "", false
}
alt := s.Args["alt"]
caption := strings.TrimSpace(s.Body)
if caption == "" {
caption = alt
}
return fmt.Sprintf(
`<figure><img src=%q alt=%q><figcaption>%s</figcaption></figure>`,
src, alt, html.EscapeString(caption),
), true
}