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:
Claude Opus 5
2026-08-01 21:21:04 +06:00
committed by bdeshi
parent 1f168d973b
commit 53e9ef473a
11 changed files with 332 additions and 22 deletions
+8
View File
@@ -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
+151
View File
@@ -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
}
+88
View File
@@ -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)
}
}
+6 -2
View File
@@ -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,