Read the leaf blocks, ahead of any inline parsing #31

Merged
lilleman merged 5 commits from tick-3b into main 2026-08-28 12:11:35 +02:00
22 changed files with 746 additions and 26 deletions
+5 -3
View File
@@ -82,8 +82,9 @@ closed list a consumer may switch exhaustively, the message free text, the path
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, one code covers them all and `path` and `message`
say which. A cause the carry answers gets no code: a mark no spelling writes rides the carry with say which. A claim code names the spelling claimed, never the node that spelling would have built:
its node. 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.
## 9. Release automation ## 9. Release automation
@@ -139,7 +140,8 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
unrepresentable. unrepresentable.
- `src/adf/` holds ADF's own knowledge and imports no format. Each format directory (`markdown/`, - `src/adf/` holds ADF's own knowledge and imports no format. Each format directory (`markdown/`,
`html/`) parts into `emit/` (ADF→format) and `parse/` (format→ADF), its root holding what both `html/`) parts into `emit/` (ADF→format) and `parse/` (format→ADF), its root holding what both
directions read. directions read. A construct's reader lives in that root beside the regex the emitter escapes
against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`.
- The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a - The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a
format spells the narrowed value. A spelling that re-checks the type is the check's second copy. format spells the narrowed value. A spelling that re-checks the type is the check's second copy.
- Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a - Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a
+1
View File
@@ -0,0 +1 @@
malformed-directive
+1
View File
@@ -0,0 +1 @@
:::
+1
View File
@@ -0,0 +1 @@
unmappable-html
+1
View File
@@ -0,0 +1 @@
<!-- Draft: the collation note -->
+1
View File
@@ -0,0 +1 @@
malformed-pipe-table
+1
View File
@@ -0,0 +1 @@
| Part | Qty |
+15
View File
@@ -0,0 +1,15 @@
{
"content": [
{
"content": [
{
"text": "SELECT id\nFROM part",
"type": "text"
}
],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
+2
View File
@@ -0,0 +1,2 @@
SELECT id
FROM part
+30
View File
@@ -0,0 +1,30 @@
{
"content": [
{
"attrs": {
"level": 1
},
"content": [
{
"text": "Assembly",
"type": "text"
}
],
"type": "heading"
},
{
"attrs": {
"level": 2
},
"content": [
{
"text": "Parts",
"type": "text"
}
],
"type": "heading"
}
],
"type": "doc",
"version": 1
}
+5
View File
@@ -0,0 +1,5 @@
Assembly
========
Parts
-----
+42 -10
View File
@@ -1,15 +1,18 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs' import { readFileSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path' import { basename, dirname, join } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
import { isAdfDocument } from './adf/document.ts' import { isAdfDocument } from './adf/document.ts'
import { isJsonValue } from './json-value.ts' import { isJsonValue } from './json-value.ts'
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from './canonical-json.ts'
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus') const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
const errorsRoot = join(corpusRoot, 'errors')
const normalizationRoot = join(corpusRoot, 'normalization')
const roundTripRoot = join(corpusRoot, 'round-trip') const roundTripRoot = join(corpusRoot, 'round-trip')
const unspellableRoot = join(corpusRoot, 'unspellable') const unspellableRoot = join(corpusRoot, 'unspellable')
@@ -33,6 +36,21 @@ function names(root: string, extension: string): string[] {
.sort() .sort()
} }
// One kind's fixture pairs, its two tests declared with them.
function pairedNames(root: string, first: string, second: string): string[] {
const kind = basename(root)
test(`${kind} pairs every ${first} with a ${second}`, () => {
assert.deepEqual(names(root, first), names(root, second))
})
test(`${kind} holds fixtures`, () => {
assert.ok(names(root, first).length > 0)
})
return names(root, first)
}
function corpusJsonPaths(): string[] { function corpusJsonPaths(): string[] {
return readdirSync(corpusRoot, { encoding: 'utf8', recursive: true }) return readdirSync(corpusRoot, { encoding: 'utf8', recursive: true })
.filter((name) => name.endsWith('.json')) .filter((name) => name.endsWith('.json'))
@@ -40,6 +58,10 @@ function corpusJsonPaths(): string[] {
.sort() .sort()
} }
test('every corpus directory is a kind the runner reads', () => {
assert.deepEqual(directoryNames(corpusRoot), ['errors', 'normalization', 'round-trip', 'unspellable'])
})
test('every round-trip directory emits', () => { test('every round-trip directory emits', () => {
assert.deepEqual(directoryNames(roundTripRoot), [...emittingDirectories].sort()) assert.deepEqual(directoryNames(roundTripRoot), [...emittingDirectories].sort())
}) })
@@ -144,15 +166,7 @@ for (const directory of emittingDirectories) {
} }
} }
test('unspellable pairs every .json with an .error', () => { for (const name of pairedNames(unspellableRoot, '.json', '.error')) {
assert.deepEqual(names(unspellableRoot, '.json'), names(unspellableRoot, '.error'))
})
test('unspellable holds fixtures', () => {
assert.ok(names(unspellableRoot, '.json').length > 0)
})
for (const name of names(unspellableRoot, '.json')) {
test(`unspellable/${name} is refused with the error it names`, () => { test(`unspellable/${name} is refused with the error it names`, () => {
const parsed: unknown = JSON.parse(readFileSync(join(unspellableRoot, `${name}.json`), 'utf8')) const parsed: unknown = JSON.parse(readFileSync(join(unspellableRoot, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(parsed), `${name}.json is not an ADF document`) assert.ok(isAdfDocument(parsed), `${name}.json is not an ADF document`)
@@ -162,6 +176,24 @@ for (const name of names(unspellableRoot, '.json')) {
}) })
} }
for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
test(`normalization/${name} parses to the document beside it`, () => {
const expected: unknown = JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(result.value, expected)
})
}
for (const name of pairedNames(errorsRoot, '.md', '.error')) {
test(`errors/${name} is refused with the error it names`, () => {
const result = markdownToAdf(readFileSync(join(errorsRoot, `${name}.md`), 'utf8'))
assert.ok(!result.ok, result.ok ? `built ${JSON.stringify(result.value)}` : '')
assert.equal(result.error.code, readFileSync(join(errorsRoot, `${name}.error`), 'utf8').trimEnd())
})
}
test('the corpus holds JSON to gate', () => { test('the corpus holds JSON to gate', () => {
assert.ok(corpusJsonPaths().length > 0) assert.ok(corpusJsonPaths().length > 0)
}) })
+45 -2
View File
@@ -12,17 +12,43 @@ const controlCharacter = new RegExp(`[${controlCharacterRange}]`)
const entityReference = new RegExp(entityReferenceSource) const entityReference = new RegExp(entityReferenceSource)
const nullCharacter = new RegExp(nullCharacterSource) const nullCharacter = new RegExp(nullCharacterSource)
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
const firstCharacterOpeners = [/^#{1,6}(?:[ \t]|$)/, /^>/, /^[*+-](?:[ \t]|$)/, /^`{3,}/, /^~{3,}/, /^:{2,}/, /^\|/] const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/
const codeFenceOpener = /^(`{3,}|~{3,})/
const directiveClaim = /^:{2,}(?:[A-Za-z0-9]|[ \t]*$)/
const pipeClaim = /^\|/
// A superset of what the parser claims: over-escaping a line is safe, under-escaping one breaks the round-trip.
const firstCharacterOpeners = [atxHeadingOpener, /^>/, /^[*+-](?:[ \t]|$)/, codeFenceOpener, /^:{2,}/, pipeClaim]
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/] const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
const orderedListOpener = /^(\d{1,9})[.)](?:[ \t]|$)/ const orderedListOpener = /^(\d{1,9})[.)](?:[ \t]|$)/
const setextUnderline = /^(?:=+|-+)$/ const setextUnderline = /^(=+|-+)[ \t]*$/
const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/ const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u
export function atxHeading(line: string): { level: number; text: string } | undefined {
const hashes = atxHeadingOpener.exec(line)?.[1]
if (hashes === undefined) return undefined
const text = trimSpace(line.slice(hashes.length))
return { level: hashes.length, text: trimSpace(text.replace(/(?:^|(?<=[ \t]))#+$/, '')) }
}
export function claimsDirectiveLine(line: string): boolean {
return directiveClaim.test(line)
}
export function claimsLine(line: string, position: LinePosition): boolean { export function claimsLine(line: string, position: LinePosition): boolean {
return escapesLineClaim(line, 0, position) || orderedListOpener.test(line) return escapesLineClaim(line, 0, position) || orderedListOpener.test(line)
} }
export function claimsPipeLine(line: string): boolean {
return pipeClaim.test(line)
}
export function closingCodeFence(line: string, marker: string): boolean {
const closing = codeFenceOpener.exec(line)?.[1]
if (closing === undefined || closing.charAt(0) !== marker.charAt(0) || closing.length < marker.length) return false
return /^[ \t]*$/.test(line.slice(closing.length))
}
export function escapesLineClaim(line: string, offset: number, position: LinePosition): boolean { export function escapesLineClaim(line: string, offset: number, position: LinePosition): boolean {
if (offset === 0) { if (offset === 0) {
if (firstCharacterOpeners.some((opener) => opener.test(line)) || thematicBreak.test(line)) return true if (firstCharacterOpeners.some((opener) => opener.test(line)) || thematicBreak.test(line)) return true
@@ -60,6 +86,13 @@ export function isUnicodeWhitespace(character: string): boolean {
return unicodeWhitespace.test(character) return unicodeWhitespace.test(character)
} }
export function openingCodeFence(line: string): { info: string; marker: string } | undefined {
const marker = codeFenceOpener.exec(line)?.[1]
if (marker === undefined) return undefined
const info = trimSpace(line.slice(marker.length))
return marker.startsWith('`') && info.includes('`') ? undefined : { info, marker }
}
export function opensBracketedAutolink(text: string): boolean { export function opensBracketedAutolink(text: string): boolean {
return bracketedAutolink.test(text) return bracketedAutolink.test(text)
} }
@@ -68,6 +101,16 @@ export function opensHtmlConstruct(text: string): boolean {
return htmlConstructs.some((construct) => construct.test(text)) return htmlConstructs.some((construct) => construct.test(text))
} }
export function setextHeadingLevel(line: string): number | undefined {
const underline = setextUnderline.exec(line)?.[1]
if (underline === undefined) return undefined
return underline.startsWith('=') ? 1 : 2
}
export function startsEntityReference(text: string): boolean { export function startsEntityReference(text: string): boolean {
return anchoredEntityReference.test(text) return anchoredEntityReference.test(text)
} }
export function trimSpace(text: string): string {
return text.replace(/^[ \t]+|[ \t]+$/g, '')
}
+1
View File
@@ -29,6 +29,7 @@ type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; deli
const delimiters = ['*', '_', '`', '~'] const delimiters = ['*', '_', '`', '~']
// The `:` keeps a `[label]: url` line escaped: unescaped, the parser swallows it as a link reference definition.
const followsLinkText = /[([:]/ const followsLinkText = /[([:]/
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine { export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
+60
View File
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { LinkDefinition } from './link-reference-definitions.ts'
import { parseBlocks } from './blocks.ts'
function definitions(markdown: string): [string, LinkDefinition][] {
return [...parseBlocks(markdown).definitions]
}
function kinds(markdown: string): string[] {
return parseBlocks(markdown).blocks.map((block) => block.kind)
}
test('keeps the link reference definitions a paragraph gives up, the first of a label winning', () => {
assert.deepEqual(definitions('[a]: /url\n'), [['a', { destination: '/url' }]])
assert.deepEqual(definitions('[Foo Bar]:\n<the url>\n"Title"\n'), [['foo bar', { destination: 'the url', title: 'Title' }]])
assert.deepEqual(definitions("[a]: /url 'One'\n[a]: /other (Two)\n[b]: /b\n"), [
['a', { destination: '/url', title: 'One' }],
['b', { destination: '/b' }],
])
assert.deepEqual(definitions('[a\\]b]: /url\n'), [['a\\]b', { destination: '/url' }]])
assert.deepEqual(definitions('[a]: /url(x)y\n'), [['a', { destination: '/url(x)y' }]])
assert.deepEqual(definitions('[a]: /url\\(x\n'), [['a', { destination: '/url\\(x' }]])
assert.deepEqual(definitions('[a]: <>\n'), [['a', { destination: '' }]])
assert.deepEqual(definitions('[a]: /url "He said \\"hi\\""\n'), [['a', { destination: '/url', title: 'He said \\"hi\\"' }]])
assert.deepEqual(definitions('[a]: /url\\\n[b]: /b\n'), [
['a', { destination: '/url\\' }],
['b', { destination: '/b' }],
])
assert.deepEqual(definitions('[\u00a0a]: /one\n[a]: /two\n'), [
['\u00a0a', { destination: '/one' }],
['a', { destination: '/two' }],
])
})
test('leaves the paragraph a line no definition spells', () => {
assert.deepEqual(definitions('[]: /url\n'), [])
assert.deepEqual(definitions('[ ]: /url\n'), [])
assert.deepEqual(definitions('[a]: <un>closed>\n'), [])
assert.deepEqual(definitions('[a]: <unclosed\n'), [])
assert.deepEqual(definitions('[a]: /url)x\n'), [])
assert.deepEqual(definitions('[a]: /url "One" and more\n'), [])
assert.deepEqual(definitions('[a]:\n'), [])
assert.deepEqual(definitions('[a]: /url "unclosed\n'), [])
assert.deepEqual(kinds('[a]: /url\nPart.\n'), ['paragraph'])
})
test('swallows an HTML block to the end condition its start sets', () => {
assert.deepEqual(kinds('<div>\nx\n\nPart.\n'), ['html', 'paragraph'])
assert.deepEqual(kinds('<!--\n:::\n-->\nPart.\n'), ['html', 'paragraph'])
assert.deepEqual(kinds('<pre>x</pre>\nPart.\n'), ['html', 'paragraph'])
assert.deepEqual(kinds('<div>\nx\n'), ['html'])
assert.deepEqual(kinds('Part.\n<div>\n'), ['paragraph', 'html'])
})
test('carries a claimed line as the block it opens, the refusal the node layer builds', () => {
assert.deepEqual(kinds(':::\nPart.\n'), ['claim', 'paragraph'])
assert.deepEqual(kinds('Part.\n| x |\n'), ['paragraph', 'claim'])
})
+187
View File
@@ -0,0 +1,187 @@
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 { openingHtmlBlock } from './html-blocks.ts'
import { readLinkDefinitions } from './link-reference-definitions.ts'
export type ClaimedConstruct = 'directive' | 'pipe-table'
export type LeafBlock =
| { construct: ClaimedConstruct; kind: 'claim' }
| { construct: string; kind: 'html' }
| { kind: 'code'; language: string; text: string }
| { kind: 'heading'; level: number; text: string }
| { kind: 'paragraph'; text: string }
| { kind: 'rule' }
export type ParsedBlocks = { blocks: LeafBlock[]; definitions: Map<string, LinkDefinition> }
type Walk = ParsedBlocks & { paragraph: string[] }
const blankLine = /^[ \t]*$/
const indentedCodeColumns = 4
const largestOpenerIndentation = 3
const tabStop = 4
export function parseBlocks(markdown: string): ParsedBlocks {
const lines = normalizeInput(markdown).split('\n')
const walk: Walk = { blocks: [], definitions: new Map(), paragraph: [] }
let index = 0
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 }
}
function openBlock(walk: Walk, lines: readonly string[], index: number, line: string): number | undefined {
const opener = removeColumns(line, largestOpenerIndentation)
const claimed = claimedConstruct(opener)
if (claimed !== undefined) {
closeParagraph(walk)
walk.blocks.push({ construct: claimed, kind: 'claim' })
return index + 1
}
if (readLineBlock(walk, opener)) return index + 1
const fence = openingCodeFence(opener)
if (fence !== undefined) {
closeParagraph(walk)
return readFencedCode(walk, lines, index + 1, fence, leadingColumns(line))
}
const html = openingHtmlBlock(opener, walk.paragraph.length > 0)
if (html === undefined) return undefined
closeParagraph(walk)
return readHtmlBlock(walk, lines, index, html)
}
function normalizeInput(markdown: string): string {
return markdown
.replace(/\r\n?/g, '\n')
.replaceAll('\u0000', '\ufffd')
.replace(/\n$/, '')
}
function claimedConstruct(opener: string): ClaimedConstruct | undefined {
if (claimsDirectiveLine(opener)) return 'directive'
return claimsPipeLine(opener) ? 'pipe-table' : undefined
}
// A setext underline over a paragraph the definitions emptied is no heading: it opens the next block.
function readLineBlock(walk: Walk, opener: string): boolean {
const level = walk.paragraph.length === 0 ? undefined : setextHeadingLevel(opener)
if (level !== undefined) {
const text = takeParagraph(walk)
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 {
let columns = 0
for (const character of line) {
if (character === ' ') columns += 1
else if (character === '\t') columns += tabStop - (columns % tabStop)
else break
}
return columns
}
// CommonMark's tab stops: a tab the cut splits gives the columns it holds past the cut back as spaces.
function removeColumns(line: string, columns: number): string {
let removed = 0
let index = 0
while (removed < columns && index < line.length) {
const character = line.charAt(index)
if (character !== ' ' && character !== '\t') break
const width = character === ' ' ? 1 : tabStop - (removed % tabStop)
index += 1
if (removed + width > columns) return ' '.repeat(removed + width - columns) + line.slice(index)
removed += width
}
return line.slice(index)
}
+28
View File
@@ -0,0 +1,28 @@
export type OpenHtmlBlock = { closer: RegExp | undefined; construct: string }
type HtmlBlockCondition = { closer: RegExp | undefined; construct: string | undefined; interrupts: boolean; start: RegExp }
// CommonMark 0.31.2, HTML blocks: the tag names start condition 6 lists.
const blockTagNames =
'address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul'
const attributeSource = '(?:[ \\t]+[A-Za-z_:][A-Za-z0-9_.:-]*(?:[ \\t]*=[ \\t]*(?:[^ \\t"\'=<>`]+|\'[^\']*\'|"[^"]*"))?)'
const completeTag = new RegExp(`^(?:<[A-Za-z][A-Za-z0-9-]*${attributeSource}*[ \\t]*/?>|</[A-Za-z][A-Za-z0-9-]*[ \\t]*>)[ \\t]*$`)
const tagName = /^<\/?([A-Za-z][A-Za-z0-9-]*).*$/
const conditions: HtmlBlockCondition[] = [
{ closer: /<\/(?:pre|script|style|textarea)>/i, construct: undefined, interrupts: true, start: /^<(?:pre|script|style|textarea)(?:[ \t>]|$)/i },
{ closer: /-->/, construct: 'an HTML comment', interrupts: true, start: /^<!--/ },
{ closer: /\?>/, construct: 'an HTML processing instruction', interrupts: true, start: /^<\?/ },
{ closer: />/, construct: 'an HTML declaration', interrupts: true, start: /^<![A-Za-z]/ },
{ closer: /\]\]>/, construct: 'a CDATA section', interrupts: true, start: /^<!\[CDATA\[/ },
{ closer: undefined, construct: undefined, interrupts: true, start: new RegExp(`^</?(?:${blockTagNames})(?:[ \\t>]|/>|$)`, 'i') },
{ closer: undefined, construct: undefined, interrupts: false, start: completeTag },
]
export function openingHtmlBlock(line: string, interrupting: boolean): OpenHtmlBlock | undefined {
for (const condition of conditions) {
if ((interrupting && !condition.interrupts) || !condition.start.test(line)) continue
return { closer: condition.closer, construct: condition.construct ?? line.replace(tagName, '<$1>') }
}
return undefined
}
@@ -0,0 +1,111 @@
import { holdsControlCharacter, isAsciiPunctuation } from '../commonmark-grammar.ts'
export type LinkDefinition = { destination: string; title?: string }
type ReadDefinition = { definition: LinkDefinition; label: string; length: number }
type ReadValue = { length: number; value: string }
const bracketedDestination = /^<((?:[^\n<>\\]|\\[^\n])*)>/
const label = /^\[((?:[^[\]\\]|\\[\s\S]){1,999})\]:/
const restOfLine = /^[ \t]*(?:\n|$)/
const titleClosers: Readonly<Record<string, string>> = { '"': '"', "'": "'", '(': ')' }
export function readLinkDefinitions(definitions: Map<string, LinkDefinition>, text: string): string {
let rest = text
let read = readDefinition(rest)
while (read !== undefined) {
if (!definitions.has(read.label)) definitions.set(read.label, read.definition)
rest = rest.slice(read.length)
read = readDefinition(rest)
}
return rest
}
function readDefinition(text: string): ReadDefinition | undefined {
const matched = label.exec(text)
if (matched === null) return undefined
const name = normalizeLabel(matched[1] ?? '')
if (name === '') return undefined
const afterLabel = skipSpace(text, matched[0].length)
const destination = readDestination(text, afterLabel)
if (destination === undefined) return undefined
const afterDestination = afterLabel + destination.length
const titled = readTitledEnd(text, afterDestination)
if (titled !== undefined) return { definition: { destination: destination.value, title: titled.value }, label: name, length: titled.length }
const plain = endOfLine(text, afterDestination)
if (plain === undefined) return undefined
return { definition: { destination: destination.value }, label: name, length: plain }
}
function normalizeLabel(raw: string): string {
return raw
.replace(/^[ \t\n]+|[ \t\n]+$/g, '')
.replace(/[ \t\n]+/g, ' ')
.toLowerCase()
}
function readTitledEnd(text: string, offset: number): ReadValue | undefined {
const afterSpace = skipSpace(text, offset)
if (afterSpace === offset) return undefined
const title = readTitle(text, afterSpace)
if (title === undefined) return undefined
const end = endOfLine(text, afterSpace + title.length)
return end === undefined ? undefined : { length: end, value: title.value }
}
function readDestination(text: string, offset: number): ReadValue | undefined {
const bracketed = bracketedDestination.exec(text.slice(offset))
if (bracketed !== null) return { length: bracketed[0].length, value: bracketed[0].slice(1, -1) }
if (text.charAt(offset) === '<') return undefined
let depth = 0
let index = offset
while (index < text.length) {
const character = text.charAt(index)
if (character === ' ' || holdsControlCharacter(character)) break
if (escapesNext(text, index)) {
index += 2
continue
}
if (character === '(') depth += 1
if (character === ')') {
depth -= 1
if (depth < 0) break
}
index += 1
}
return index <= offset ? undefined : { length: index - offset, value: text.slice(offset, index) }
}
function readTitle(text: string, offset: number): ReadValue | undefined {
const opener = text.charAt(offset)
const closer = titleClosers[opener]
if (closer === undefined) return undefined
let index = offset + 1
while (index < text.length) {
const character = text.charAt(index)
if (escapesNext(text, index)) {
index += 2
continue
}
if (character === closer) return { length: index + 1 - offset, value: text.slice(offset + 1, index) }
if (character === opener) return undefined
index += 1
}
return undefined
}
// A backslash escapes ASCII punctuation only, so a line ending always ends the destination it follows.
function escapesNext(text: string, index: number): boolean {
return text.charAt(index) === '\\' && isAsciiPunctuation(text.charAt(index + 1))
}
// The label, the destination and the title each take at most one line ending with them.
function skipSpace(text: string, offset: number): number {
const rest = text.slice(offset)
return offset + rest.length - rest.replace(/^[ \t]*\n?[ \t]*/, '').length
}
function endOfLine(text: string, offset: number): number | undefined {
const rest = restOfLine.exec(text.slice(offset))?.[0]
return rest === undefined ? undefined : offset + rest.length
}
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Result } from '../../result.ts'
import { markdownToAdf } from './markdown-to-adf.ts'
function code(result: Result<AdfDocument>): string {
return result.ok ? `built ${JSON.stringify(result.value)}` : result.error.code
}
function content(result: Result<AdfDocument>): AdfNode[] | string {
return result.ok ? (result.value.content ?? []) : `${result.error.code}: ${result.error.message}`
}
function path(result: Result<AdfDocument>): readonly (number | string)[] {
return result.ok ? ['built'] : result.error.path
}
function text(value: string): AdfNode {
return { text: value, type: 'text' }
}
function paragraph(value: string): AdfNode {
return { content: [text(value)], type: 'paragraph' }
}
test('builds an empty document from input holding no block', () => {
assert.deepEqual(markdownToAdf(''), { ok: true, value: { type: 'doc', version: 1 } })
assert.deepEqual(content(markdownToAdf('\n \n\t\n')), [])
})
test('builds one paragraph from the lines a blank line does not part', () => {
assert.deepEqual(content(markdownToAdf('One\ntwo.\n\nThree.\n')), [paragraph('One two.'), paragraph('Three.')])
})
test('reads an ATX heading and its level', () => {
assert.deepEqual(content(markdownToAdf('# Assembly\n')), [{ attrs: { level: 1 }, content: [text('Assembly')], type: 'heading' }])
assert.deepEqual(content(markdownToAdf('###### M8\n')), [{ attrs: { level: 6 }, content: [text('M8')], type: 'heading' }])
assert.deepEqual(content(markdownToAdf(' ## Parts ##\n')), [{ attrs: { level: 2 }, content: [text('Parts')], type: 'heading' }])
assert.deepEqual(content(markdownToAdf('#\n')), [{ attrs: { level: 1 }, type: 'heading' }])
assert.deepEqual(content(markdownToAdf('####### Seven\n')), [paragraph('####### Seven')])
assert.deepEqual(content(markdownToAdf('#hashtag\n')), [paragraph('#hashtag')])
})
test('reads a setext underline as the heading level it spells', () => {
assert.deepEqual(content(markdownToAdf('Assembly\n===\n')), [{ attrs: { level: 1 }, content: [text('Assembly')], type: 'heading' }])
assert.deepEqual(content(markdownToAdf('One\ntwo\n-\n')), [{ attrs: { level: 2 }, content: [text('One two')], type: 'heading' }])
assert.deepEqual(content(markdownToAdf('===\n')), [paragraph('===')])
})
test('reads a thematic break, the dashed one only where no paragraph is open', () => {
assert.deepEqual(content(markdownToAdf('---\n')), [{ type: 'rule' }])
assert.deepEqual(content(markdownToAdf('Part.\n\n * * *\n')), [paragraph('Part.'), { type: 'rule' }])
assert.deepEqual(content(markdownToAdf('___\n')), [{ type: 'rule' }])
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' }])
})
test('reads a fenced code block, its info string the language', () => {
assert.deepEqual(content(markdownToAdf('```sql\nSELECT id\nFROM part\n```\n')), [
{ attrs: { language: 'sql' }, content: [text('SELECT id\nFROM part')], type: 'codeBlock' },
])
assert.deepEqual(content(markdownToAdf('```\nx\n```\n')), [{ content: [text('x')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('``` rust \nx\n```\n')), [{ attrs: { language: 'rust' }, content: [text('x')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('```\n```\n')), [{ type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('```\nx\n')), [{ content: [text('x')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('````\n```\n````\n')), [{ content: [text('```')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('~~~ a`b\n```\n~~~\n')), [{ attrs: { language: 'a`b' }, content: [text('```')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('``` a`b\n')), [paragraph('``` a`b')])
assert.deepEqual(content(markdownToAdf('```\n``` x\n```\n')), [{ content: [text('``` x')], type: 'codeBlock' }])
})
test('strips the opening fence indentation from the content lines it holds', () => {
assert.deepEqual(content(markdownToAdf(' ```\n x\n y\n ```\n')), [{ content: [text(' x\ny')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf(' ```\n\tx\n ```\n')), [{ content: [text(' x')], type: 'codeBlock' }])
})
test('reads an indented code block where no paragraph is open', () => {
assert.deepEqual(content(markdownToAdf(' SELECT id\n')), [{ content: [text('SELECT id')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('\tSELECT id\n')), [{ content: [text('SELECT id')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf(' x\n')), [{ content: [text(' x')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf(' a\n\n b\n\nPart.\n')), [{ content: [text('a\n\nb')], type: 'codeBlock' }, paragraph('Part.')])
assert.deepEqual(content(markdownToAdf('Part.\n more\n')), [paragraph('Part. more')])
})
test('claims a block-level colon run with no directive to parse it', () => {
assert.equal(code(markdownToAdf(':::\n')), 'malformed-directive')
assert.equal(code(markdownToAdf('::panel\n')), 'malformed-directive')
assert.equal(code(markdownToAdf(' :::panel info\nx\n:::\n')), 'malformed-directive')
assert.deepEqual(path(markdownToAdf('Part.\n:::x\n')), ['content', 1])
assert.deepEqual(content(markdownToAdf(':10:30\n')), [paragraph(':10:30')])
assert.deepEqual(content(markdownToAdf(':: two\n')), [paragraph(':: two')])
})
test('claims a block-level pipe with no table to parse it', () => {
assert.equal(code(markdownToAdf('| Part | Qty |\n')), 'malformed-pipe-table')
assert.deepEqual(content(markdownToAdf('\\| Part\n')), [paragraph('\\| Part')])
})
test('refuses the raw HTML no element mapping carries', () => {
assert.equal(code(markdownToAdf('<!-- note -->\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<div>\nx\n</div>\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<?php ?>\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<!DOCTYPE html>\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<![CDATA[x]]>\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<pre>\nx\n</pre>\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<span foo="bar">\n')), 'unmappable-html')
assert.deepEqual(path(markdownToAdf('Part.\n\n<div>\n')), ['content', 1])
assert.equal(code(markdownToAdf('<div>\nx\n\n:::\n')), 'unmappable-html')
})
test('swallows an HTML block ahead of the claim a line inside it would make', () => {
assert.equal(code(markdownToAdf('<!--\n:::\n-->\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<div>\n| x |\n</div>\n')), 'unmappable-html')
})
test('leaves a tag that opens no HTML block to the paragraph it sits in', () => {
assert.deepEqual(content(markdownToAdf('Part.\n<span>\n')), [paragraph('Part. <span>')])
assert.deepEqual(content(markdownToAdf('3 < 4\n')), [paragraph('3 < 4')])
})
test('gives up the link reference definitions a paragraph opens with', () => {
assert.deepEqual(content(markdownToAdf('[a]: /url\n')), [])
assert.deepEqual(content(markdownToAdf('[a]: /url\n[b]: /other\nPart.\n')), [paragraph('Part.')])
assert.deepEqual(content(markdownToAdf('[a]: /url\n"Title"\n\nPart.\n')), [paragraph('Part.')])
assert.deepEqual(content(markdownToAdf('[a]: /url and more\n')), [paragraph('[a]: /url and more')])
assert.deepEqual(content(markdownToAdf('Part.\n[a]: /url\n')), [paragraph('Part. [a]: /url')])
assert.deepEqual(content(markdownToAdf('[a]: /url\n===\n')), [paragraph('===')])
})
test('keeps the whitespace CommonMark strips no more of than a space or a tab', () => {
assert.deepEqual(content(markdownToAdf('\u00a0Part.\u00a0\n')), [paragraph('\u00a0Part.\u00a0')])
assert.deepEqual(content(markdownToAdf(' \u3000Part.\t\n')), [paragraph('\u3000Part.')])
})
test('normalizes the line endings and the null character CommonMark replaces', () => {
assert.deepEqual(content(markdownToAdf('One\r\ntwo.\r\n')), [paragraph('One two.')])
assert.deepEqual(content(markdownToAdf('One\rtwo.\r')), [paragraph('One two.')])
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')])
})
+47
View File
@@ -0,0 +1,47 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { ClaimedConstruct, LeafBlock } from './blocks.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { parseBlocks } from './blocks.ts'
import { trimSpace } from '../commonmark-grammar.ts'
export function markdownToAdf(markdown: string): Result<AdfDocument> {
const content: AdfNode[] = []
for (const [index, block] of parseBlocks(markdown).blocks.entries()) {
const node = blockNode(block, ['content', index])
if (!node.ok) return node
content.push(node.value)
}
return success(content.length === 0 ? { type: 'doc', version: 1 } : { content, type: 'doc', version: 1 })
}
function blockNode(block: LeafBlock, path: ConvertErrorPath): Result<AdfNode> {
if (block.kind === 'claim') return claimFailure(block.construct, path)
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 === 'html') return failure('unmappable-html', `no ADF node carries ${block.construct}`, path)
if (block.kind === 'paragraph') return success(withContent({ type: 'paragraph' }, block.text))
return success({ type: 'rule' })
}
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)
return failure('malformed-pipe-table', 'the line claims a pipe table and parses as none', path)
}
function codeBlockNode(language: string, text: string): AdfNode {
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
return text === '' ? node : { ...node, content: [{ text, type: 'text' }] }
}
function withContent(node: AdfNode, text: string): AdfNode {
const content = inlineContent(text)
return content.length === 0 ? node : { ...node, content }
}
function inlineContent(text: string): AdfNode[] {
const line = text
.split('\n')
.map((part) => trimSpace(part))
.join(' ')
return line === '' ? [] : [{ text: line, type: 'text' }]
}
+3
View File
@@ -1,5 +1,8 @@
export type ConvertErrorCode = export type ConvertErrorCode =
| 'malformed-directive'
| 'malformed-pipe-table'
| 'not-an-adf-document' | 'not-an-adf-document'
| 'unmappable-html'
| 'unspellable-adjacent-lists' | 'unspellable-adjacent-lists'
| 'unspellable-character' | 'unspellable-character'
| 'unspellable-line-start' | 'unspellable-line-start'
+17 -11
View File
@@ -196,7 +196,7 @@ detail is settled at its own milestone.
`name (type)` list and asserts it equals the table, leaving the spec the source a human `name (type)` list and asserts it equals the table, leaving the spec the source a human
writes with no build step and no generated file. It is built at 3g, where a wrong entry writes with no build step and no generated file. It is built at 3g, where a wrong entry
starts refusing documents. starts refusing documents.
- [ ] **3b — The leaf blocks.** The line walk that opens and closes a block, ahead of any inline - [x] **3b — The leaf blocks.** The line walk that opens and closes a block, ahead of any inline
parsing: paragraph, ATX and setext heading, thematic break, fenced and indented code parsing: paragraph, ATX and setext heading, thematic break, fenced and indented code
block, the HTML block whose lines it swallows whether or not the construct then errors, block, the HTML block whose lines it swallows whether or not the construct then errors,
the link reference definitions a closing paragraph gives up, and the blank lines between the link reference definitions a closing paragraph gives up, and the blank lines between
@@ -210,7 +210,11 @@ detail is settled at its own milestone.
rather than editing them. rather than editing them.
- [ ] **3c — The container blocks.** Blockquote, bullet and ordered list: the continuation a - [ ] **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. 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
to it: the open containers' prefix comes off the line first and the readers take one line at a
time, `LeafBlock` renamed with the union they join and `blockNode`'s chain gaining their
branches.
**Settled** (the maintainer, 2026-08-27): a claimed line ends lazy continuation, so a **Settled** (the maintainer, 2026-08-27): a claimed line ends lazy continuation, so a
closing fence on the line after a blockquote's open paragraph closes its container instead closing fence on the line after a blockquote's open paragraph closes its container instead
of continuing the paragraph CommonMark would fold it into. Claiming at block level is of continuing the paragraph CommonMark would fold it into. Claiming at block level is
@@ -222,13 +226,13 @@ detail is settled at its own milestone.
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 a round-trip pair.
- [ ] **3d — Inline text.** The inline scanner over a block's content: backslash escapes, - [ ] **3d — Inline text.** The inline scanner over a block's content: backslash escapes, entity
entity references decoding to their characters, code spans and the literal they hold — references decoding to their characters, code spans and the literal they hold — directive
directive syntax and `~~` included — CommonMark's own hard breaks, a trailing backslash syntax and `~~` included — CommonMark's own hard breaks, a trailing backslash and two
and two trailing spaces alike, a soft line break as one space, and the raw inline tag, trailing spaces alike, a soft line break as one space, the fenced info string's own decoding
comment and processing instruction refused by name, recognized by the the block walk leaves raw, and the raw inline tag, comment and processing instruction
`commonmark-grammar.ts` predicates the emitter already escapes against, under 3b's refused by name, recognized by the `commonmark-grammar.ts` predicates the emitter already
one-table rule. escapes against, under 3b's one-table rule.
- [ ] **3e — Emphasis and links.** `_`, `*` and `~~` runs through `matchEmphasis` to the `em`, - [ ] **3e — Emphasis and links.** `_`, `*` and `~~` runs through `matchEmphasis` to the `em`,
`strong` and `strike` marks; links inline and reference, 3b's definitions resolved here, `strong` and `strike` marks; links inline and reference, 3b's definitions resolved here,
autolinks, and the image gap's named errors — a titled image, and one amid other text. autolinks, and the image gap's named errors — a titled image, and one amid other text.
@@ -244,7 +248,8 @@ detail is settled at its own milestone.
- [ ] **3f — The directive grammar.** The three forms — inline `:name[content]{attrs}`, - [ ] **3f — The directive grammar.** The three forms — inline `:name[content]{attrs}`,
container `:::name arg {attrs}`, leaf `::name arg {attrs}` — the attribute grammar with container `:::name arg {attrs}`, leaf `::name arg {attrs}` — the attribute grammar with
its quoting and escapes, the fence-length and nesting rules, and the malformed list its quoting and escapes, the fence-length and nesting rules, and the malformed list
`spec/flavour.md` spells, each a named error. `spec/flavour.md` spells, each a named error. `corpus.test.ts`'s `fenceNestingFault` stays a
second reading of the fence rule over emitted bytes: the double entry is the check.
**Settled** (the maintainer, 2026-08-27): the code span, the entity and raw HTML bind **Settled** (the maintainer, 2026-08-27): the code span, the entity and raw HTML bind
first in input, as 2e3 already assumed of the emitted side — a raw `` ` ``, `&`, `<` or first in input, as 2e3 already assumed of the emitted side — a raw `` ` ``, `&`, `<` or
`|` inside `{attrs}` breaks the directive and is a named error, the author writing the `|` inside `{attrs}` breaks the directive and is a named error, the author writing the
@@ -281,7 +286,8 @@ detail is settled at its own milestone.
named error, a carry inside a mark spelling another, and the three carve-outs' escapes named error, a carry inside a mark spelling another, and the three carve-outs' escapes
reading as the literal text they hold. reading as the literal text they hold.
- [ ] **3k — The CommonMark spec suite.** Checked in at `corpus/commonmark-spec/`, pinned to - [ ] **3k — The CommonMark spec suite.** Checked in at `corpus/commonmark-spec/`, pinned to
the version it ships, `corpus/README.md` gaining the kind. the version it ships — the one `html-blocks.ts` names for its start conditions —
`corpus/README.md` gaining the kind.
**Settled** (the maintainer, 2026-08-27): three checks an example must pass, the reference **Settled** (the maintainer, 2026-08-27): three checks an example must pass, the reference
HTML each ships read as corpus data — which adds no format and no direction (§1). §2's HTML each ships read as corpus data — which adds no format and no direction (§1). §2's
canonical fixpoint: a named error, or markdown that parses and emits to itself byte for canonical fixpoint: a named error, or markdown that parses and emits to itself byte for