internal/render/shortcodes.go

// 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
}