`*[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.
247 lines
7.5 KiB
Go
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)
|
|
}
|