Merge pull request 'Corpus 2e1: the carve-out escapes and the claimed line' (#19) from carve-outs into main
CI / gate (push) Successful in 5s

This commit was merged in pull request #19.
This commit is contained in:
2026-08-26 22:29:40 +02:00
11 changed files with 372 additions and 34 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ One directory per contract kind, each landing with its milestone:
- `round-trip/``<name>.json` + `<name>.md`: the markdown `adfToMarkdown` must emit for that - `round-trip/``<name>.json` + `<name>.md`: the markdown `adfToMarkdown` must emit for that
document, byte for byte, and that `markdownToAdf` must read back to it (AGENTS.md §2). Grouped document, byte for byte, and that `markdownToAdf` must read back to it (AGENTS.md §2). Grouped
by node family. by what the fixture exercises.
- `normalization/``<name>.md` + `<name>.json`: markdown input, and the document - `normalization/``<name>.md` + `<name>.json`: markdown input, and the document
`markdownToAdf` must build from it. One-way; the markdown is not canonical. `markdownToAdf` must build from it. One-way; the markdown is not canonical.
- `errors/``<name>.md`: markdown input that must not convert. A `<name>.error` beside it - `errors/``<name>.md`: markdown input that must not convert. A `<name>.error` beside it
@@ -0,0 +1,73 @@
{
"content": [
{
"content": [
{
"text": "Write :mention[@Mikael]{id=5b10a2} to tag someone.",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "::syncBlock {localId=x}",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": ":::panel warning",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "Stand-up at 10:30, shipped :tada:, {maxLevel} deep, a ::syncBlock mid-line.",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"attrs": {
"text": "In review"
},
"type": "status"
},
{
"text": "{color} decides.",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": ":status[nope]{color=red} vs ",
"type": "text"
},
{
"attrs": {
"color": "red",
"text": "yep"
},
"type": "status"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,11 @@
Write \:mention[@Mikael]{id=5b10a2} to tag someone.
\::syncBlock {localId=x}
\:::panel warning
Stand-up at 10:30, shipped :tada:, {maxLevel} deep, a ::syncBlock mid-line.
:status[In review]\{color} decides.
\:status[nope]{color=red} vs :status[yep]{color=red}
@@ -0,0 +1,60 @@
{
"content": [
{
"content": [
{
"text": "| Part | Qty |",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "Pass a | b to the shell.",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"content": [
{
"content": [
{
"text": "| --- | --- |",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "bulletList"
},
{
"attrs": {
"panelType": "info"
},
"content": [
{
"content": [
{
"text": "| a | b |",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "panel"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,9 @@
\| Part | Qty |
Pass a | b to the shell.
- \| --- | --- |
:::panel info
\| a | b |
:::
@@ -0,0 +1,69 @@
{
"content": [
{
"content": [
{
"text": "~~not strike~~",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "strike"
}
],
"text": "gone",
"type": "text"
},
{
"text": " but ~~kept~~",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "A ~ B ~ C",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "~",
"type": "text"
},
{
"marks": [
{
"type": "strike"
}
],
"text": "a",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "~~~not~~~",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,9 @@
\~~not strike~~
~~gone~~ but \~~kept~~
A ~ B ~ C
\~~~a~~
\~\~~not~~~
@@ -0,0 +1,83 @@
{
"content": [
{
"attrs": {
"panelType": "info"
},
"content": [
{
"content": [
{
"content": [
{
"content": [
{
"text": ":::",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "bulletList"
},
{
"content": [
{
"content": [
{
"text": ":::",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "blockquote"
},
{
"content": [
{
"text": ":::",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "::: x",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "::",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "panel"
},
{
"content": [
{
"text": ":::",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,13 @@
:::panel info
- \:::
> \:::
\:::
\::: x
\::
:::
\:::
+5 -1
View File
@@ -13,7 +13,7 @@ const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
const roundTripRoot = join(corpusRoot, 'round-trip') const roundTripRoot = join(corpusRoot, 'round-trip')
const unspellableRoot = join(corpusRoot, 'unspellable') const unspellableRoot = join(corpusRoot, 'unspellable')
const emittingDirectories = ['block-nodes', 'commonmark-subset', 'inline-nodes', 'opaque-carry'] const emittingDirectories = ['block-nodes', 'combinations', 'commonmark-subset', 'inline-nodes', 'opaque-carry']
function directoryNames(root: string): string[] { function directoryNames(root: string): string[] {
return readdirSync(root, { withFileTypes: true }) return readdirSync(root, { withFileTypes: true })
@@ -86,6 +86,8 @@ function fenceNestingFault(markdown: string): string | undefined {
const run = colons[1]?.length ?? 0 const run = colons[1]?.length ?? 0
const enclosing = open[open.length - 1] const enclosing = open[open.length - 1]
if (colons[2] === '') { if (colons[2] === '') {
if (enclosing === undefined) return `${JSON.stringify(line)} closes no open container`
if (run < enclosing) return `${JSON.stringify(line)} is shorter than the ${enclosing} colons it would close`
open.pop() open.pop()
continue continue
} }
@@ -99,6 +101,8 @@ test('the fence nesting check catches a fence a container cannot hold', () => {
assert.equal(fenceNestingFault(':::panel info\n- :::panel warning\n B\n :::\n:::'), '"- :::panel warning" sits in a container fenced with 3 colons') assert.equal(fenceNestingFault(':::panel info\n- :::panel warning\n B\n :::\n:::'), '"- :::panel warning" sits in a container fenced with 3 colons')
assert.equal(fenceNestingFault(':::panel info\n- - :::panel warning\n B\n :::\n:::'), '"- - :::panel warning" sits in a container fenced with 3 colons') assert.equal(fenceNestingFault(':::panel info\n- - :::panel warning\n B\n :::\n:::'), '"- - :::panel warning" sits in a container fenced with 3 colons')
assert.equal(fenceNestingFault(':::panel info\n10. :::panel warning\n B\n :::\n:::'), '"10. :::panel warning" sits in a container fenced with 3 colons') assert.equal(fenceNestingFault(':::panel info\n10. :::panel warning\n B\n :::\n:::'), '"10. :::panel warning" sits in a container fenced with 3 colons')
assert.equal(fenceNestingFault('::::panel info\n:::\n::::'), '":::" is shorter than the 4 colons it would close')
assert.equal(fenceNestingFault('Text\n:::\n'), '":::" closes no open container')
assert.equal(fenceNestingFault('::::panel info\n- - :::panel warning\n B\n :::\n::::'), undefined) assert.equal(fenceNestingFault('::::panel info\n- - :::panel warning\n B\n :::\n::::'), undefined)
assert.equal(fenceNestingFault(':::tableCell\n```text\n:::::::panel warning\n:::\n```\n:::'), undefined) assert.equal(fenceNestingFault(':::tableCell\n```text\n:::::::panel warning\n:::\n```\n:::'), undefined)
}) })
+35 -28
View File
@@ -84,37 +84,41 @@ detail is settled at its own milestone.
attribute fallback 2e owes, since the reservation leaves that node no other spelling attribute fallback 2e owes, since the reservation leaves that node no other spelling
whatever 1d decides for its `localId`. whatever 1d decides for its `localId`.
- [ ] **2e — Carve-outs and combinations.** Fixtures and emitter together, into - [ ] **2e — Carve-outs and combinations.** Fixtures and emitter together, into
`corpus/round-trip/combinations/`: the three carve-outs and their escapes, mark runs — the `corpus/round-trip/combinations/`.
longest-run rule, attributes included — and the runs a carry breaks, a mark spelling that - [ ] **2e1 — The carve-outs and the claimed line.** The three carve-outs
cannot open where it sits (`un**-real**istic`; the spec owes the carry a trigger), and their escapes, and a paragraph line inside a container body shaped like a closing
attribute canonicalization, a pipe cell's whitespace edges and `\u007c` for a `|` inside a fence (`:::`, `::: x`). Guard `fenceNestingFault`'s bare-run pop here too — a run shorter
quoted attribute value, documents combining nodes rather than isolating one, and a than the open fence is a fault, not a close — which today's emitter cannot reach.
paragraph line inside a container body shaped like a closing fence (`:::`, `::: x`). - [ ] **2e2 — Mark runs and the runs a carry breaks.** The longest-run rule, attributes
Guard `fenceNestingFault`'s bare-run pop here too — a run shorter than the open fence is a included, and a mark spelling that cannot open where it sits (`un**-real**istic`; the spec
fault, not a close — which today's emitter cannot reach. owes the carry a trigger). One mark vocabulary lands here, before 2e3 changes the
Two moves land before the attribute spelling changes. One mark vocabulary: attribute spelling: `emphasisSpellings`, `linkAttributes` and the `code`/`link` names join
`emphasisSpellings`, `linkAttributes` and the `code`/`link` names join
`inline-directives.ts`, which holds four of the nine marks while the rest are branch `inline-directives.ts`, which holds four of the nine marks while the rest are branch
literals in the emitter — and the parser (3) needs every name to make `:em[x]` the named literals in the emitter — and the parser (3) needs every name to make `:em[x]` the named
error `spec/flavour.md` promises. And `escaping: 'attribute'` earns its keep at the error `spec/flavour.md` promises.
`\u007c` rule or collapses into `none`: nothing the escaper does tells the two apart - [ ] **2e3 — Attribute canonicalization and the quoted value's escape.** Attribute
today, since a carried segment holds only spaces, tabs and newlines. canonicalization, a pipe cell's whitespace edges, and `\u007c` for a `|` inside a quoted
The carry's fallback triggers land here too: `spec/flavour.md` carries a node its section attribute value. The rule is wider than the pipe: a quoted value spells `` ` ``, `&` and
`<` raw today, so a backtick pair in an `inlineCard`'s `data`, a `status`'s `style` or the
inline carry's `json` reads back as a code span, an entity or raw HTML inside the value.
One escape settles all four, and the pick decides whether a directive or a code span wins
where they overlap (3). `escaping: 'attribute'` earns its keep at that rule or collapses
into `none`: nothing the escaper does tells the two apart today, since a carried segment
holds only spaces, tabs and newlines.
- [ ] **2e4 — The carry's fallback triggers.** `spec/flavour.md` carries a node its section
cannot spell — an attrs key no section lists, a value that is not the section's type, an cannot spell — an attrs key no section lists, a value that is not the section's type, an
arg slot holding no bare token, marks no nesting spells — where the emitter still refuses, arg slot holding no bare token, marks no nesting spells — where the emitter still refuses,
which leaves the refusals a container's own spelling owns. The `\u007c` rule is wider than which leaves the refusals a container's own spelling owns. The carry spells
the pipe: a quoted attribute value spells `` ` ``, `&` and `<` raw today, so a backtick `escaping: 'none'`, which is what makes `tryPipeCell` refuse the pipe form for a carry
pair in an `inlineCard`'s `data`, a `status`'s `style` or the inline carry's `json` reads whose JSON holds a pipe.
back as a code span, an entity or raw HTML inside the value. One escape settles all four, - [ ] **2e5 — Combined documents and the collision property.** Documents combining nodes rather
and the pick decides whether a directive or a code span wins where they overlap (3). The than isolating one, and the gate's collision property: no two corpus documents may emit
carry spells `escaping: 'none'` meanwhile, which is what makes `tryPipeCell` refuse the the same bytes — one spelling for two documents is a round-trip break no parser can undo,
pipe form for a carry whose JSON holds a pipe. and it is provable without one. It also settles the emitter's one known approximation:
The gate gains the collision property here: no two corpus documents may emit the same delimiter flanking is exact, but CommonMark's *matching* — the multiple-of-3 rule and the
bytes — one spelling for two documents is a round-trip break no parser can undo, and it is way a run splits across several openers — is not modelled. No reachable violation has been
provable without one. It also settles the emitter's one known approximation: delimiter found by hand; the property test is what decides it, and 2e1's `carve-out-strike` pins a
flanking is exact, but CommonMark's *matching* — the multiple-of-3 rule and the way a run second backslash only flanking-without-matching emits.
splits across several openers — is not modelled. No reachable violation has been found by
hand; the property test is what decides it.
- [ ] **2f — The attributes CommonMark cannot hold.** 1d's settled answer: the block nodes - [ ] **2f — The attributes CommonMark cannot hold.** 1d's settled answer: the block nodes
CommonMark spells — `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`, CommonMark spells — `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`,
`orderedList`, `paragraph`, `rule` — get directive sections in `spec/flavour.md` carrying `orderedList`, `paragraph`, `rule` — get directive sections in `spec/flavour.md` carrying
@@ -137,7 +141,10 @@ detail is settled at its own milestone.
assumes — CommonMark flanking, as for `*` — which `spec/flavour.md` does not yet pin, and assumes — CommonMark flanking, as for `*` — which `spec/flavour.md` does not yet pin, and
the precedence between a directive and the constructs a raw attribute value opens inside it — the precedence between a directive and the constructs a raw attribute value opens inside it —
a code span, an entity, raw HTML — which one directive alone already reaches until 2e's a code span, an entity, raw HTML — which one directive alone already reaches until 2e's
escape lands. escape lands. Whether a claimed line interrupts a paragraph is pinned for the plain case and
unstated for the lazy one: a closing fence on the line after a blockquote's open paragraph is
lazy continuation in CommonMark, which would swallow the fence and leave the container
unclosed. 2e1's `closing-fence-line` orders its blockquote away from that edge meanwhile.
`src/` gets its hierarchy at the same split — `adf/`, `src/` gets its hierarchy at the same split — `adf/`,
`markdown/`, `html/`, the grammar module shared inside `markdown/` — while the rename is `markdown/`, `html/`, the grammar module shared inside `markdown/` — while the rename is
still mechanical. Three files do not move whole: `block-directives.ts` and still mechanical. Three files do not move whole: `block-directives.ts` and