add inline notation, and take the tilde back from strikethrough
~sub~, ^sup^, ==mark==, and ~~strike~~ moved in from goldmark. Not a preference: goldmark's strikethrough claims a single tilde as well as a double, so with it enabled H~2~O rendered as H<del>2</del>O — measured before the change. Two features cannot share a byte and both be correct, so notation owns it and the authored syntax stays exactly as ADR-0058 documented. The second failure was worse and only showed up under test. Under delimiter rules `x^2 + y^2 = z^2` pairs its carets across the whole expression and renders x<sup>2 + y</sup>2 — prose silently becoming markup, in exactly the content this engine is for. So a single run is scanned rather than paired, and may not cross whitespace: a subscript holds a formula, never a phrase. Pandoc draws the same line. The cost is that a single run takes its content literally, so there is no emphasis inside a subscript, which the ADR states rather than leaving to be discovered. New package under internal/ext, which is a stop condition and was asked. It takes the extensions counter to 4, past its threshold, and the answer is still no: four features attach in three unrelated ways, and two goldmark extenders compose in goldmark's own extender list, which is already the registry for that shape. The example site's hand-copied extender list drifted, exactly as the latent row added last loop predicted — the demo case failed and named it. Both are now in step again. core 2794/2800, ext 1236/2000, 34 gates green, 0 warnings.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
// Package notation adds the inline marks CommonMark has no syntax for: subscript, superscript,
|
||||
// strikethrough and highlight.
|
||||
//
|
||||
// Every one is a delimiter pair around text, so they are one mechanism rather than four (ADR-0061). The
|
||||
// tilde carries two meanings by run length — `~2~` subscripts and `~~struck~~` strikes — which is Pandoc's
|
||||
// rule and the reason strikethrough lives here rather than in goldmark's own extension: sharing a byte
|
||||
// between two features is how they break each other.
|
||||
package notation
|
||||
@@ -0,0 +1,151 @@
|
||||
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...))
|
||||
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 (nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(kind, render)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/yuin/goldmark/extension"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/ext/notation"
|
||||
"khosra/internal/ext/shortcodes"
|
||||
"khosra/internal/render"
|
||||
)
|
||||
@@ -49,7 +50,7 @@ func exampleSite(t *testing.T) http.Handler {
|
||||
extension.Table,
|
||||
extension.NewFootnote(extension.WithFootnoteIDPrefixFunction(shortcodes.FootnotePrefix)),
|
||||
extension.DefinitionList,
|
||||
extension.Strikethrough,
|
||||
notation.New(),
|
||||
shortcodes.New(p),
|
||||
}
|
||||
})
|
||||
@@ -119,7 +120,10 @@ var exampleFeatures = []featureCase{
|
||||
expect: []string{`<h2 id="method">Method</h2>`, "<em>Emphasis and links survive</em>"}},
|
||||
{what: "a fragment is not a bundle", path: "/writing/notes-on-water/_method/", code: 404},
|
||||
{what: "authored HTML renders, because the site root is trusted", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{"H<sub>2</sub>O", "<kbd>Shift</kbd>"}, absent: []string{"raw HTML omitted"}},
|
||||
expect: []string{"<kbd>Shift</kbd>"}, absent: []string{"raw HTML omitted"}},
|
||||
{what: "notation marks become elements, and a single tilde is a subscript not a strike", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{"H<sub>2</sub>O", "10<sup>-3</sup>", "<mark>important</mark>", "<del>a struck phrase</del>"},
|
||||
absent: []string{"<del>2</del>"}},
|
||||
{what: "the dialect renders tables, definition lists and strikethrough", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{"<table>", "<th>Gauge</th>", "<dl>", "<dt>Monsoon</dt>", "<del>a struck phrase</del>"}},
|
||||
{what: "a fragment's footnote ids are namespaced, so the page's own keep working", path: "/writing/notes-on-water/", code: 200,
|
||||
|
||||
Reference in New Issue
Block a user