Answer prose round 1: the refusal list lives in the error row, the slot rule gives its reason, and stale item citations go
CI / gate (push) Successful in 51s
CI / publish (push) Has been skipped

This commit is contained in:
2026-10-04 21:09:51 +02:00
parent d88f4a4351
commit 04931ab35a
9 changed files with 37 additions and 39 deletions
+5 -4
View File
@@ -18,11 +18,12 @@
spelled `-0`, and a `codeBlock` of several text nodes is a fence per node. A `codeBlock` holding spelled `-0`, and a `codeBlock` of several text nodes is a fence per node. A `codeBlock` holding
other than plain text nodes rides the block carry, where it was refused. other than plain text nodes rides the block carry, where it was refused.
- **Breaking:** `unspellable-character` and `unspellable-line-start` leave `ConvertErrorCode`, and - **Breaking:** `unspellable-character` and `unspellable-line-start` leave `ConvertErrorCode`, and
`adfToMarkdown` no longer refuses `unspellable-whitespace`. A carriage return in text is spelled `adfToMarkdown` no longer returns `unspellable-whitespace`. A carriage return in text is spelled
`
` and a null character `!adf:text{text="\u0000"}`; a paragraph line opening with a code span `
` and a null character `!adf:text{text="\u0000"}`; a paragraph line opening with a code span
is spelled as one, its backticks opening no fence; a code block holding either character, a text is spelled as that code span, its backticks opening no fence; a code block holding either
node holding no text or holding `content`, and an `emoji`, `mention` or `status` whose `text` holds character, a code span holding a null character, a text node holding no text or holding
a line ending or a null character ride the carry. See `MIGRATION.md`. `content`, and an `emoji`, `mention` or `status` whose `text` holds a line ending or a null
character ride the carry. See `MIGRATION.md`.
- Fix a link `title` holding a null character: it is written as `!adf:link[text]{attrs}`, where it - Fix a link `title` holding a null character: it is written as `!adf:link[text]{attrs}`, where it
read back as U+FFFD. read back as U+FFFD.
- **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no - **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no
+3 -3
View File
@@ -63,8 +63,8 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
### Error codes ### Error codes
`unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`: `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`:
a `switch` naming one stops compiling. The document a removed code named on emit converts; the a `switch` naming one stops compiling. A document `adfToMarkdown` refused with one of them now
markdown it named on parse refuses with the code the table gives. converts; markdown the parser refused with one of them now refuses with the code the table gives.
| Input | `0.1.0` | `0.2.0` | | Input | `0.1.0` | `0.2.0` |
| --- | --- | --- | | --- | --- | --- |
@@ -73,7 +73,7 @@ markdown it named on parse refuses with the code the table gives.
| a null character in text, on emit | `unspellable-character` | spells `!adf:text{text="\u0000"}` | | a null character in text, on emit | `unspellable-character` | spells `!adf:text{text="\u0000"}` |
| a carriage return or null character in a code block, or a null character in a code span, on emit | `unspellable-character` | rides the carry | | a carriage return or null character in a code block, or a null character in a code span, on emit | `unspellable-character` | rides the carry |
| a paragraph line opening with a code span of three or more backticks, on emit | `unspellable-line-start` | spells the code span | | a paragraph line opening with a code span of three or more backticks, on emit | `unspellable-line-start` | spells the code span |
| a block directive whose CommonMark spelling holds one of the above, on parse | `unspellable-character`, `unspellable-line-start` | `unsupported-node-shape`; write the CommonMark spelling | | a block directive whose CommonMark spelling holds a carriage return, a null character or a code span opening a line, on parse | `unspellable-character`, `unspellable-line-start` | `unsupported-node-shape`; write the CommonMark spelling |
| an `emoji`, `mention` or `status` whose `text` holds a line ending or a null character, on emit | `unspellable-whitespace`, `unspellable-character` | rides the inline carry | | an `emoji`, `mention` or `status` whose `text` holds a line ending or a null character, on emit | `unspellable-whitespace`, `unspellable-character` | rides the inline carry |
| a text node holding no text, or holding `content`, on emit | `unsupported-node-shape` | rides the carry | | a text node holding no text, or holding `content`, on emit | `unsupported-node-shape` | rides the carry |
| a `codeBlock` holding other than plain text nodes, on emit | `unsupported-node-shape` | rides the block carry | | a `codeBlock` holding other than plain text nodes, on emit | `unsupported-node-shape` | rides the block carry |
+3 -4
View File
@@ -196,7 +196,7 @@ emit refuses:
| Code | Fires when | What you can do | | Code | Fires when | What you can do |
| --- | --- | --- | | --- | --- | --- |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
| `unsupported-node-shape` | parsing: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node listed under "Not every document converts back" below | fix what the message names; `spec/flavour.md` lists every type's attributes and body | | `unsupported-node-shape` | parsing: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; `spec/flavour.md` lists every type's attributes and body |
## The guarantees ## The guarantees
@@ -224,9 +224,8 @@ Serves Goals 1, 3 and 4.
text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other. text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other.
The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite
holds no example of the fourth. holds no example of the fourth.
- Not every document converts back: `adfToMarkdown` refuses a document nesting past 500 levels, a - Not every document converts back: `adfToMarkdown` refuses a document nesting past 500 levels, and
node carrying one mark type twice, a block or a non-text inline node holding `text`, and a leaf the node shapes the `unsupported-node-shape` row lists. Show the refusal and keep the document
block or a non-text inline node holding `content`. Show the refusal and keep the document
read-only; saving markdown you could not produce is the loss the round-trip exists to stop. read-only; saving markdown you could not produce is the loss the round-trip exists to stop.
- The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error - The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error
result rather than the prose it reads as, and an alignment colon in the delimiter row is an result rather than the prose it reads as, and an alignment colon in the delimiter row is an
+7 -7
View File
@@ -23,7 +23,7 @@ sanitized and a mention keeps the test user's real account id.
## Markdown in is a canonical fixpoint ## Markdown in is a canonical fixpoint
2026-08-23, the maintainer; the way back made total 2026-10-04. Goals 1 and 4. Valid while 2026-08-23, the maintainer; every parsed document converts back since 2026-10-04. Goals 1 and 4. Valid while
markdown input may be written by hand. markdown input may be written by hand.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
@@ -347,10 +347,10 @@ handles one cause alike whichever node, attribute or direction raised it.
- A code names the cause; where one cause recurs across node types, across one mark's attributes - A code names the cause; where one cause recurs across node types, across one mark's attributes
or across directions, one code covers them all and `path` and `message` say which — or across directions, one code covers them all and `path` and `message` say which —
`unsupported-nesting-depth` is the 500-level guard whichever direction hits it. Where two codes `unsupported-nesting-depth` is the 500-level guard whichever direction hits it.
stay apart, the line between them is what they name: `unspellable-whitespace` is the newline an `unspellable-whitespace` is the newline an inline directive's content slot spells in input; the
inline directive's content slot spells in input, where the emitter carries the node emitter carries such a node. A null character in that slot is `unsupported-node-shape`, since
(2026-10-04). `unspellable-whitespace` does not read true of it (2026-10-04).
- A claim code names the spelling claimed, never the node that spelling would have built: a - A claim code names the spelling claimed, never the node that spelling would have built: a
malformed `!adf:table` is a `malformed-directive`, and an alignment colon a malformed `!adf:table` is a `malformed-directive`, and an alignment colon a
`malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the `malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the
@@ -493,8 +493,8 @@ exits 0 on.
2026-10-03, the maintainer. KISS, a technical principle, and its comprehension floor of 7. Valid 2026-10-03, the maintainer. KISS, a technical principle, and its comprehension floor of 7. Valid
while `todo.md` items 59, 60, 61 and 62 are open. while `todo.md` items 59, 60, 61 and 62 are open.
A four-seat comprehension panel scored the project under the floor of 7. Its last scoring run A four-seat comprehension panel scored the project under the floor of 7. Its scoring run of
(item 55, 2026-10-04) is the baseline. Since 2026-10-04, a chunk whose panel scores any dimension 2026-10-04 is the baseline. Since 2026-10-04, a chunk whose panel scores any dimension
lower improves what the seats named and scores again, and after three such rounds asks the lower improves what the seats named and scores again, and after three such rounds asks the
maintainer. maintainer.
+9 -10
View File
@@ -450,8 +450,8 @@ the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in
plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
parsing to anything but one text node carrying neither marks, attributes nor content is a named parsing to anything but one text node carrying neither marks, attributes nor content is a named
error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot. error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot.
A node whose `text` holds a line ending or a null character rides the inline carry; a slot spelling An `emoji`, `mention` or `status` whose `text` attribute holds a line ending or a null character
one is a named error. rides the inline carry; a content slot spelling one is a named error.
The rest take no content, `!adf:text` included; content on a node that takes none is a named error. The rest take no content, `!adf:text` included; content on a node that takes none is a named error.
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds). - `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
@@ -475,11 +475,11 @@ Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=175608000000
``` ```
**Text CommonMark cannot hold plainly.** A carriage return in text is `&#13;`, which CommonMark reads **Text CommonMark cannot hold plainly.** A carriage return in text is `&#13;`, which CommonMark reads
back as the character. A newline inside a text node, a null character, which CommonMark reads as back as the character. Three things are spelled `!adf:text{text="…"}`: a newline inside a text
U+FFFD, and a space or tab where CommonMark strips or refuses one — a block's inline content edges, node; a null character, which CommonMark reads as U+FFFD; and a space or tab where CommonMark strips
either side of a line break, an em, strong or strike spelling's inner edges, a pipe cell's edges — or refuses one — a block's inline content edges, either side of a line break, an em, strong or
is spelled `!adf:text{text="…"}`, the reserved key carrying the node's text, escaped by the strike spelling's inner edges, a pipe cell's edges. The reserved key carries the node's text,
attribute grammar and never literal: pipe cells trim and pad. The emitter wraps the run alone and escaped by the attribute grammar and never literal: pipe cells trim and pad. The emitter wraps the run alone and
leaves the rest plain text, which the spelled run joins on reading. Input reads that spelling leaves the rest plain text, which the spelled run joins on reading. Input reads that spelling
alone: the value is one run of spaces and tabs, one run of newlines, or one run of null characters, alone: the value is one run of spaces and tabs, one run of newlines, or one run of null characters,
and anything else — a mixed run, or text CommonMark carries plainly — is a named error. and anything else — a mixed run, or text CommonMark carries plainly — is a named error.
@@ -535,9 +535,8 @@ breaks at every node the emitter carries, so no emitted carry sits inside a mark
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs and spelling does not list, a value that is not the spelling's type, an attribute the spelling needs and
the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside
another mark, `code` over anything but a text node holding text or over text holding a line ending another mark, `code` over anything but a text node holding text, or over text holding a line ending
or a null character, or a spelling or a null character, or a spelling CommonMark's flanking rules cannot open or close where the run sits (`un**-real**istic`), or one
CommonMark's flanking rules cannot open or close where the run sits (`un**-real**istic`), or one
CommonMark's matching pairs elsewhere — the intra-word `*` runs together with a neighbouring `**`, CommonMark's matching pairs elsewhere — the intra-word `*` runs together with a neighbouring `**`,
and the multiple-of-3 rule can leave the merged run's pairing to another delimiter — rides the and the multiple-of-3 rule can leave the merged run's pairing to another delimiter — rides the
inline carry whole. An opaque carry inside a mark spelling is a named error in input: the carry inline carry whole. An opaque carry inside a mark spelling is a named error in input: the carry
+1 -1
View File
@@ -65,7 +65,7 @@ export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; ma
return keys return keys
} }
// A format spells this node as text; anything more rides a carry. // No attrs, content or empty marks; a format may spell this node as text, and anything more rides a carry.
export function isBareText(node: AdfNode): boolean { export function isBareText(node: AdfNode): boolean {
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0 return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
} }
+1 -1
View File
@@ -100,7 +100,7 @@ export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read<
return { value: spelled.decoded } return { value: spelled.decoded }
} }
// What no content slot holds, which the emitter carries instead: an inline directive never spans lines, and CommonMark reads a null character as U+FFFD. // No content slot holds a line ending or a null character, which only the text directive spells; the emitter carries such a node.
export function slotFault(type: string, text: string): ConvertFault | undefined { export function slotFault(type: string, text: string): ConvertFault | undefined {
if (/[\n\r]/.test(text)) return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` } if (/[\n\r]/.test(text)) return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` }
return holdsNullCharacter(text) ? unsupportedNodeShape(`the ${type} content slot holds a null character: write the node as !adf:carry`) : undefined return holdsNullCharacter(text) ? unsupportedNodeShape(`the ${type} content slot holds a null character: write the node as !adf:carry`) : undefined
+1 -1
View File
@@ -272,7 +272,7 @@ function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertEr
return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n'))) return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n')))
} }
// The text each fence holds, or `undefined` where a child is no text a fence keeps, which the carry holds instead. // The text of each fence, or `undefined` where a child holds what no fence keeps, so the block carry takes the node.
function fencedTexts(node: AdfNode): string[] | undefined { function fencedTexts(node: AdfNode): string[] | undefined {
if (node.content === undefined) return [''] if (node.content === undefined) return ['']
const texts: string[] = [] const texts: string[] = []
+7 -8
View File
@@ -51,7 +51,7 @@
| 33 | 0.3.0 | | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 8 | 10.7 | | 33 | 0.3.0 | | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 8 | 10.7 |
| 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 7, 9 | 10.3 | | 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 7, 9 | 10.3 |
| 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 9 | 10.3 | | 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 9 | 10.3 |
| 69 | 0.3.0 | question | **Decide whether a text node's `content` is content `adfToPlainMarkdown` keeps, and keep or document it.** | 2 | 2 | 3 | 5 | 6 | 7.9 | | 69 | 0.3.0 | question | **Decide whether `adfToPlainMarkdown` keeps a text node's `content`, and keep it or name the drop in the README.** | 2 | 2 | 3 | 5 | 6 | 7.9 |
| 56 | 0.3.0 | principle | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.** | 4 | 5 | 2 | 4 | 1 | 6.8 | | 56 | 0.3.0 | principle | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.** | 4 | 5 | 2 | 4 | 1 | 6.8 |
| 57 | 0.3.0 | principle | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 3 | 1 | 3 | 7 | 1.2 | | 57 | 0.3.0 | principle | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 3 | 1 | 3 | 7 | 1.2 |
| 58 | 0.3.0 | principle | **Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.** | 4 | 6 | 1 | 2 | 7 | -2.2 | | 58 | 0.3.0 | principle | **Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.** | 4 | 6 | 1 | 2 | 7 | -2.2 |
@@ -62,8 +62,7 @@
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF. ### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.
Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs against Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs against
`markdownToHtml`. The README documents HTML as it documents markdown, and its tagline and `markdownToHtml`. The README documents HTML as it documents markdown.
`package.json`'s `description` keep naming HTML.
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`. ### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
@@ -76,7 +75,7 @@ cannot say which branch refused, where `not-an-adf-document`'s message already d
`quoteNode`'s title loop (`parse/markdown-to-adf.ts`) spreads `inlineLeaves`'s result into `quoteNode`'s title loop (`parse/markdown-to-adf.ts`) spreads `inlineLeaves`'s result into
`push`, against `docs/decisions.md` §Nothing spreads an unbounded array: an inline carry in a `push`, against `docs/decisions.md` §Nothing spreads an unbounded array: an inline carry in a
folded callout's title holding an unknown node with ~125k text children throws a `RangeError` from folded callout's title holding an unknown node with ~125k text children throws a `RangeError` from
`plainMarkdownToAdf`. Found by the README-goals audit of item 55, 2026-10-04. `plainMarkdownToAdf`. Found by the README-goals audit, 2026-10-04.
### 6. Specify the HTML dialect. ### 6. Specify the HTML dialect.
@@ -126,8 +125,8 @@ writer panel chose this reading 3 of 3 (2026-10-04), by Goal 5, which outranks G
`refuseContentAndText` (`emit/inline-line.ts`) and `emitDirectiveBlock` (`emit/adf-to-markdown.ts`) `refuseContentAndText` (`emit/inline-line.ts`) and `emitDirectiveBlock` (`emit/adf-to-markdown.ts`)
refuse these with `unsupported-node-shape`, though `isAdfDocument` accepts them and the carry refuse these with `unsupported-node-shape`, though `isAdfDocument` accepts them and the carry
round-trips them; `docs/decisions.md` §The code list gives a cause the carry answers no code. The round-trips them; `docs/decisions.md` §The code list gives a cause the carry answers no code. The
README's "Not every document converts back" bullet loses them. Found by the README-goals audit of Drop them from the README's "Not every document converts back" bullet. Found by the README-goals
item 55, 2026-10-04. audit, 2026-10-04.
### 49. Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`. ### 49. Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.
@@ -264,7 +263,7 @@ or report the unminified gzip). The figure lands in README §The package beside
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6 dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
kB. kB.
### 69. Decide whether a text node's `content` is content `adfToPlainMarkdown` keeps, and keep or document it. ### 69. Decide whether `adfToPlainMarkdown` keeps a text node's `content`, and keep it or name the drop in the README.
`nodeLeaves` (`plain/inline-reduction.ts`) keeps only a text node's `text`, so `nodeLeaves` (`plain/inline-reduction.ts`) keeps only a text node's `text`, so
`{ text: 'a', content: [{ text: 'kept', type: 'text' }] }` writes `a`, though `toEditorNormal` `{ text: 'a', content: [{ text: 'kept', type: 'text' }] }` writes `a`, though `toEditorNormal`
@@ -272,7 +271,7 @@ keeps that content (`plain/editor-normal.ts`) and an unknown node's children kee
Atlassian's schema allows no `content` on a text node, so no Atlassian reader saw it; an Atlassian's schema allows no `content` on a text node, so no Atlassian reader saw it; an
export/indexing tool fed a looser producer loses searchable text. Goal 6 reads two ways here: keep export/indexing tool fed a looser producer loses searchable text. Goal 6 reads two ways here: keep
the children's text as an unknown node's, or name the drop in the README's plain section. Found by the children's text as an unknown node's, or name the drop in the README's plain section. Found by
the product-owner review of item 55, 2026-10-04. the product-owner review, 2026-10-04.
### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes. ### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.