internal/render/markdown/extensions/footnotes.go

// Package extensions contains goldmark extenders that tilstream layers on top
// of the default CommonMark set. Footnotes is our own implementation rather
// than goldmark's stock one because I need numbered backrefs and a stable
// "footnotes" section wrapper that matches my theme.
package extensions

import (
	"bytes"
	"fmt"
	"regexp"
	"sort"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer"
	"github.com/yuin/goldmark/renderer/html"
	"github.com/yuin/goldmark/text"
	"github.com/yuin/goldmark/util"
)

// FootnoteRef is an inline node representing a [^label] reference.
type FootnoteRef struct {
	ast.BaseInline
	Label string
	Index int
}

// Kind identifies FootnoteRef to the AST walker.
var KindFootnoteRef = ast.NewNodeKind("FootnoteRef")

func (*FootnoteRef) Kind() ast.NodeKind { return KindFootnoteRef }

// Dump satisfies ast.Node.
func (n *FootnoteRef) Dump(src []byte, level int) { ast.DumpHelper(n, src, level, nil, nil) }

// Footnotes is the goldmark extender.
type Footnotes struct{}

// Extend registers parser and renderer hooks.
func (f *Footnotes) Extend(m goldmark.Markdown) {
	m.Parser().AddOptions(parser.WithInlineParsers(
		util.Prioritized(&refParser{}, 150),
	))
	m.Renderer().AddOptions(renderer.WithNodeRenderers(
		util.Prioritized(&refRenderer{}, 500),
	))
}

type refParser struct{}

var refPattern = regexp.MustCompile(`^\[\^([^\]]+)\]`)

func (p *refParser) Trigger() []byte { return []byte{'['} }

func (p *refParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
	line, seg := block.PeekLine()
	m := refPattern.FindSubmatch(line)
	if m == nil {
		return nil
	}
	block.Advance(len(m[0]))
	_ = seg
	return &FootnoteRef{Label: string(m[1])}
}

type refRenderer struct{}

func (r *refRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
	reg.Register(KindFootnoteRef, r.render)
}

func (r *refRenderer) render(w util.BufWriter, src []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
	if !entering {
		return ast.WalkContinue, nil
	}
	ref := n.(*FootnoteRef)
	fmt.Fprintf(w, `<sup class="footnote-ref"><a href="#fn-%s" id="fnref-%s">%d</a></sup>`,
		ref.Label, ref.Label, ref.Index)
	return ast.WalkContinue, nil
}

// Definition holds a single "[^label]: text" entry.
type Definition struct {
	Label string
	Body  []byte
}

// ExtractDefinitions scans markdown source for footnote definitions and
// strips them out, returning the cleaned source and a sorted slice of
// definitions. Callers render the definitions at the bottom of the page.
func ExtractDefinitions(src []byte) ([]byte, []Definition) {
	defPattern := regexp.MustCompile(`(?m)^\[\^([^\]]+)\]:\s*(.+)$`)
	matches := defPattern.FindAllSubmatchIndex(src, -1)
	if len(matches) == 0 {
		return src, nil
	}
	var defs []Definition
	cleaned := make([]byte, 0, len(src))
	last := 0
	for _, m := range matches {
		cleaned = append(cleaned, src[last:m[0]]...)
		defs = append(defs, Definition{
			Label: string(src[m[2]:m[3]]),
			Body:  bytes.TrimSpace(src[m[4]:m[5]]),
		})
		last = m[1]
		// Also consume the trailing newline if present.
		if last < len(src) && src[last] == '\n' {
			last++
		}
	}
	cleaned = append(cleaned, src[last:]...)
	sort.SliceStable(defs, func(i, j int) bool { return defs[i].Label < defs[j].Label })
	return cleaned, defs
}

// AssignIndexes walks a node tree and assigns a 1-based Index to every
// FootnoteRef in document order. Returns the number of refs found.
func AssignIndexes(root ast.Node) int {
	idx := 0
	_ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		if r, ok := n.(*FootnoteRef); ok {
			idx++
			r.Index = idx
		}
		return ast.WalkContinue, nil
	})
	return idx
}

// RenderSection writes the footnote section HTML for the given defs. The
// caller decides where to place it in the output stream.
func RenderSection(w *bytes.Buffer, defs []Definition) {
	if len(defs) == 0 {
		return
	}
	w.WriteString(`<section class="footnotes"><ol>` + "\n")
	for _, d := range defs {
		fmt.Fprintf(w, `  <li id="fn-%s">%s <a href="#fnref-%s" class="footnote-back">&#8617;</a></li>`+"\n",
			d.Label, d.Body, d.Label)
	}
	w.WriteString("</ol></section>\n")
}

// Unused html import guard to match go imports in constrained builds.
var _ = html.DefaultWriter