Files
khosra/internal/ext/notation/abbr.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

247 lines
7.5 KiB
Go

package notation
import (
"sort"
"strings"
"unicode"
"unicode/utf8"
"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"
)
// defined holds this document's abbreviations, keyed by the term as written.
var defined = parser.NewContextKey()
// definitionKind marks the line that declared one. It renders nothing: a definition is a note to the parser,
// not content.
var definitionKind = ast.NewNodeKind("AbbrDefinition")
type definition struct{ ast.BaseBlock }
func (n *definition) Kind() ast.NodeKind { return definitionKind }
func (n *definition) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
// abbrKind is one expanded occurrence.
var abbrKind = ast.NewNodeKind("Abbr")
type abbr struct {
ast.BaseInline
title string
}
func (n *abbr) Kind() ast.NodeKind { return abbrKind }
func (n *abbr) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
// definitionParser claims a whole line of the form `*[TERM]: expansion`.
//
// Its own block parser rather than a pattern found later, because the line has to stop being content: left to
// the paragraph parser it would render as prose, which is what the author is least expecting.
type definitionParser struct{}
func (definitionParser) Trigger() []byte { return []byte{'*'} }
func (definitionParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
line, seg := reader.PeekLine()
term, expansion, ok := parseDefinition(string(line))
if !ok {
return nil, parser.NoChildren
}
terms, _ := pc.Get(defined).(map[string]string)
if terms == nil {
terms = map[string]string{}
pc.Set(defined, terms)
}
terms[term] = expansion
reader.Advance(seg.Len() - 1)
return &definition{}, parser.NoChildren
}
func (definitionParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
return parser.Close
}
func (definitionParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {}
func (definitionParser) CanInterruptParagraph() bool { return true }
func (definitionParser) CanAcceptIndentedLine() bool { return false }
// parseDefinition reads `*[TERM]: expansion`. A space after the asterisk makes it a list item instead, which
// is why the bracket must follow immediately.
func parseDefinition(line string) (term, expansion string, ok bool) {
rest, found := strings.CutPrefix(strings.TrimRight(line, "\r\n"), "*[")
if !found {
return "", "", false
}
term, rest, found = strings.Cut(rest, "]")
if !found || term == "" {
return "", "", false
}
expansion, found = strings.CutPrefix(rest, ":")
if !found {
return "", "", false
}
expansion = strings.TrimSpace(expansion)
if expansion == "" {
return "", "", false
}
return term, expansion, true
}
// expand replaces every defined term in the document's text with an abbreviation.
//
// A transformer rather than an inline parser, because a definition may appear after the use it explains, and
// an inline parser only ever sees what it has already read.
type expand struct{}
func (expand) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
terms, _ := pc.Get(defined).(map[string]string)
if len(terms) == 0 {
return
}
// Longest first, so a definition of "HTML5" is not shadowed by one of "HTML".
ordered := make([]string, 0, len(terms))
for term := range terms {
ordered = append(ordered, term)
}
sort.Slice(ordered, func(i, j int) bool { return len(ordered[i]) > len(ordered[j]) })
source := reader.Source()
var texts []*ast.Text
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch n.Kind() {
case ast.KindCodeSpan, ast.KindRawHTML, ast.KindAutoLink, abbrKind:
// An author's literal text, a tag, a URL, and an expansion already made: none of them is prose.
return ast.WalkSkipChildren, nil
case ast.KindText:
texts = append(texts, n.(*ast.Text))
}
return ast.WalkContinue, nil
})
for _, node := range texts {
replace(node, source, terms, ordered)
}
}
// replace swaps one text node for the alternating run of text and abbreviations it contains.
func replace(node *ast.Text, source []byte, terms map[string]string, ordered []string) {
parent := node.Parent()
if parent == nil {
return
}
seg := node.Segment
body := string(source[seg.Start:seg.Stop])
at := 0
var built []ast.Node
for at < len(body) {
term, index := firstMatch(body[at:], ordered)
if term == "" {
break
}
start := at + index
if start > at {
built = append(built, ast.NewTextSegment(slice(seg, at, start)))
}
marked := &abbr{title: terms[term]}
marked.AppendChild(marked, ast.NewTextSegment(slice(seg, start, start+len(term))))
built = append(built, marked)
at = start + len(term)
}
if len(built) == 0 {
return
}
if at < len(body) {
built = append(built, ast.NewTextSegment(slice(seg, at, len(body))))
}
for _, n := range built {
parent.InsertBefore(parent, node, n)
}
parent.RemoveChild(parent, node)
}
// slice is the segment covering body[from:to].
func slice(seg text.Segment, from, to int) text.Segment {
out := seg
out.Start, out.Stop = seg.Start+from, seg.Start+to
return out
}
// firstMatch finds the earliest whole-word occurrence of any term. `ordered` is longest first and the
// comparison is strict, so two terms starting at the same place resolve to the longer one.
func firstMatch(body string, ordered []string) (string, int) {
best, at := "", -1
for _, term := range ordered {
index := earliest(body, term)
if index >= 0 && (at < 0 || index < at) {
best, at = term, index
}
}
return best, at
}
// earliest is the first whole-word occurrence of one term, or -1. Its own function so firstMatch stays flat:
// skipping a match that turned out to sit inside a longer word is a loop, not a condition.
func earliest(body, term string) int {
for from := 0; from+len(term) <= len(body); {
index := strings.Index(body[from:], term)
if index < 0 {
return -1
}
index += from
if whole(body, index, len(term)) {
return index
}
from = index + 1
}
return -1
}
// whole reports whether the match at index stands alone rather than sitting inside a longer word.
func whole(body string, index, length int) bool {
if index > 0 {
before, _ := utf8.DecodeLastRuneInString(body[:index])
if unicode.IsLetter(before) || unicode.IsDigit(before) {
return false
}
}
if end := index + length; end < len(body) {
after, _ := utf8.DecodeRuneInString(body[end:])
if unicode.IsLetter(after) || unicode.IsDigit(after) {
return false
}
}
return true
}
// renderDefinition writes nothing: the line was a note to the parser.
func renderDefinition(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
return ast.WalkSkipChildren, nil
}
// renderAbbr writes the element. The title is authored text going into an attribute, so it is escaped —
// correctness rather than trust: an apostrophe or a quote in an expansion would otherwise end the attribute.
func renderAbbr(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
_, _ = w.WriteString("</abbr>")
return ast.WalkContinue, nil
}
_, _ = w.WriteString(`<abbr title="`)
_, _ = w.Write(util.EscapeHTML([]byte(n.(*abbr).title)))
_, _ = w.WriteString(`">`)
return ast.WalkContinue, nil
}
func (nodeRenderer) registerAbbr(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(definitionKind, renderDefinition)
reg.Register(abbrKind, renderAbbr)
}