Keep the info string to the languages it carries back, and name the invariant's one exception
CI / gate (push) Successful in 5s

This commit is contained in:
2026-08-27 16:02:05 +02:00
parent 1ec991d64a
commit ab799b883d
5 changed files with 23 additions and 13 deletions
+2 -1
View File
@@ -129,7 +129,8 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
table, a pipe cell — gives way with `undefined` for every shape it cannot spell, and fails only table, a pipe cell — gives way with `undefined` for every shape it cannot spell, and fails only
where the general form fails on the same node. Refusing there refuses a document the general where the general form fails on the same node. Refusing there refuses a document the general
form spells, so a refusal the general form does not share belongs in the general form or form spells, so a refusal the general form does not share belongs in the general form or
nowhere. nowhere — save the nested list a tight spelling would swallow, whose refusal the
tight-versus-blank answer owns (`todo.md` 2b).
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an - Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an
attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a
`Result` rather than the stack overflow that waits near 2000. `Result` rather than the stack overflow that waits near 2000.
+9 -7
View File
@@ -32,7 +32,7 @@ normalizes to it through the round-trip.
inside it, directive syntax and `~~` included. inside it, directive syntax and `~~` included.
- Thematic break `---`. - Thematic break `---`.
- Hard break: backslash at end of line (survives editors that trim trailing spaces). Where - Hard break: backslash at end of line (survives editors that trim trailing spaces). Where
CommonMark admits no spelling — the end of a block, inside a heading — or where the node CommonMark admits no spelling — the end of a block, inside an ATX heading — or where the node
carries an attribute, it is the inline directive. carries an attribute, it is the inline directive.
- An empty paragraph — real payloads carry them — is `::paragraph`. - An empty paragraph — real payloads carry them — is `::paragraph`.
- Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes. - Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes.
@@ -174,14 +174,16 @@ form.
- `blockquote`, `bulletList`, `listItem` — containers, block body; no attributes beyond `localId`. - `blockquote`, `bulletList`, `listItem` — containers, block body; no attributes beyond `localId`.
- `codeBlock` — container, body one fenced code block whose info string is the language and whose - `codeBlock` — container, body one fenced code block whose info string is the language and whose
content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId`, content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId`,
`uniqueId` (string), `wrap` (boolean). A language no info string holds — empty, or holding a `uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty, the
backtick, edge whitespace, an entity reference or the reserved `adf` — rides the `language` reserved `adf`, or holding a backtick, a backslash, a control character, edge whitespace or an
attribute instead, and the fence carries no info string. entity reference — rides the `language` attribute instead and the fence carries no info string;
writing both is a named error.
- `heading` — container, inline body. Attributes: `level` (number), `localId`. `level` is the `#` - `heading` — container, inline body. Attributes: `level` (number), `localId`. `level` is the `#`
count, so a heading carrying none, or one outside 1-6, has no CommonMark spelling. count, so a heading carrying none, or one that is no whole number from 1 to 6, has no CommonMark
spelling.
- `orderedList` — container of `listItem`, block body. Attributes: `localId`, `order` (number). - `orderedList` — container of `listItem`, block body. Attributes: `localId`, `order` (number).
`order` is the first marker, so a list carrying none, or one whose markers would run past `order` is the first marker, so a list carrying none, one that is no whole number from 0, or one
999999999, has no CommonMark spelling. whose markers would run past 999999999, has no CommonMark spelling.
- `paragraph` — container, inline body; no attributes beyond `localId`. - `paragraph` — container, inline body; no attributes beyond `localId`.
- `rule` — leaf; no attributes beyond `localId`. - `rule` — leaf; no attributes beyond `localId`.
+4 -1
View File
@@ -69,6 +69,9 @@ test('spells a code block language no info string holds as an attribute', () =>
assert.equal(language('a`b'), ':::codeBlock {language="a\\u0060b"}\n```\n```\n:::\n') assert.equal(language('a`b'), ':::codeBlock {language="a\\u0060b"}\n```\n```\n:::\n')
assert.equal(language(' sql'), ':::codeBlock {language=" sql"}\n```\n```\n:::\n') assert.equal(language(' sql'), ':::codeBlock {language=" sql"}\n```\n```\n:::\n')
assert.equal(language('&#97;df'), ':::codeBlock {language="\\u0026#97;df"}\n```\n```\n:::\n') assert.equal(language('&#97;df'), ':::codeBlock {language="\\u0026#97;df"}\n```\n```\n:::\n')
assert.equal(language('foo\\+bar'), ':::codeBlock {language="foo\\\\+bar"}\n```\n```\n:::\n')
assert.equal(language('a\u0000b'), ':::codeBlock {language="a\\u0000b"}\n```\n```\n:::\n')
assert.equal(language('a\tb'), ':::codeBlock {language="a\\tb"}\n```\n```\n:::\n')
}) })
test('refuses a link destination CommonMark cannot spell', () => { test('refuses a link destination CommonMark cannot spell', () => {
@@ -165,7 +168,7 @@ test('refuses a node whose content model the canonical form cannot emit', () =>
assert.equal(code(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape')
}) })
test('spells a list its own markers cannot hold as a directive', () => { test('spells a list its own content shape cannot hold as a directive', () => {
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), ':::bulletList\n::paragraph\n:::\n') assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), ':::bulletList\n::paragraph\n:::\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), ':::bulletList\n:::\n') assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), ':::bulletList\n:::\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), ':::orderedList {order=2}\n:::\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), ':::orderedList {order=2}\n:::\n')
+3 -3
View File
@@ -7,7 +7,7 @@ import { emitInlineLine } from './markdown-inline.ts'
import { tryPipeTable } from './markdown-pipe-table.ts' import { tryPipeTable } from './markdown-pipe-table.ts'
import { carriedBlock, carryName } from './opaque-carry.ts' import { carriedBlock, carryName } from './opaque-carry.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { holdsEntityReference, holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts' import { holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
import { carriesOnly, isAdfDocument } from './adf-document.ts' import { carriesOnly, isAdfDocument } from './adf-document.ts'
import { largestNesting } from './nesting.ts' import { largestNesting } from './nesting.ts'
import { fencedCodeBlock } from './backtick-runs.ts' import { fencedCodeBlock } from './backtick-runs.ts'
@@ -187,11 +187,11 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
return success(text) return success(text)
} }
// spec/flavour.md, The CommonMark blocks: the languages an info string holds, the absent one as the empty string. // spec/flavour.md, The CommonMark blocks.
function fenceInfo(language: JsonValue | undefined): string | undefined { function fenceInfo(language: JsonValue | undefined): string | undefined {
if (language === undefined) return '' if (language === undefined) return ''
if (typeof language !== 'string' || language === '' || language === carryName) return undefined if (typeof language !== 'string' || language === '' || language === carryName) return undefined
if (/[`\n\r]/.test(language) || language !== language.trim() || holdsEntityReference(language)) return undefined if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return undefined
return language return language
} }
+5 -1
View File
@@ -67,7 +67,8 @@ detail is settled at its own milestone.
above it is refused meanwhile, not spelled: the maintainer's answer on tight-versus-blank above it is refused meanwhile, not spelled: the maintainer's answer on tight-versus-blank
separation turns that refusal into an emission. The test is broader than the name it separation turns that refusal into an emission. The test is broader than the name it
carries — `interruptsParagraph` reads the next list alone, so a list after a block no carries — `interruptsParagraph` reads the next list alone, so a list after a block no
paragraph continues, a code block say, is refused too — and the same answer narrows it. Block separation becomes paragraph continues, a code block say, is refused too — and the same answer narrows it.
Block separation becomes
`separationBetween(previous, next, container)` here — a boolean cannot hold the third case `separationBetween(previous, next, container)` here — a boolean cannot hold the third case
`spec/flavour.md` states for two directive blocks in a container body, and the maintainer's `spec/flavour.md` states for two directive blocks in a container body, and the maintainer's
answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give
@@ -150,6 +151,9 @@ detail is settled at its own milestone.
spelling an empty list or a non-`listItem` child that the plain form refuses. `order` is spelling an empty list or a non-`listItem` child that the plain form refuses. `order` is
the first marker, so `order: 1` keeps the plain `1.` — what a real payload carries — and a the first marker, so `order: 1` keeps the plain `1.` — what a real payload carries — and a
list carrying no `order` has no number to take and takes the directive form. list carrying no `order` has no number to take and takes the directive form.
2f raises what 1d's unspelled block separation costs: a single `localId` on a paragraph
beside a plain one now refuses every container body that is a directive's — a panel, an
expand, a table cell — where before 2f the attribute refused the document anyway.
- [ ] **3 — `markdownToAdf`.** The CommonMark parser is the largest single component; split it - [ ] **3 — `markdownToAdf`.** The CommonMark parser is the largest single component; split it
into sub-items before starting (§15). Fixtures land with the code that reads them: into sub-items before starting (§15). Fixtures land with the code that reads them:
`corpus/normalization/` (setext, indented code, loose lists, `*`/`+` bullets, entity `corpus/normalization/` (setext, indented code, loose lists, `*`/`+` bullets, entity