// 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">↩</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