Read the container blocks, and part a nested list the tight spelling would swallow
CI / gate (push) Successful in 6s

This commit is contained in:
2026-08-28 14:46:14 +02:00
parent 1bc456f732
commit e73de651be
24 changed files with 540 additions and 150 deletions
+3 -2
View File
@@ -81,8 +81,9 @@ The error surface is a contract too. `ConvertError` is `{ code, message, path }`
closed list a consumer may switch exhaustively, the message free text, the path the node's position closed list a consumer may switch exhaustively, the message free text, the path the node's position
from the document root. Adding, removing or renaming a code is breaking, so a milestone meeting a from the document root. Adding, removing or renaming a code is breaking, so a milestone meeting a
new failure cause reuses a code where one fits; the list is complete at `0.1.0`. A code names the new failure cause reuses a code where one fits; the list is complete at `0.1.0`. A code names the
cause; where one cause recurs across node types, one code covers them all and `path` and `message` cause; where one cause recurs across node types or across directions, one code covers them all and
say which. A claim code names the spelling claimed, never the node that spelling would have built: `path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever
direction hits it. A claim code names the spelling claimed, never the node that spelling would have built:
a malformed `:::table` is a `malformed-directive`. A cause the carry answers gets no code: a mark no a malformed `:::table` is a `malformed-directive`. A cause the carry answers gets no code: a mark no
spelling writes rides the carry with its node. spelling writes rides the carry with its node.
@@ -0,0 +1 @@
malformed-directive
+2
View File
@@ -0,0 +1,2 @@
> Part.
:::
+1
View File
@@ -0,0 +1 @@
unsupported-nesting-depth
+1
View File
@@ -0,0 +1 @@
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > >
@@ -0,0 +1,39 @@
{
"content": [
{
"content": [
{
"content": [
{
"text": "Ship it then tell them",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "blockquote"
},
{
"content": [
{
"content": [
{
"content": [
{
"text": "Torque the bolts lazily",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "bulletList"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,6 @@
> Ship it
then tell them
- Torque
the bolts
lazily
+89
View File
@@ -0,0 +1,89 @@
{
"content": [
{
"content": [
{
"content": [
{
"content": [
{
"text": "Bolt M8",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
},
{
"content": [
{
"content": [
{
"text": "Nut M8",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "bulletList"
},
{
"content": [
{
"text": "Then:",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"content": [
{
"content": [
{
"text": "Washer M8",
"type": "text"
}
],
"type": "paragraph"
},
{
"attrs": {
"order": 1
},
"content": [
{
"content": [
{
"content": [
{
"text": "Fibre",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "orderedList"
}
],
"type": "listItem"
}
],
"type": "bulletList"
}
],
"type": "doc",
"version": 1
}
+8
View File
@@ -0,0 +1,8 @@
* Bolt M8
* Nut M8
Then:
+ Washer M8
1) Fibre
@@ -0,0 +1,3 @@
- Torque the bolts
2. Then sign off
@@ -1 +0,0 @@
unspellable-line-start
+1 -1
View File
@@ -13,7 +13,7 @@
"node": ">=24" "node": ">=24"
}, },
"scripts": { "scripts": {
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=96 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=97 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
+5 -2
View File
@@ -19,7 +19,9 @@ normalizes to it through the round-trip.
- Bullet lists `- `; ordered lists incrementing `1.` `2.` `3.`, the first number the node's - Bullet lists `- `; ordered lists incrementing `1.` `2.` `3.`, the first number the node's
`order` attribute. Continuation lines align with the first character after the marker `order` attribute. Continuation lines align with the first character after the marker
(two spaces for `- `, three for `1. `); blank lines inside an item are empty lines, none (two spaces for `- `, three for `1. `); blank lines inside an item are empty lines, none
between a nested list and a CommonMark block above it. Blank lines between items normalize between a nested list and a CommonMark block above it — one where the nested list's own marker
cannot interrupt a paragraph (any ordered list, or a bullet list whose first item opens with an
empty block), which the block above would otherwise swallow. Blank lines between items normalize
away; ADF does not record tightness. away; ADF does not record tightness.
- Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`. - Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`.
- ATX headings (`#``######`); setext input normalizes to ATX. - ATX headings (`#``######`); setext input normalizes to ATX.
@@ -92,7 +94,8 @@ above.
**Claiming at block level**, symmetric with inline: a line whose leading run of two or more **Claiming at block level**, symmetric with inline: a line whose leading run of two or more
colons is followed immediately by a name character is claimed and must parse fully as a container colons is followed immediately by a name character is claimed and must parse fully as a container
opening or a leaf, else it is a named error. A bare colon-run line is a closing fence while a opening or a leaf, else it is a named error. A bare colon-run line is a closing fence while a
container is open, a named error otherwise. container is open, a named error otherwise. A claimed line also ends a lazy continuation: the
blockquote or list item whose paragraph CommonMark would fold it into closes instead.
**Attributes**: `{key=value key2="two words"}`. `{attrs}` is optional in every form, and `{}` is **Attributes**: `{key=value key2="two words"}`. `{attrs}` is optional in every form, and `{}` is
valid — no attributes. A bare value matches `[A-Za-z0-9_-]+`; any other value is double-quoted valid — no attributes. A bare value matches `[A-Za-z0-9_-]+`; any other value is double-quoted
+7 -7
View File
@@ -159,8 +159,8 @@ test('breaks a mark run at the node it carries', () => {
test('refuses a carried node nested deeper than the emitter carries', () => { test('refuses a carried node nested deeper than the emitter carries', () => {
let node: AdfNode = { type: 'blockCard' } let node: AdfNode = { type: 'blockCard' }
for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockCard' } for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockCard' }
assert.equal(code(adfToMarkdown(document(node))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document(node))), 'unsupported-nesting-depth')
assert.equal(code(adfToMarkdown(document(paragraph(node)))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document(paragraph(node)))), 'unsupported-nesting-depth')
}) })
test('refuses a node whose content model the canonical form cannot emit', () => { test('refuses a node whose content model the canonical form cannot emit', () => {
@@ -261,19 +261,19 @@ test('refuses a node carrying one mark type twice', () => {
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [em, em], text: 'x', type: 'text' })))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document(paragraph({ marks: [em, em], text: 'x', type: 'text' })))), 'unsupported-node-shape')
}) })
test('refuses a nested list the tight spelling would swallow', () => { test('parts a nested list the tight spelling would swallow from the block above it', () => {
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' }) const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' })
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' }) const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' }) const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' } const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' }
assert.equal(code(adfToMarkdown(outer(text('a'), ordered))), 'unspellable-line-start') assert.equal(markdown(adfToMarkdown(outer(text('a'), ordered))), '- a\n\n 2. b\n')
assert.equal(code(adfToMarkdown(outer(text('a'), { content: [item()], type: 'bulletList' }))), 'unspellable-line-start') assert.equal(markdown(adfToMarkdown(outer(text('a'), { content: [item()], type: 'bulletList' }))), '- a\n\n -\n')
assert.equal(markdown(adfToMarkdown(outer(text('a'), { content: [item(text('b'))], type: 'bulletList' }))), '- a\n - b\n') assert.equal(markdown(adfToMarkdown(outer(text('a'), { content: [item(text('b'))], type: 'bulletList' }))), '- a\n - b\n')
}) })
test('refuses marks and attributes nested deeper than the emitter carries', () => { test('refuses marks and attributes nested deeper than the emitter carries', () => {
const marks: AdfMark[] = Array.from({ length: 600 }, (_, index) => ({ type: index % 2 === 0 ? 'em' : 'strong' })) const marks: AdfMark[] = Array.from({ length: 600 }, (_, index) => ({ type: index % 2 === 0 ? 'em' : 'strong' }))
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
let attrs: AdfMark['attrs'] = { depth: 'x' } let attrs: AdfMark['attrs'] = { depth: 'x' }
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs } for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), 'not-an-adf-document') assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), 'not-an-adf-document')
@@ -380,7 +380,7 @@ test('spells one code span over a run of code-marked nodes', () => {
test('refuses a document nested deeper than the emitter carries', () => { test('refuses a document nested deeper than the emitter carries', () => {
let node: AdfNode = paragraph({ text: 'x', type: 'text' }) let node: AdfNode = paragraph({ text: 'x', type: 'text' })
for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockquote' } for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockquote' }
assert.equal(code(adfToMarkdown(document(node))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document(node))), 'unsupported-nesting-depth')
}) })
test('emits an empty list item without trailing whitespace', () => { test('emits an empty list item without trailing whitespace', () => {
+2 -7
View File
@@ -31,7 +31,7 @@ export function adfToMarkdown(document: AdfDocument): Result<string> {
} }
function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path: ConvertErrorPath, depth: number): Result<EmittedBody> { function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
if (depth > largestNesting) return failure('unsupported-node-shape', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path) if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
const blocks: PlacedBlock[] = [] const blocks: PlacedBlock[] = []
for (const [index, node] of nodes.entries()) { for (const [index, node] of nodes.entries()) {
const nodePath = [...path, 'content', index] const nodePath = [...path, 'content', index]
@@ -60,12 +60,7 @@ function separationBetween(previous: PlacedBlock, next: PlacedBlock, container:
if (previous.node.type === next.node.type) { if (previous.node.type === next.node.type) {
return failure('unspellable-adjacent-lists', `two adjacent ${next.node.type} nodes read back as one list`, next.path) return failure('unspellable-adjacent-lists', `two adjacent ${next.node.type} nodes read back as one list`, next.path)
} }
if (container === 'list-item') { if (container === 'list-item') return success(interruptsParagraph(next.node) ? '\n' : '\n\n')
if (!interruptsParagraph(next.node)) {
return failure('unspellable-line-start', `a ${next.node.type} that cannot interrupt the block above it has no tight spelling`, next.path)
}
return success('\n')
}
} }
if (container !== 'directive' || plainPair) return success('\n\n') if (container !== 'directive' || plainPair) return success('\n\n')
if (previous.spelling === 'directive' && next.spelling === 'directive') return success('\n') if (previous.spelling === 'directive' && next.spelling === 'directive') return success('\n')
+1 -1
View File
@@ -140,7 +140,7 @@ function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result<nul
function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result<Emission> { function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result<Emission> {
if (depth > largestNesting) { if (depth > largestNesting) {
return failure('unsupported-node-shape', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path) return failure('unsupported-nesting-depth', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path)
} }
const runs = inlineRuns(nodes, depth, firstIndex, context.carried) const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
const segments: InlineSegment[] = [] const segments: InlineSegment[] = []
+1 -1
View File
@@ -23,7 +23,7 @@ export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<str
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath): Result<string> { function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath): Result<string> {
if (!isJsonValue(node)) { if (!isJsonValue(node)) {
return failure('unsupported-node-shape', `a carried node's JSON nests deeper than the ${largestNesting} levels the emitter carries`, path) return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${largestNesting} levels the emitter carries`, path)
} }
return success(serializeCanonicalJson(node, spelling)) return success(serializeCanonicalJson(node, spelling))
} }
+4
View File
@@ -28,6 +28,10 @@ test('keeps the link reference definitions a paragraph gives up, the first of a
['a', { destination: '/url\\' }], ['a', { destination: '/url\\' }],
['b', { destination: '/b' }], ['b', { destination: '/b' }],
]) ])
assert.deepEqual(definitions('> [a]: /url\n\n- [b]: /other\n'), [
['a', { destination: '/url' }],
['b', { destination: '/other' }],
])
assert.deepEqual(definitions('[\u00a0a]: /one\n[a]: /two\n'), [ assert.deepEqual(definitions('[\u00a0a]: /one\n[a]: /two\n'), [
['\u00a0a', { destination: '/one' }], ['\u00a0a', { destination: '/one' }],
['a', { destination: '/two' }], ['a', { destination: '/two' }],
+245 -120
View File
@@ -1,80 +1,277 @@
import type { LinkDefinition } from './link-reference-definitions.ts' import type { LinkDefinition } from './link-reference-definitions.ts'
import type { OpenHtmlBlock } from './html-blocks.ts'
import { atxHeading, claimsDirectiveLine, claimsPipeLine, closingCodeFence, isThematicBreak, openingCodeFence, setextHeadingLevel } from '../commonmark-grammar.ts' import { atxHeading, claimsDirectiveLine, claimsPipeLine, closingCodeFence, isThematicBreak, openingCodeFence, setextHeadingLevel } from '../commonmark-grammar.ts'
import { openingHtmlBlock } from './html-blocks.ts' import { openingHtmlBlock } from './html-blocks.ts'
import { readLinkDefinitions } from './link-reference-definitions.ts' import { readLinkDefinitions } from './link-reference-definitions.ts'
export type ClaimedConstruct = 'directive' | 'pipe-table' export type ClaimedConstruct = 'directive' | 'pipe-table'
export type LeafBlock = export type Block =
| { blocks: Block[]; kind: 'blockquote' }
| { construct: ClaimedConstruct; kind: 'claim' } | { construct: ClaimedConstruct; kind: 'claim' }
| { construct: string; kind: 'html' } | { construct: string; kind: 'html' }
| { items: Block[][]; kind: 'bulletList' }
| { items: Block[][]; kind: 'orderedList'; start: number }
| { kind: 'code'; language: string; text: string } | { kind: 'code'; language: string; text: string }
| { kind: 'heading'; level: number; text: string } | { kind: 'heading'; level: number; text: string }
| { kind: 'paragraph'; text: string } | { kind: 'paragraph'; text: string }
| { kind: 'rule' } | { kind: 'rule' }
export type ParsedBlocks = { blocks: LeafBlock[]; definitions: Map<string, LinkDefinition> } export type ParsedBlocks = { blocks: Block[]; definitions: Map<string, LinkDefinition> }
type Walk = ParsedBlocks & { paragraph: string[] } type ListBlock = Extract<Block, { items: Block[][] }>
type OpenContainer = Extract<Block, { kind: 'blockquote' }> | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock; marker: string }
type OpenLeaf =
| { closer: RegExp | undefined; construct: string; kind: 'html' }
| { held: string[]; kind: 'indented-code'; lines: string[] }
| { indentation: number; info: string; kind: 'fenced-code'; lines: string[]; marker: string }
| { kind: 'paragraph'; lines: string[] }
type ContainerStart = { kind: 'blockquote'; rest: string } | { fresh: boolean; indentation: number; kind: 'item'; list: ListBlock; marker: string; rest: string }
type ItemMarker = { list: ListBlock; marker: string; width: number }
type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; stack: OpenContainer[] }
const blankLine = /^[ \t]*$/ const blankLine = /^[ \t]*$/
const bulletMarker = /^[-*+](?=[ \t]|$)/
const orderedMarker = /^(\d{1,9})([.)])(?=[ \t]|$)/
const indentedCodeColumns = 4 const indentedCodeColumns = 4
const largestOpenerIndentation = 3 const largestOpenerIndentation = 3
const tabStop = 4 const tabStop = 4
export function parseBlocks(markdown: string): ParsedBlocks { export function parseBlocks(markdown: string): ParsedBlocks {
const lines = normalizeInput(markdown).split('\n') const walk: Walk = { blocks: [], definitions: new Map(), leaf: undefined, stack: [] }
const walk: Walk = { blocks: [], definitions: new Map(), paragraph: [] } for (const line of normalizeInput(markdown).split('\n')) readLine(walk, line)
let index = 0 closeLeaf(walk)
while (index < lines.length) {
const line = lines[index] ?? ''
if (blankLine.test(line)) {
closeParagraph(walk)
index += 1
continue
}
if (leadingColumns(line) >= indentedCodeColumns && walk.paragraph.length === 0) {
index = readIndentedCode(walk, lines, index)
continue
}
const opened = openBlock(walk, lines, index, line)
if (opened !== undefined) {
index = opened
continue
}
walk.paragraph.push(line.replace(/^[ \t]+/, ''))
index += 1
}
closeParagraph(walk)
return { blocks: walk.blocks, definitions: walk.definitions } return { blocks: walk.blocks, definitions: walk.definitions }
} }
function openBlock(walk: Walk, lines: readonly string[], index: number, line: string): number | undefined { function readLine(walk: Walk, line: string): void {
const matched = matchContainers(walk, line)
const paragraphOpen = matched.depth === walk.stack.length && walk.leaf?.kind === 'paragraph'
const opened = openContainers(walk, matched.rest, paragraphOpen, matched.depth)
if (!opened.opened && matched.depth < walk.stack.length) {
if (continuesLazily(walk, opened.rest)) {
appendParagraph(walk, opened.rest)
return
}
closeContainers(walk, matched.depth)
}
readBlockLine(walk, opened.rest)
}
function matchContainers(walk: Walk, line: string): { depth: number; rest: string } {
let depth = 0
let rest = line
for (const container of walk.stack) {
const next = continuesContainer(walk, container, rest)
if (next === undefined) break
depth += 1
rest = next
}
return { depth, rest }
}
function continuesContainer(walk: Walk, container: OpenContainer, line: string): string | undefined {
if (container.kind === 'blockquote') {
const opener = removeColumns(line, largestOpenerIndentation)
return opener.startsWith('>') ? removeColumns(opener.slice(1), 1) : undefined
}
// A list item begins with at most one blank line: an empty one gives the second up.
if (blankLine.test(line)) return container.blocks.length === 0 && walk.leaf === undefined ? undefined : ''
return leadingColumns(line) < container.indentation ? undefined : removeColumns(line, container.indentation)
}
function openContainers(walk: Walk, line: string, paragraphOpen: boolean, depth: number): { opened: boolean; rest: string } {
let opened = false
let rest = line
while (leadingColumns(rest) < indentedCodeColumns) {
const start = containerStart(rest, opened ? false : paragraphOpen, opened ? undefined : walk.stack[depth])
if (start === undefined) break
if (!opened) closeContainers(walk, depth)
opened = true
openContainer(walk, start)
rest = start.rest
}
return { opened, rest }
}
function containerStart(line: string, paragraphOpen: boolean, enclosing: OpenContainer | undefined): ContainerStart | undefined {
const opener = removeColumns(line, largestOpenerIndentation)
if (opener.startsWith('>')) return { kind: 'blockquote', rest: removeColumns(opener.slice(1), 1) }
if (isThematicBreak(opener) || (paragraphOpen && setextHeadingLevel(opener) !== undefined)) return undefined
const marker = itemMarker(opener)
if (marker === undefined) return undefined
const after = opener.slice(marker.width)
const blank = blankLine.test(after)
if (paragraphOpen && (blank || (marker.list.kind === 'orderedList' && marker.list.start !== 1))) return undefined
const spaces = leadingColumns(after)
const padding = blank || spaces > indentedCodeColumns ? 1 : spaces
const continued = enclosing?.kind === 'item' && enclosing.list.kind === marker.list.kind && enclosing.marker === marker.marker
return {
fresh: !continued,
indentation: leadingColumns(line) + marker.width + padding,
kind: 'item',
list: continued ? enclosing.list : marker.list,
marker: marker.marker,
rest: blank ? '' : removeColumns(after, padding),
}
}
function itemMarker(opener: string): ItemMarker | undefined {
const ordered = orderedMarker.exec(opener)
if (ordered !== null) {
const digits = ordered[1] ?? ''
const delimiter = ordered[2] ?? ''
return { list: { items: [], kind: 'orderedList', start: Number(digits) }, marker: delimiter, width: digits.length + 1 }
}
const bullet = bulletMarker.exec(opener)?.[0]
return bullet === undefined ? undefined : { list: { items: [], kind: 'bulletList' }, marker: bullet, width: 1 }
}
function openContainer(walk: Walk, start: ContainerStart): void {
const blocks: Block[] = []
if (start.kind === 'blockquote') {
const blockquote: OpenContainer = { blocks, kind: 'blockquote' }
currentBlocks(walk).push(blockquote)
walk.stack.push(blockquote)
return
}
if (start.fresh) currentBlocks(walk).push(start.list)
start.list.items.push(blocks)
walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list: start.list, marker: start.marker })
}
function closeContainers(walk: Walk, depth: number): void {
closeLeaf(walk)
walk.stack.length = depth
}
// A claimed line ends the lazy continuation CommonMark would fold it into (spec/flavour.md).
function continuesLazily(walk: Walk, line: string): boolean {
if (walk.leaf?.kind !== 'paragraph' || blankLine.test(line)) return false
if (leadingColumns(line) >= indentedCodeColumns) return true
const opener = removeColumns(line, largestOpenerIndentation)
if (claimedConstruct(opener) !== undefined || isThematicBreak(opener)) return false
return atxHeading(opener) === undefined && openingCodeFence(opener) === undefined && openingHtmlBlock(opener, false) === undefined
}
function readBlockLine(walk: Walk, line: string): void {
const leaf = walk.leaf
if (leaf?.kind === 'fenced-code') {
if (closingCodeFence(removeColumns(line, largestOpenerIndentation), leaf.marker)) closeLeaf(walk)
else leaf.lines.push(removeColumns(line, leaf.indentation))
return
}
if (leaf?.kind === 'html') {
if (leaf.closer === undefined ? blankLine.test(line) : leaf.closer.test(line)) closeLeaf(walk)
return
}
if (leaf?.kind === 'indented-code') {
if (readIndentedCodeLine(leaf, line)) return
closeLeaf(walk)
}
if (blankLine.test(line)) {
closeLeaf(walk)
return
}
if (walk.leaf === undefined && leadingColumns(line) >= indentedCodeColumns) {
walk.leaf = { held: [], kind: 'indented-code', lines: [removeColumns(line, indentedCodeColumns)] }
return
}
openLeaf(walk, line)
}
function readIndentedCodeLine(leaf: Extract<OpenLeaf, { kind: 'indented-code' }>, line: string): boolean {
if (blankLine.test(line)) {
leaf.held.push(removeColumns(line, indentedCodeColumns))
return true
}
if (leadingColumns(line) < indentedCodeColumns) return false
leaf.lines.push(...leaf.held, removeColumns(line, indentedCodeColumns))
leaf.held.length = 0
return true
}
function openLeaf(walk: Walk, line: string): void {
const opener = removeColumns(line, largestOpenerIndentation) const opener = removeColumns(line, largestOpenerIndentation)
const claimed = claimedConstruct(opener) const claimed = claimedConstruct(opener)
if (claimed !== undefined) { if (claimed !== undefined) {
closeParagraph(walk) closeLeaf(walk)
walk.blocks.push({ construct: claimed, kind: 'claim' }) currentBlocks(walk).push({ construct: claimed, kind: 'claim' })
return index + 1 return
} }
if (readLineBlock(walk, opener)) return index + 1 if (readLineBlock(walk, opener)) return
const fence = openingCodeFence(opener) const fence = openingCodeFence(opener)
if (fence !== undefined) { if (fence !== undefined) {
closeParagraph(walk) closeLeaf(walk)
return readFencedCode(walk, lines, index + 1, fence, leadingColumns(line)) walk.leaf = { indentation: leadingColumns(line), info: fence.info, kind: 'fenced-code', lines: [], marker: fence.marker }
return
} }
const html = openingHtmlBlock(opener, walk.paragraph.length > 0) const html = openingHtmlBlock(opener, walk.leaf?.kind === 'paragraph')
if (html === undefined) return undefined if (html === undefined) {
closeParagraph(walk) appendParagraph(walk, line)
return readHtmlBlock(walk, lines, index, html) return
}
closeLeaf(walk)
walk.leaf = { closer: html.closer, construct: html.construct, kind: 'html' }
if (html.closer?.test(line) === true) closeLeaf(walk)
} }
function normalizeInput(markdown: string): string { // A setext underline over a paragraph the definitions emptied is no heading: it opens the next block.
return markdown function readLineBlock(walk: Walk, opener: string): boolean {
.replace(/\r\n?/g, '\n') const level = walk.leaf?.kind === 'paragraph' ? setextHeadingLevel(opener) : undefined
.replaceAll('\u0000', '\ufffd') if (level !== undefined) {
.replace(/\n$/, '') const text = takeParagraph(walk)
if (text !== undefined) {
currentBlocks(walk).push({ kind: 'heading', level, text })
return true
}
}
if (isThematicBreak(opener)) {
closeLeaf(walk)
currentBlocks(walk).push({ kind: 'rule' })
return true
}
const heading = atxHeading(opener)
if (heading === undefined) return false
closeLeaf(walk)
currentBlocks(walk).push({ kind: 'heading', level: heading.level, text: heading.text })
return true
}
function appendParagraph(walk: Walk, line: string): void {
const leaf = walk.leaf
const text = line.replace(/^[ \t]+/, '')
if (leaf?.kind === 'paragraph') leaf.lines.push(text)
else walk.leaf = { kind: 'paragraph', lines: [text] }
}
function closeLeaf(walk: Walk): void {
const leaf = walk.leaf
if (leaf === undefined) return
if (leaf.kind === 'paragraph') {
const text = takeParagraph(walk)
if (text !== undefined) currentBlocks(walk).push({ kind: 'paragraph', text })
return
}
walk.leaf = undefined
if (leaf.kind === 'html') currentBlocks(walk).push({ construct: leaf.construct, kind: 'html' })
else currentBlocks(walk).push({ kind: 'code', language: leaf.kind === 'fenced-code' ? leaf.info : '', text: leaf.lines.join('\n') })
}
function takeParagraph(walk: Walk): string | undefined {
const leaf = walk.leaf
if (leaf?.kind !== 'paragraph') return undefined
walk.leaf = undefined
const text = readLinkDefinitions(walk.definitions, leaf.lines.join('\n'))
return text === '' ? undefined : text
}
function currentBlocks(walk: Walk): Block[] {
return walk.stack.at(-1)?.blocks ?? walk.blocks
} }
function claimedConstruct(opener: string): ClaimedConstruct | undefined { function claimedConstruct(opener: string): ClaimedConstruct | undefined {
@@ -82,83 +279,11 @@ function claimedConstruct(opener: string): ClaimedConstruct | undefined {
return claimsPipeLine(opener) ? 'pipe-table' : undefined return claimsPipeLine(opener) ? 'pipe-table' : undefined
} }
// A setext underline over a paragraph the definitions emptied is no heading: it opens the next block. function normalizeInput(markdown: string): string {
function readLineBlock(walk: Walk, opener: string): boolean { return markdown
const level = walk.paragraph.length === 0 ? undefined : setextHeadingLevel(opener) .replace(/\r\n?/g, '\n')
if (level !== undefined) { .replaceAll('\u0000', '\ufffd')
const text = takeParagraph(walk) .replace(/\n$/, '')
if (text !== undefined) {
walk.blocks.push({ kind: 'heading', level, text })
return true
}
}
if (isThematicBreak(opener)) {
closeParagraph(walk)
walk.blocks.push({ kind: 'rule' })
return true
}
const heading = atxHeading(opener)
if (heading === undefined) return false
closeParagraph(walk)
walk.blocks.push({ kind: 'heading', level: heading.level, text: heading.text })
return true
}
function readFencedCode(walk: Walk, lines: readonly string[], start: number, fence: { info: string; marker: string }, indentation: number): number {
const collected: string[] = []
let index = start
while (index < lines.length) {
const line = lines[index] ?? ''
index += 1
if (closingCodeFence(removeColumns(line, largestOpenerIndentation), fence.marker)) break
collected.push(removeColumns(line, indentation))
}
walk.blocks.push({ kind: 'code', language: fence.info, text: collected.join('\n') })
return index
}
function readIndentedCode(walk: Walk, lines: readonly string[], start: number): number {
const collected: string[] = []
const held: string[] = []
let index = start
let end = start
while (index < lines.length) {
const line = lines[index] ?? ''
index += 1
if (blankLine.test(line)) {
held.push(removeColumns(line, indentedCodeColumns))
continue
}
if (leadingColumns(line) < indentedCodeColumns) break
collected.push(...held, removeColumns(line, indentedCodeColumns))
held.length = 0
end = index
}
walk.blocks.push({ kind: 'code', language: '', text: collected.join('\n') })
return end
}
function readHtmlBlock(walk: Walk, lines: readonly string[], start: number, html: OpenHtmlBlock): number {
let index = start
while (index < lines.length) {
const line = lines[index] ?? ''
if (html.closer === undefined && blankLine.test(line)) break
index += 1
if (html.closer !== undefined && html.closer.test(line)) break
}
walk.blocks.push({ construct: html.construct, kind: 'html' })
return index
}
function closeParagraph(walk: Walk): void {
const text = takeParagraph(walk)
if (text !== undefined) walk.blocks.push({ kind: 'paragraph', text })
}
function takeParagraph(walk: Walk): string | undefined {
const text = readLinkDefinitions(walk.definitions, walk.paragraph.join('\n'))
walk.paragraph = []
return text === '' ? undefined : text
} }
function leadingColumns(line: string): number { function leadingColumns(line: string): number {
@@ -25,6 +25,22 @@ function paragraph(value: string): AdfNode {
return { content: [text(value)], type: 'paragraph' } return { content: [text(value)], type: 'paragraph' }
} }
function item(...content: AdfNode[]): AdfNode {
return content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' }
}
function bulletList(...content: AdfNode[]): AdfNode {
return { content, type: 'bulletList' }
}
function orderedList(order: number, ...content: AdfNode[]): AdfNode {
return { attrs: { order }, content, type: 'orderedList' }
}
function quote(...content: AdfNode[]): AdfNode {
return content.length === 0 ? { type: 'blockquote' } : { content, type: 'blockquote' }
}
test('builds an empty document from input holding no block', () => { test('builds an empty document from input holding no block', () => {
assert.deepEqual(markdownToAdf(''), { ok: true, value: { type: 'doc', version: 1 } }) assert.deepEqual(markdownToAdf(''), { ok: true, value: { type: 'doc', version: 1 } })
assert.deepEqual(content(markdownToAdf('\n \n\t\n')), []) assert.deepEqual(content(markdownToAdf('\n \n\t\n')), [])
@@ -140,3 +156,72 @@ test('normalizes the line endings and the null character CommonMark replaces', (
assert.deepEqual(content(markdownToAdf('```\r\nx\r\n```\r\n')), [{ content: [text('x')], type: 'codeBlock' }]) assert.deepEqual(content(markdownToAdf('```\r\nx\r\n```\r\n')), [{ content: [text('x')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('a\u0000b\n')), [paragraph('a\ufffdb')]) assert.deepEqual(content(markdownToAdf('a\u0000b\n')), [paragraph('a\ufffdb')])
}) })
test('reads a blockquote and the blocks its prefix carries', () => {
assert.deepEqual(content(markdownToAdf('> Ship it.\n>\n> Then tell them.\n')), [quote(paragraph('Ship it.'), paragraph('Then tell them.'))])
assert.deepEqual(content(markdownToAdf('>Ship it.\n')), [quote(paragraph('Ship it.'))])
assert.deepEqual(content(markdownToAdf(' > > Deep.\n')), [quote(quote(paragraph('Deep.')))])
assert.deepEqual(content(markdownToAdf('>\n')), [quote()])
assert.deepEqual(content(markdownToAdf('> One.\n\n> Two.\n')), [quote(paragraph('One.')), quote(paragraph('Two.'))])
assert.deepEqual(content(markdownToAdf('Part.\n> Ship it.\n')), [paragraph('Part.'), quote(paragraph('Ship it.'))])
assert.deepEqual(content(markdownToAdf(' > Code.\n')), [{ content: [text('> Code.')], type: 'codeBlock' }])
})
test('reads a bullet list, the marker width setting the continuation', () => {
assert.deepEqual(content(markdownToAdf('- Bolt M8\n- Nut M8\n')), [bulletList(item(paragraph('Bolt M8')), item(paragraph('Nut M8')))])
assert.deepEqual(content(markdownToAdf('- Washer M8\n - Fibre\n')), [bulletList(item(paragraph('Washer M8'), bulletList(item(paragraph('Fibre')))))])
assert.deepEqual(content(markdownToAdf('-\n')), [bulletList(item())])
assert.deepEqual(content(markdownToAdf('- One\n\n Two.\n')), [bulletList(item(paragraph('One'), paragraph('Two.')))])
assert.deepEqual(content(markdownToAdf('- Code.\n')), [bulletList(item({ content: [text('Code.')], type: 'codeBlock' }))])
assert.deepEqual(content(markdownToAdf('- a\n* b\n')), [bulletList(item(paragraph('a'))), bulletList(item(paragraph('b')))])
assert.deepEqual(content(markdownToAdf('-\n\n Part.\n')), [bulletList(item()), paragraph('Part.')])
})
test('reads an ordered list, its first marker the order attribute', () => {
assert.deepEqual(content(markdownToAdf('9. Bolt M8\n10. Nut M8\n')), [orderedList(9, item(paragraph('Bolt M8')), item(paragraph('Nut M8')))])
assert.deepEqual(content(markdownToAdf('1) Loosen the clamp\n')), [orderedList(1, item(paragraph('Loosen the clamp')))])
assert.deepEqual(content(markdownToAdf('1. a\n1) b\n')), [orderedList(1, item(paragraph('a'))), orderedList(1, item(paragraph('b')))])
assert.deepEqual(content(markdownToAdf('0. Zero\n')), [orderedList(0, item(paragraph('Zero')))])
})
test('drops the tightness ADF does not record', () => {
assert.deepEqual(content(markdownToAdf('- a\n\n- b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))])
assert.deepEqual(content(markdownToAdf('- a\n\n 2. b\n')), [bulletList(item(paragraph('a'), orderedList(2, item(paragraph('b')))))])
})
test('opens a list beside a paragraph only where the marker interrupts it', () => {
assert.deepEqual(content(markdownToAdf('Part.\n- a\n')), [paragraph('Part.'), bulletList(item(paragraph('a')))])
assert.deepEqual(content(markdownToAdf('Part.\n1. a\n')), [paragraph('Part.'), orderedList(1, item(paragraph('a')))])
assert.deepEqual(content(markdownToAdf('Part.\n2. a\n')), [paragraph('Part. 2. a')])
assert.deepEqual(content(markdownToAdf('Part.\n*\n')), [paragraph('Part. *')])
assert.deepEqual(content(markdownToAdf('Part.\n- - -\n')), [paragraph('Part.'), { type: 'rule' }])
assert.deepEqual(content(markdownToAdf('Part.\n-\n')), [{ attrs: { level: 2 }, content: [text('Part.')], type: 'heading' }])
assert.deepEqual(content(markdownToAdf('- a\n 2. b\n')), [bulletList(item(paragraph('a 2. b')))])
})
test('folds a lazy continuation into the paragraph the container holds', () => {
assert.deepEqual(content(markdownToAdf('> One\ntwo.\n')), [quote(paragraph('One two.'))])
assert.deepEqual(content(markdownToAdf('- One\ntwo.\n')), [bulletList(item(paragraph('One two.')))])
assert.deepEqual(content(markdownToAdf('> One\n two.\n')), [quote(paragraph('One two.'))])
assert.deepEqual(content(markdownToAdf('> One\n\ntwo.\n')), [quote(paragraph('One')), paragraph('two.')])
assert.deepEqual(content(markdownToAdf('> One\n# Two\n')), [quote(paragraph('One')), { attrs: { level: 1 }, content: [text('Two')], type: 'heading' }])
assert.deepEqual(content(markdownToAdf('> One\n---\n')), [quote(paragraph('One')), { type: 'rule' }])
assert.deepEqual(content(markdownToAdf('> One\n```\n')), [quote(paragraph('One')), { type: 'codeBlock' }])
assert.equal(code(markdownToAdf('> One\n<div>\n')), 'unmappable-html')
})
test('ends a lazy continuation at a claimed line', () => {
assert.equal(code(markdownToAdf('> Part.\n:::\n')), 'malformed-directive')
assert.deepEqual(path(markdownToAdf('> Part.\n:::\n')), ['content', 1])
assert.equal(code(markdownToAdf('- Part.\n| x |\n')), 'malformed-pipe-table')
})
test('names the block the claim inside a container opens', () => {
assert.deepEqual(path(markdownToAdf('> Part.\n>\n> :::x\n')), ['content', 0, 'content', 1])
assert.deepEqual(path(markdownToAdf('- Part.\n- | x |\n')), ['content', 0, 'content', 1, 'content', 0])
})
test('refuses input nested deeper than the parser carries', () => {
assert.equal(code(markdownToAdf('> '.repeat(501))), 'unsupported-nesting-depth')
assert.ok(markdownToAdf('> '.repeat(500)).ok)
})
+32 -5
View File
@@ -1,28 +1,55 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { ClaimedConstruct, LeafBlock } from './blocks.ts' import type { Block, ClaimedConstruct } from './blocks.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { largestNesting } from '../../nesting.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { trimSpace } from '../commonmark-grammar.ts' import { trimSpace } from '../commonmark-grammar.ts'
export function markdownToAdf(markdown: string): Result<AdfDocument> { export function markdownToAdf(markdown: string): Result<AdfDocument> {
const content = blockNodes(parseBlocks(markdown).blocks, [], 0)
if (!content.ok) return content
return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 })
}
function blockNodes(blocks: readonly Block[], path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
const content: AdfNode[] = [] const content: AdfNode[] = []
for (const [index, block] of parseBlocks(markdown).blocks.entries()) { for (const [index, block] of blocks.entries()) {
const node = blockNode(block, ['content', index]) const node = blockNode(block, [...path, 'content', index], depth)
if (!node.ok) return node if (!node.ok) return node
content.push(node.value) content.push(node.value)
} }
return success(content.length === 0 ? { type: 'doc', version: 1 } : { content, type: 'doc', version: 1 }) return success(content)
} }
function blockNode(block: LeafBlock, path: ConvertErrorPath): Result<AdfNode> { function blockNode(block: Block, path: ConvertErrorPath, depth: number): Result<AdfNode> {
if (block.kind === 'blockquote') return containerNode({ type: 'blockquote' }, block.blocks, path, depth)
if (block.kind === 'bulletList') return listNode({ type: 'bulletList' }, block.items, path, depth)
if (block.kind === 'claim') return claimFailure(block.construct, path) if (block.kind === 'claim') return claimFailure(block.construct, path)
if (block.kind === 'code') return success(codeBlockNode(block.language, block.text)) if (block.kind === 'code') return success(codeBlockNode(block.language, block.text))
if (block.kind === 'heading') return success(withContent({ attrs: { level: block.level }, type: 'heading' }, block.text)) if (block.kind === 'heading') return success(withContent({ attrs: { level: block.level }, type: 'heading' }, block.text))
if (block.kind === 'html') return failure('unmappable-html', `no ADF node carries ${block.construct}`, path) if (block.kind === 'html') return failure('unmappable-html', `no ADF node carries ${block.construct}`, path)
if (block.kind === 'orderedList') return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, path, depth)
if (block.kind === 'paragraph') return success(withContent({ type: 'paragraph' }, block.text)) if (block.kind === 'paragraph') return success(withContent({ type: 'paragraph' }, block.text))
return success({ type: 'rule' }) return success({ type: 'rule' })
} }
function containerNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath, depth: number): Result<AdfNode> {
const content = blockNodes(blocks, path, depth + 1)
if (!content.ok) return content
return success(content.value.length === 0 ? node : { ...node, content: content.value })
}
function listNode(node: AdfNode, items: readonly Block[][], path: ConvertErrorPath, depth: number): Result<AdfNode> {
const content: AdfNode[] = []
for (const [index, blocks] of items.entries()) {
const item = containerNode({ type: 'listItem' }, blocks, [...path, 'content', index], depth)
if (!item.ok) return item
content.push(item.value)
}
return success({ ...node, content })
}
function claimFailure(construct: ClaimedConstruct, path: ConvertErrorPath): Result<AdfNode> { function claimFailure(construct: ClaimedConstruct, path: ConvertErrorPath): Result<AdfNode> {
if (construct === 'directive') return failure('malformed-directive', 'the line claims a directive and parses as none', path) if (construct === 'directive') return failure('malformed-directive', 'the line claims a directive and parses as none', path)
return failure('malformed-pipe-table', 'the line claims a pipe table and parses as none', path) return failure('malformed-pipe-table', 'the line claims a pipe table and parses as none', path)
+1
View File
@@ -11,6 +11,7 @@ export type ConvertErrorCode =
| 'unspellable-whitespace' | 'unspellable-whitespace'
| 'unspelled-block-separation' | 'unspelled-block-separation'
| 'unsupported-document-version' | 'unsupported-document-version'
| 'unsupported-nesting-depth'
| 'unsupported-node-shape' | 'unsupported-node-shape'
export type ConvertErrorPath = readonly (number | string)[] export type ConvertErrorPath = readonly (number | string)[]
+3 -3
View File
@@ -208,7 +208,7 @@ detail is settled at its own milestone.
error the claim promises meanwhile. The runner's parse half comes with it, and the first error the claim promises meanwhile. The runner's parse half comes with it, and the first
`normalization/` fixtures, holding inline-trivial content so 3d and 3e add beside them `normalization/` fixtures, holding inline-trivial content so 3d and 3e add beside them
rather than editing them. rather than editing them.
- [ ] **3c — The container blocks.** Blockquote, bullet and ordered list: the continuation a - [x] **3c — The container blocks.** Blockquote, bullet and ordered list: the continuation a
marker's width sets, lazy continuation, and the tightness ADF does not record — `> ` marker's width sets, lazy continuation, and the tightness ADF does not record — `> `
repeated being two bytes a level, so this is the cheapest way to reach §11's 500. 3b's leaf repeated being two bytes a level, so this is the cheapest way to reach §11's 500. 3b's leaf
readers scan the physical line themselves, so a container re-cuts the walk rather than adding readers scan the physical line themselves, so a container re-cuts the walk rather than adding
@@ -225,7 +225,7 @@ detail is settled at its own milestone.
where the nested list would be swallowed — `interruptsParagraph` inverted from a refusal where the nested list would be swallowed — `interruptsParagraph` inverted from a refusal
into the separation it names, and `spec/flavour.md`'s "none between a nested list and a into the separation it names, and `spec/flavour.md`'s "none between a nested list and a
CommonMark block above it" gaining that exception. Every fixture spelled tight today keeps CommonMark block above it" gaining that exception. Every fixture spelled tight today keeps
its bytes, and `nested-list-tight` becomes a round-trip pair. its bytes, and `nested-list-tight` becomes the round-trip pair `nested-list-separation`.
- [ ] **3d — Inline text.** The inline scanner over a block's content: backslash escapes, entity - [ ] **3d — Inline text.** The inline scanner over a block's content: backslash escapes, entity
references decoding to their characters, code spans and the literal they hold — directive references decoding to their characters, code spans and the literal they hold — directive
syntax and `~~` included — CommonMark's own hard breaks, a trailing backslash and two syntax and `~~` included — CommonMark's own hard breaks, a trailing backslash and two
@@ -270,7 +270,7 @@ detail is settled at its own milestone.
in a container body with no blank line between them. That reduces the three cases to one in a container body with no blank line between them. That reduces the three cases to one
rule, separation only where its absence would merge the blocks: the `:::` fence is rule, separation only where its absence would merge the blocks: the `:::` fence is
separation already, and 3c's claim ends the lazy continuation that would otherwise swallow separation already, and 3c's claim ends the lazy continuation that would otherwise swallow
it. The fixture becomes a round-trip pair, and with `nested-list-tight` and 3e's pair that it. The fixture becomes a round-trip pair, and with `nested-list-separation` and 3e's pair that
empties `corpus/unspellable/`: this chunk settles the directory's own guard in empties `corpus/unspellable/`: this chunk settles the directory's own guard in
`corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here. `corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here.
The emitter's other refusals survive on causes no fixture in that directory covers, so The emitter's other refusals survive on causes no fixture in that directory covers, so