Files
khosra/internal/ext/notation/notation.go
T
Claude Opus 5andbdeshi 661fb469e8 expand abbreviations from a definition line
`*[TERM]: expansion` on its own line, PHP Markdown Extra's form, and every
whole-word use in the document becomes <abbr title="…">.

A transformer rather than an inline parser, because a definition may appear
after the use it explains and a parser only ever sees what it has already read.
A block parser rather than a pattern found later, because the line has to stop
being content — an author would notice that going wrong before anything else.

Whole-word matching is the part that would have bitten: without it a definition
of HTML quietly rewrites HTMLish and xHTML too, so both have cases. Code spans,
autolinks, raw HTML and an already-expanded term are skipped, the longest
definition wins where two could match, and the expansion is escaped into the
attribute so a quoted phrase cannot end it.

Definitions are document-scoped. A term defined in a page does not reach an
included fragment, which is parsed on its own bytes exactly as footnotes are —
stated in content-model.md rather than left to be discovered.

The nesting gate caught firstMatch four levels deep; the inner search is its own
function now, which reads better than it did before the warning.

core 2794/2800, ext 1483/2000, 34 gates green, 0 warnings.
2026-08-01 21:25:44 +06:00

158 lines
5.5 KiB
Go

package notation
import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// mark is one delimiter byte and what a single or doubled run of it means around some text.
//
// The tilde carries both, and that is the whole reason this package exists: goldmark's own strikethrough
// claims a single tilde as well as a double, which silently turns `H~2~O` into `H<del>2</del>O`. Owning the
// byte here is what lets subscript and strikethrough coexist (ADR-0061).
var marks = []mark{
{char: '~', single: "sub", double: "del"},
{char: '^', single: "sup"},
{char: '=', double: "mark"},
}
// A single run is scanned to its closing byte and may not cross whitespace; a doubled run goes through
// goldmark's delimiter machinery and may. The split is not a preference: `x^2 + y^2 = z^2` pairs its carets
// across the whole expression under delimiter rules, which is prose silently becoming markup. Pandoc draws
// the same line, and it is why a subscript holds a formula rather than a phrase.
type mark struct {
char byte
single, double string
}
// New returns the Markdown extension.
func New() goldmark.Extender { return extension{} }
type extension struct{}
// Extend registers one inline parser per mark, and the single renderer they share.
//
// Priority 500 is goldmark's own for delimiter-run inlines, so these resolve alongside emphasis rather than
// ahead of it: a mark is ordinary inline text, not a construct that outranks the language.
func (extension) Extend(md goldmark.Markdown) {
inline := make([]util.PrioritizedValue, 0, len(marks))
for _, m := range marks {
inline = append(inline, util.Prioritized(inlineParser{m}, 500))
}
md.Parser().AddOptions(
parser.WithInlineParsers(inline...),
// Above the paragraph parser, so a definition line stops being content (ADR-0062).
parser.WithBlockParsers(util.Prioritized(definitionParser{}, 99)),
parser.WithASTTransformers(util.Prioritized(expand{}, 200)),
)
md.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(nodeRenderer{}, 500)))
}
// kind identifies a parsed mark in the tree.
var kind = ast.NewNodeKind("Notation")
// node is one matched pair, carrying the element it becomes and nothing else.
type node struct {
ast.BaseInline
tag string
}
func (n *node) Kind() ast.NodeKind { return kind }
func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
// processor pairs doubled runs. A run of one is refused here, so `=marked=` stays prose and the single-run
// meaning — where a mark has one — is decided by the scan instead.
type processor struct{ tag string }
func (p processor) IsDelimiter(b byte) bool { return b == '~' || b == '=' }
func (p processor) CanOpenCloser(opener, closer *parser.Delimiter) bool {
return opener.Char == closer.Char && opener.Length >= 2 && closer.Length >= 2
}
func (p processor) OnMatch(consumes int) ast.Node { return &node{tag: p.tag} }
// inlineParser handles one byte, both of its meanings.
type inlineParser struct{ m mark }
func (p inlineParser) Trigger() []byte { return []byte{p.m.char} }
func (p inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
line, segment := block.PeekLine()
if len(line) > 1 && line[1] == p.m.char {
return p.doubled(block, line, segment, pc)
}
return p.single(block, line, segment)
}
// doubled pushes a delimiter run, so the pair may hold spaces and nested markup: `~~a *b*~~`.
func (p inlineParser) doubled(block text.Reader, line []byte, segment text.Segment, pc parser.Context) ast.Node {
if p.m.double == "" {
return nil
}
before := block.PrecendingCharacter()
d := parser.ScanDelimiter(line, before, 2, processor{p.m.double})
if d == nil {
return nil
}
d.Segment = segment.WithStop(segment.Start + d.OriginalLength)
block.Advance(d.OriginalLength)
pc.PushDelimiter(d)
return d
}
// single scans to the closing byte on this line, refusing to cross whitespace.
//
// The content is taken as text rather than parsed for markup: a span that cannot hold a space has no room
// for emphasis either, and scanning is what keeps `x^2 + y^2` out of the parser's hands.
func (p inlineParser) single(block text.Reader, line []byte, segment text.Segment) ast.Node {
// A run of the byte is not a single mark, so `^^up^^` stays prose rather than matching the pair inside
// it. goldmark's own strikethrough guards the same way.
if p.m.single == "" || block.PrecendingCharacter() == rune(p.m.char) {
return nil
}
end := 0
for i := 1; i < len(line); i++ {
if line[i] == p.m.char {
end = i
break
}
if util.IsSpace(line[i]) {
return nil
}
}
if end < 2 {
return nil
}
inner := segment
inner.Start, inner.Stop = segment.Start+1, segment.Start+end
n := &node{tag: p.m.single}
n.AppendChild(n, ast.NewTextSegment(inner))
block.Advance(end + 1)
return n
}
// nodeRenderer writes the element a matched pair became. The tag is one of a fixed set in `marks`, never
// anything an author supplied, so it is written directly.
type nodeRenderer struct{}
func (r nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kind, render)
r.registerAbbr(reg)
}
func render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
tag := n.(*node).tag
if entering {
_, _ = w.WriteString("<" + tag + ">")
} else {
_, _ = w.WriteString("</" + tag + ">")
}
return ast.WalkContinue, nil
}