diff --git a/cmd/khosra/wire.go b/cmd/khosra/wire.go
index 5015d18..d129bb8 100644
--- a/cmd/khosra/wire.go
+++ b/cmd/khosra/wire.go
@@ -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),
}
}
diff --git a/docs/content-model.md b/docs/content-model.md
index d721ea1..677f4fc 100644
--- a/docs/content-model.md
+++ b/docs/content-model.md
@@ -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
diff --git a/docs/decisions.md b/docs/decisions.md
index 9df8be8..6f0be06 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -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 `H2O` — 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.
diff --git a/docs/state.md b/docs/state.md
index 3cedff7..f861915 100644
--- a/docs/state.md
+++ b/docs/state.md
@@ -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).
diff --git a/docs/surface.md b/docs/surface.md
index 722172e..824f042 100644
--- a/docs/surface.md
+++ b/docs/surface.md
@@ -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
diff --git a/docs/theme-contract.md b/docs/theme-contract.md
index 2ff00b7..df26aa3 100644
--- a/docs/theme-contract.md
+++ b/docs/theme-contract.md
@@ -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: `
| Gauge | ", "
|---|