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.
This commit is contained in:
Claude Opus 5
2026-08-01 21:25:44 +06:00
committed by bdeshi
parent 53e9ef473a
commit 661fb469e8
10 changed files with 393 additions and 26 deletions
+61
View File
@@ -86,3 +86,64 @@ func TestCodeSpansAreUntouched(t *testing.T) {
t.Errorf("a code span must survive verbatim:\n%s", got)
}
}
func TestAbbreviationsExpandWhereverTheyAppear(t *testing.T) {
got := html(t, "The HTML spec.\n\n*[HTML]: HyperText Markup Language\n\nHTML again.\n")
if n := strings.Count(got, `<abbr title="HyperText Markup Language">HTML</abbr>`); n != 2 {
t.Errorf("both uses should expand, got %d:\n%s", n, got)
}
// A definition may follow the use it explains, which is why this is a transformer and not a parser.
if strings.Contains(got, "*[HTML]") || strings.Contains(got, "HyperText Markup Language<") {
t.Errorf("the definition line is a note to the parser, not content:\n%s", got)
}
}
func TestAnAbbreviationIsAWholeWord(t *testing.T) {
got := html(t, "HTML and HTMLish and xHTML.\n\n*[HTML]: HyperText Markup Language\n")
if strings.Count(got, "<abbr") != 1 {
t.Errorf("only the standalone use should expand:\n%s", got)
}
for _, keep := range []string{"HTMLish", "xHTML"} {
if !strings.Contains(got, keep) {
t.Errorf("%q should survive untouched:\n%s", keep, got)
}
}
}
func TestTheLongestDefinitionWins(t *testing.T) {
got := html(t, "HTML5 and HTML.\n\n*[HTML]: Markup\n*[HTML5]: The fifth one\n")
if !strings.Contains(got, `<abbr title="The fifth one">HTML5</abbr>`) {
t.Errorf("HTML5 should not be shadowed by HTML:\n%s", got)
}
if !strings.Contains(got, `<abbr title="Markup">HTML</abbr>`) {
t.Errorf("the shorter term should still expand on its own:\n%s", got)
}
}
func TestCodeAndUrlsAreNotExpanded(t *testing.T) {
got := html(t, "Use `HTML` here and <https://HTML.example/> there, but HTML in prose.\n\n*[HTML]: Markup\n")
if strings.Count(got, "<abbr") != 1 {
t.Errorf("only the prose use should expand:\n%s", got)
}
if !strings.Contains(got, "<code>HTML</code>") {
t.Errorf("a code span is literal text:\n%s", got)
}
}
func TestAnExpansionCannotBreakItsAttribute(t *testing.T) {
got := html(t, `Term T here.`+"\n\n"+`*[T]: a "quoted" thing`+"\n")
if strings.Contains(got, `title="a "quoted" thing"`) {
t.Errorf("the attribute was broken by the expansion:\n%s", got)
}
if !strings.Contains(got, "&quot;quoted&quot;") {
t.Errorf("the expansion should be escaped into the attribute:\n%s", got)
}
}
// A list item starts with an asterisk too, so the definition form must not eat one.
func TestAListItemIsNotADefinition(t *testing.T) {
got := html(t, "* [a link](/x/): still a list\n")
if !strings.Contains(got, "<li>") {
t.Errorf("a list item must survive:\n%s", got)
}
}