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:
+3
-2
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|-------|---------|------|
|
||||
|
||||
@@ -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