`*[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.
150 lines
5.4 KiB
Go
150 lines
5.4 KiB
Go
package notation
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/yuin/goldmark"
|
|
)
|
|
|
|
func html(t *testing.T, markdown string) string {
|
|
t.Helper()
|
|
md := goldmark.New(goldmark.WithExtensions(New()))
|
|
var out bytes.Buffer
|
|
if err := md.Convert([]byte(markdown), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func TestEachMarkBecomesItsElement(t *testing.T) {
|
|
for _, c := range []struct{ in, want string }{
|
|
{"H~2~O", "H<sub>2</sub>O"},
|
|
{"10^6^ of them", "10<sup>6</sup> of them"},
|
|
{"==marked==", "<mark>marked</mark>"},
|
|
{"~~struck~~", "<del>struck</del>"},
|
|
// Nested and adjacent marks are ordinary inline text, so emphasis still works around them.
|
|
{"*a ~1~ b*", "<em>a <sub>1</sub> b</em>"},
|
|
{"CO~2~ and H~2~O", "CO<sub>2</sub> and H<sub>2</sub>O"},
|
|
} {
|
|
if got := html(t, c.in); !strings.Contains(got, c.want) {
|
|
t.Errorf("%q rendered %s, want it to contain %q", c.in, strings.TrimSpace(got), c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The reason this package owns the tilde. goldmark's own strikethrough treats one tilde as a strike, which
|
|
// turns a chemical formula into struck text — measured on the real binary before ADR-0061.
|
|
func TestOneTildeIsSubscriptAndTwoIsStrikethrough(t *testing.T) {
|
|
got := html(t, "H~2~O is not ~~struck~~")
|
|
if !strings.Contains(got, "H<sub>2</sub>O") {
|
|
t.Errorf("a single tilde must subscript, not strike:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "<del>struck</del>") {
|
|
t.Errorf("a double tilde must still strike:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "<del>2</del>") {
|
|
t.Errorf("the formula was struck instead of subscripted:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// Prose is full of these bytes. An unpaired or meaningless run has to stay exactly as typed.
|
|
func TestProseKeepsItsPunctuation(t *testing.T) {
|
|
for _, c := range []struct{ in, keep string }{
|
|
{"a = b and c == d", "=="}, // `==` needs no space to open; `c == d` has one either side
|
|
{"x^2 + y^2 = z^2", "x^2 + y^2"}, // unpaired carets
|
|
{"the range 10~20 is wide", "10~20"},
|
|
{"a ~ b", "~"},
|
|
} {
|
|
got := html(t, c.in)
|
|
if !strings.Contains(got, c.keep) {
|
|
t.Errorf("%q lost its punctuation: %s", c.in, strings.TrimSpace(got))
|
|
}
|
|
for _, tag := range []string{"<sub>", "<sup>", "<mark>", "<del>"} {
|
|
if strings.Contains(got, tag) {
|
|
t.Errorf("%q produced %s: %s", c.in, tag, strings.TrimSpace(got))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A run length the table has no meaning for must not match at a shorter one.
|
|
func TestAnUndefinedRunLengthDoesNotMatch(t *testing.T) {
|
|
if got := html(t, "=marked="); strings.Contains(got, "<mark>") {
|
|
t.Errorf("a single = is not a highlight: %s", strings.TrimSpace(got))
|
|
}
|
|
if got := html(t, "^^up^^"); strings.Contains(got, "<sup>") {
|
|
t.Errorf("a double ^ is not a superscript: %s", strings.TrimSpace(got))
|
|
}
|
|
}
|
|
|
|
// Code spans are the author's literal text, whatever bytes are in them.
|
|
func TestCodeSpansAreUntouched(t *testing.T) {
|
|
got := html(t, "`H~2~O` and `a==b`")
|
|
if strings.Contains(got, "<sub>") || strings.Contains(got, "<mark>") {
|
|
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, ""quoted"") {
|
|
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)
|
|
}
|
|
}
|