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
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"khosra/internal/ext/notation"
"khosra/internal/ext/shortcodes"
"khosra/internal/render"
)
@@ -14,13 +15,13 @@ import (
// It lives in cmd because nothing below it may know which features exist: internal/render, internal/web
// and internal/content must all build and serve with this list empty (conventions.md layering).
func extenders(partial render.Partial) []goldmark.Extender {
// Dialect before features: these four say what Markdown *means* here (ADR-0058), and shortcodes is
// Dialect before features: these say what Markdown *means* here (ADR-0058, ADR-0061), and shortcodes is
// khosra's own. Every one is parse-phase, so none of them moves the render-transform counter.
return []goldmark.Extender{
extension.Table,
extension.NewFootnote(extension.WithFootnoteIDPrefixFunction(shortcodes.FootnotePrefix)),
extension.DefinitionList,
extension.Strikethrough,
notation.New(),
shortcodes.New(partial),
}
}
+8
View File
@@ -317,8 +317,16 @@ them is a render transform, and the list lives in `cmd/khosra/wire.go` where fea
| Footnotes | `text[^1]`, then `[^1]: the note` |
| Definition lists | a term, then `: definition` on the next line |
| Strikethrough | `~~struck~~` |
| Subscript | `H~2~O` |
| Superscript | `10^6^` |
| Highlight | `==marked==` |
| Heading ids | automatic, from the heading's text — the anchor a table of contents needs |
The last four are khosra's own (ADR-0061), because goldmark's strikethrough claims a single tilde as well as
a double and would read `H~2~O` as struck text. **A subscript or superscript may not contain a space** — it
holds a formula, not a phrase — which is what keeps `x^2 + y^2` prose. Its content is taken literally, so
there is no emphasis inside one.
Deliberately absent, so their absence is a decision rather than an oversight: **task lists** (a note-taking
affordance, not a publishing one), **linkify** (it rewrites an author's plain text into markup, which is the
line ADR-0034 draws), **CJK line breaking** (wrong script family — it does nothing for Bengali), and the
+20
View File
@@ -969,3 +969,23 @@ their choice, but it means an author who pastes something they did not read has
comments arrive, the untrusted renderer must be built rather than assumed, with the gate as the reminder.
Revisit if: an untrusted source appears — which is when the second renderer is built and this gate proves
whether the split was ever real.
## ADR-0061 — Inline notation is khosra's, and it owns the tilde
Date: 2026-08-01 · Status: accepted (amends ADR-0058: strikethrough is unchanged as an authored syntax, but
`internal/ext/notation` provides it instead of `extension.Strikethrough`)
Decision: `internal/ext/notation` adds `~sub~`, `^sup^` and `==mark==`, and takes over `~~strike~~`. 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. Extensions counter 3 → 4.
Why: goldmark's strikethrough claims a *single* tilde as well as a double, so with it enabled `H~2~O`
rendered `H<del>2</del>O` — measured before this change. Two features cannot share a byte and both be
correct, so one of them has to own it; taking strikethrough is cheaper than inventing a subscript syntax
nobody else uses, and it leaves the authored form exactly as ADR-0058 documented it. The whitespace rule is
the second half: under delimiter rules `x^2 + y^2 = z^2` pairs its carets across the expression and turns
prose into markup, which is the same class of silent damage raw HTML dropping caused. Pandoc draws the line
in the same place, so a subscript holds a formula and never a phrase.
Consequence: cheap — four marks from one table, and the two failure modes are now tests rather than
surprises. Expensive — a single-run mark takes its content as text, so `~*a*~` is not emphasised inside a
subscript, which is a limit worth stating rather than discovering; and khosra now maintains a strikethrough
implementation it used to get from upstream.
Revisit if: a mark wants markup inside a single run — which needs recursive inline parsing and is a
different mechanism, not a wider table.
+13 -10
View File
@@ -26,6 +26,7 @@ table owns.
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) |
| `internal/render/templates/` | reference theme, complete: `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026, ADR-0049) |
| `internal/ext/shortcodes/` | first feature: `::name{key=value}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044). `FootnotePrefix` namespaces an included file's footnote ids (ADR-0058). Directive syntax since ADR-0059 |
| `internal/ext/notation/` | the inline marks CommonMark lacks: `~sub~`, `^sup^`, `==mark==`, and `~~strike~~`, which it owns so a single tilde can mean subscript (ADR-0061) |
| `internal/ext/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite |
| `internal/ext/watch/` | polls `content/` and `templates/` on an interval it is given, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048, ADR-0056). `site.yaml` is deliberately not fingerprinted (ADR-0055) |
| `internal/ext/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering, and calls left in the retired shortcode form (ADR-0059) |
@@ -46,8 +47,8 @@ key (ADR-0035) — a paginated listing per section, tag listings global and
section-narrowed, sequence navigation and a series archive on any nested bundle, `static/` verbatim, a directory bundle's own files under its
URL, generated derivatives under `/derived/`, Atom feeds per site,
section and tag, a bundle's extras as a browsable tree, plus `/robots.txt` and `/sitemap.xml`.
Markdown is CommonMark plus tables, footnotes, definition lists, strikethrough and heading ids, and nothing
else (ADR-0058); HTML an author writes renders, because the site root is trusted (ADR-0060). Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
Markdown is CommonMark plus tables, footnotes, definition lists, heading ids and the inline marks
(`~sub~`, `^sup^`, `==mark==`, `~~strike~~`), and nothing else (ADR-0058, ADR-0061); HTML an author writes renders, because the site root is trusted (ADR-0060). Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
smoothing (ADR-0034); line breaking is left to CSS (ADR-0045). This repo holds engine source only — the site root is external and passed with
`khosra check` validates a site root and exits non-zero on anything that makes it wrong; `khosra new`
scaffolds a draft bundle into one. A running server notices changes under `content/` and `templates/` by
@@ -83,17 +84,19 @@ this change*.
| Collection pages | 4 | **1** — done | Query primitive: `content.Query{Section, Tag, Lang}` + `Site.Run`. The fourth — a series archive — resolves through `Site.Sequence` instead: membership is structural and the sort ascends, so it shares the index but not the Query |
| Views — **per-bundle selection only** | 0 | **2** | The View layer `architecture.md` describes: `view:` in frontmatter choosing a presentation, resolved through the cascade. Nothing selects a view yet. *Output formats* are counted separately and are not it: HTML, sitemap XML and Atom are three functions with nothing to share — an interface over them would have one member and no leverage |
| Effects | 1 | **2** | Effect runner + trigger wiring (change / schedule / demand). The first and only is the derivative pass (ADR-0042), called straight from `cmd` inside `rebuilder`, so it already answers both triggers it will ever need — startup and a settled change (ADR-0048) — and one call needs no runner. Swapping the index or the theme is **not** an Effect: both re-read the site root into memory, writing no artifact and calling nothing outbound (ADR-0055) |
| Extensions | 3 | **3**due, and the answer is still no | Extension registry (`extensions.md`). It reached 3 once before and went back to 2 when the widows feature was deleted (ADR-0045) — a threshold reached by a feature that should not exist was never a threshold. It is 3 again with `scaffold`, and the note below the table says why a registry still buys nothing |
| Extensions | 4 | **3**passed, and the answer is still no | Extension registry (`extensions.md`). It reached 3 once before and went back to 2 when the widows feature was deleted (ADR-0045) — a threshold reached by a feature that should not exist was never a threshold. It is 4 with `notation`, and the note below the table says why a registry still buys nothing |
| Interface implementations | — | **2** | The interface itself |
| Non-stdlib dependencies | 4 direct | budget in `scripts/budgets.env` | — |
**The Extensions counter is due, and a registry would still buy nothing.** The three features attach in two
unrelated ways: `shortcodes` is a goldmark extender listed in `extenders()`, while `check` and `scaffold` are
functions `cmd` calls for a subcommand. A registry would have to abstract over "extends Markdown", "validates
content" and "writes a file", which share nothing but the word *feature* — one member and no leverage. What the
counter is really detecting is that two of the three are commands, and commands compose fine as a switch in
`main`. Build the registry when a feature wants a **route** (the seam ADR-0042 named) or when two features need
to agree on an order.
**The Extensions counter is past its threshold, and a registry would still buy nothing.** The four features
attach in three unrelated ways: `shortcodes` and `notation` are goldmark extenders listed in `extenders()`,
`check` and `scaffold` are functions `cmd` calls for a subcommand, and `watch` is a goroutine. A registry would
have to abstract over "extends Markdown", "validates content", "writes a file" and "polls a directory", which
share nothing but the word *feature* — one member and no leverage. Adding `notation` made this clearer rather
than more urgent: two goldmark extenders compose in goldmark's own extender list, which is already the registry
for that shape, and they need no order relative to each other because one is inline and the other block.
Build the registry when a feature wants a **route** (the seam ADR-0042 named) or when two features genuinely
need to agree on an order that no existing mechanism expresses.
Allowlist, all four imported: `goldmark` (markdown), `golang.org/x/text` (NFC, ADR-0015),
`gopkg.in/yaml.v3` (frontmatter, ADR-0020), `golang.org/x/image` (resampling and WebP, ADR-0040).
+30 -4
View File
@@ -6,9 +6,9 @@ Every top-level declaration in the engine, with its line. Read this before openi
file: it answers "where does X live" and "what is in this package" without the bodies. What each
file is *for* lives in `state.md`; why it is that way lives in `decisions.md`.
## cmd/khosra — 304 lines + 63 test
## cmd/khosra — 305 lines + 63 test
check.go 45 · main.go 191 · new.go 42 · wire.go 26
check.go 45 · main.go 191 · new.go 42 · wire.go 27
- check.go:16 func runCheck(args []string)
- main.go:24 func main()
@@ -20,7 +20,7 @@ check.go 45 · main.go 191 · new.go 42 · wire.go 26
- main.go:174 func defaultCache() string
- main.go:184 func fatal(msg string, err error)
- new.go:12 func runNew(args []string)
- wire.go:16 func extenders(partial render.Partial) []goldmark.Extender
- wire.go:17 func extenders(partial render.Partial) []goldmark.Extender
## internal/content — 1042 lines + 537 test
@@ -113,6 +113,32 @@ check.go 223 · doc.go 8
- check.go:172 func asset(fsys fs.FS, trimmed string, site *content.Site) bool
- check.go:200 func mixedOrdering(bundles []content.Bundle, site *content.Site) []Finding
## internal/ext/notation — 159 lines + 88 test
doc.go 8 · notation.go 151
- notation.go:17 var marks = []mark{
- notation.go:27 type mark struct
- notation.go:33 func New() goldmark.Extender { return extension{} }
- notation.go:35 type extension struct{}
- notation.go:41 func (extension) Extend(md goldmark.Markdown)
- notation.go:51 var kind = ast.NewNodeKind("Notation")
- notation.go:54 type node struct
- notation.go:59 func (n *node) Kind() ast.NodeKind { return kind }
- notation.go:61 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- notation.go:65 type processor struct{ tag string }
- notation.go:67 func (p processor) IsDelimiter(b byte) bool { return b == '~' || b == '=' }
- notation.go:69 func (p processor) CanOpenCloser(opener, closer *parser.Delimiter) bool
- notation.go:73 func (p processor) OnMatch(consumes int) ast.Node { return &node{tag p.tag} }
- notation.go:76 type inlineParser struct{ m mark }
- notation.go:78 func (p inlineParser) Trigger() []byte { return []byte{p.m.char} }
- notation.go:80 func (p inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node
- notation.go:89 func (p inlineParser) doubled(block text.Reader, line []byte, segment text.Segment, pc parser.Context) ast.Node
- notation.go:108 func (p inlineParser) single(block text.Reader, line []byte, segment text.Segment) ast.Node
- notation.go:137 type nodeRenderer struct{}
- notation.go:139 func (nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- notation.go:143 func render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
## internal/ext/scaffold — 102 lines + 89 test
doc.go 8 · scaffold.go 94
@@ -228,7 +254,7 @@ chrome.go 110 · render.go 473 · view.go 130
- view.go:111 type Item struct
- view.go:122 type Alternate struct
## internal/web — 734 lines + 1601 test
## internal/web — 734 lines + 1605 test
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217
+1 -1
View File
@@ -152,7 +152,7 @@ not insert characters into an author's prose to influence line breaking.
`.HTML` is Markdown output, so the dialect decides which elements a theme has to be ready to style
(`content-model.md`). Beyond CommonMark's own, since ADR-0058: `<table>` with `<thead>`/`<tbody>`,
`<dl>`/`<dt>`/`<dd>`, `<del>`, and goldmark's footnote markup — `<sup id="fnref:N">` in the text and a
`<dl>`/`<dt>`/`<dd>`, `<del>`, `<sub>`, `<sup>`, `<mark>`, and goldmark's footnote markup — `<sup id="fnref:N">` in the text and a
`<div class="footnotes">` carrying an `<ol>` of notes. Headings arrive with an `id`.
None of it is optional and none of it is configurable: a theme that styles none of these still renders a
@@ -16,9 +16,10 @@ This bundle also has an `extras/` directory, so the theme offers a link to it at
The page has a footnote of its own[^page], a table, a definition list and ~~a struck phrase~~.
Where the dialect has no syntax for something, HTML does: the gauge measures H<sub>2</sub>O to about
10<sup>-3</sup> m, and the reading is taken with <kbd>Shift</kbd> held. Content in the site root is the
author's own, so it renders rather than being dropped.
The notation marks cover what prose needs most often: the gauge measures H~2~O to about 10^-3^ m, and the
==important== column is the second. Where there is no mark for something, HTML does it: the reading is taken
with <kbd>Shift</kbd> held. Content in the site root is the author's own, so it renders rather than being
dropped.
| Gauge | Reading | Note |
|-------|---------|------|
+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,