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"
|
||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
|
|
||||||
|
"khosra/internal/ext/notation"
|
||||||
"khosra/internal/ext/shortcodes"
|
"khosra/internal/ext/shortcodes"
|
||||||
"khosra/internal/render"
|
"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
|
// 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).
|
// and internal/content must all build and serve with this list empty (conventions.md layering).
|
||||||
func extenders(partial render.Partial) []goldmark.Extender {
|
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.
|
// khosra's own. Every one is parse-phase, so none of them moves the render-transform counter.
|
||||||
return []goldmark.Extender{
|
return []goldmark.Extender{
|
||||||
extension.Table,
|
extension.Table,
|
||||||
extension.NewFootnote(extension.WithFootnoteIDPrefixFunction(shortcodes.FootnotePrefix)),
|
extension.NewFootnote(extension.WithFootnoteIDPrefixFunction(shortcodes.FootnotePrefix)),
|
||||||
extension.DefinitionList,
|
extension.DefinitionList,
|
||||||
extension.Strikethrough,
|
notation.New(),
|
||||||
shortcodes.New(partial),
|
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` |
|
| Footnotes | `text[^1]`, then `[^1]: the note` |
|
||||||
| Definition lists | a term, then `: definition` on the next line |
|
| Definition lists | a term, then `: definition` on the next line |
|
||||||
| Strikethrough | `~~struck~~` |
|
| 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 |
|
| 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
|
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
|
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
|
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.
|
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
|
Revisit if: an untrusted source appears — which is when the second renderer is built and this gate proves
|
||||||
whether the split was ever real.
|
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/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/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/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/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/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) |
|
| `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
|
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,
|
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`.
|
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
|
Markdown is CommonMark plus tables, footnotes, definition lists, heading ids and the inline marks
|
||||||
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
|
(`~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
|
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`
|
`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
|
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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| Interface implementations | — | **2** | The interface itself |
|
||||||
| Non-stdlib dependencies | 4 direct | budget in `scripts/budgets.env` | — |
|
| 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
|
**The Extensions counter is past its threshold, and a registry would still buy nothing.** The four features
|
||||||
unrelated ways: `shortcodes` is a goldmark extender listed in `extenders()`, while `check` and `scaffold` are
|
attach in three unrelated ways: `shortcodes` and `notation` are goldmark extenders listed in `extenders()`,
|
||||||
functions `cmd` calls for a subcommand. A registry would have to abstract over "extends Markdown", "validates
|
`check` and `scaffold` are functions `cmd` calls for a subcommand, and `watch` is a goroutine. A registry would
|
||||||
content" and "writes a file", which share nothing but the word *feature* — one member and no leverage. What the
|
have to abstract over "extends Markdown", "validates content", "writes a file" and "polls a directory", which
|
||||||
counter is really detecting is that two of the three are commands, and commands compose fine as a switch in
|
share nothing but the word *feature* — one member and no leverage. Adding `notation` made this clearer rather
|
||||||
`main`. Build the registry when a feature wants a **route** (the seam ADR-0042 named) or when two features need
|
than more urgent: two goldmark extenders compose in goldmark's own extender list, which is already the registry
|
||||||
to agree on an order.
|
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),
|
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).
|
`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: 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`.
|
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)
|
- check.go:16 func runCheck(args []string)
|
||||||
- main.go:24 func main()
|
- 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:174 func defaultCache() string
|
||||||
- main.go:184 func fatal(msg string, err error)
|
- main.go:184 func fatal(msg string, err error)
|
||||||
- new.go:12 func runNew(args []string)
|
- 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
|
## 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:172 func asset(fsys fs.FS, trimmed string, site *content.Site) bool
|
||||||
- check.go:200 func mixedOrdering(bundles []content.Bundle, site *content.Site) []Finding
|
- 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
|
## internal/ext/scaffold — 102 lines + 89 test
|
||||||
|
|
||||||
doc.go 8 · scaffold.go 94
|
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:111 type Item struct
|
||||||
- view.go:122 type Alternate 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
|
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
|
`.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>`,
|
(`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`.
|
`<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
|
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~~.
|
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
|
The notation marks cover what prose needs most often: the gauge measures H~2~O to about 10^-3^ m, and the
|
||||||
10<sup>-3</sup> m, and the reading is taken with <kbd>Shift</kbd> held. Content in the site root is the
|
==important== column is the second. Where there is no mark for something, HTML does it: the reading is taken
|
||||||
author's own, so it renders rather than being dropped.
|
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 |
|
| 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"
|
"github.com/yuin/goldmark/extension"
|
||||||
|
|
||||||
"khosra/internal/content"
|
"khosra/internal/content"
|
||||||
|
"khosra/internal/ext/notation"
|
||||||
"khosra/internal/ext/shortcodes"
|
"khosra/internal/ext/shortcodes"
|
||||||
"khosra/internal/render"
|
"khosra/internal/render"
|
||||||
)
|
)
|
||||||
@@ -49,7 +50,7 @@ func exampleSite(t *testing.T) http.Handler {
|
|||||||
extension.Table,
|
extension.Table,
|
||||||
extension.NewFootnote(extension.WithFootnoteIDPrefixFunction(shortcodes.FootnotePrefix)),
|
extension.NewFootnote(extension.WithFootnoteIDPrefixFunction(shortcodes.FootnotePrefix)),
|
||||||
extension.DefinitionList,
|
extension.DefinitionList,
|
||||||
extension.Strikethrough,
|
notation.New(),
|
||||||
shortcodes.New(p),
|
shortcodes.New(p),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -119,7 +120,10 @@ var exampleFeatures = []featureCase{
|
|||||||
expect: []string{`<h2 id="method">Method</h2>`, "<em>Emphasis and links survive</em>"}},
|
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: "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,
|
{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,
|
{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>"}},
|
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,
|
{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