5b1: the error's source position #44

Merged
lilleman merged 5 commits from error-source-position into main 2026-09-03 17:31:30 +02:00
15 changed files with 248 additions and 81 deletions
+24 -7
View File
@@ -88,10 +88,11 @@ The emitted markdown and HTML are contracts. After 1.0: previously-emitted outpu
differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR. differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR.
Pre-1.0, normal 0.x rules. Pre-1.0, normal 0.x rules.
The error surface is a contract too. `ConvertError` is `{ code, message, path }` — the code from a The error surface is a contract too. `ConvertError` is `{ code, message, path, position? }` — the
closed list a consumer may switch exhaustively, the message free text, the path the node's position code from a closed list a consumer may switch exhaustively, the message free text, the path the
from the document root. Adding, removing or renaming a code is breaking, so a milestone meeting a node's place from the document root, the position where a parse read the refusal in its input.
new failure cause reuses a code where one fits; the list is complete at `0.1.0`. A code names the 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
cause; where one cause recurs across node types or across directions, one code covers them all and cause; where one cause recurs across node types or across directions, one code covers them all and
`path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever `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: direction hits it. A claim code names the spelling claimed, never the node that spelling would have built:
@@ -102,11 +103,27 @@ apart from a typo is what a consumer switches on when a later MINOR gives the na
the grammar itself refuses stays a claim code, key order among it; a well-formed directive the the grammar itself refuses stays a claim code, key order among it; a well-formed directive the
node tables refuse — an attribute a node does not hold or spells elsewhere, a value outside its node tables refuse — an attribute a node does not hold or spells elsewhere, a value outside its
kind or its canonical spelling, an argument or a body its content model does not take — is kind or its canonical spelling, an argument or a body its content model does not take — is
`unsupported-node-shape`, the emitter's code for the same mismatch read the other way. A `unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code
refusal found before its position is known — the block walk's, a directive reader's — is a across both directions for good, since the call site knows which direction it called and parting
`ConvertFault`, the code and message without the path; the node walk attaches the path as it them after `0.1.0` is MAJOR. `unmappable-html` names the version rather than the element: this one
converts no raw HTML, so at `0.3.0` the mapped elements stop erroring and the code stays for what
no ADF node carries. A refusal found before its path is known — the block walk's, a directive
reader's — is a `ConvertFault`, the code and message alone; the node walk attaches the path as it
descends, so a document reports its first error in document order. descends, so a document reports its first error in document order.
`position` is the parse side's alone: an emitter reads no source, so an emit error carries `path`
and nothing more. It is `{ line, offset }` at the start of the line the block holding the refusal
begins on — the offset indexing the string the caller passed, the line counted from 1 — minted by
the block walk and attached as results return, so the innermost block wins, the emitter's own
refusals the parser re-enters for the CommonMark spelling included.
A parse names a position for every refusal it returns, so the type says so rather than the prose:
`Result<T, E extends ConvertError = ConvertError>`, and a direction reading a source returns
`Result<T, ParseError>``ConvertError` with `position` required. An optional field a direction
always fills is a branch a consumer cannot take, and the `!` §11 bans is how they take it anyway.
`htmlToAdf` inherits this at `0.3.0`; the composed `markdownToHtml` and `htmlToMarkdown` keep the
wide `Result<T>`, since half their refusals come from an emit stage that read no source.
## 9. Release automation ## 9. Release automation
- `package.json` version on `main` is the source of truth. CI on `main`: tests green and version - `package.json` version on `main` is the source of truth. CI on `main`: tests green and version
+10 -4
View File
@@ -24,17 +24,23 @@ Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compos
```ts ```ts
adfToMarkdown(doc: AdfDocument): Result<string> adfToMarkdown(doc: AdfDocument): Result<string>
markdownToAdf(markdown: string): Result<AdfDocument> markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToHtml(doc: AdfDocument): Result<string> adfToHtml(doc: AdfDocument): Result<string>
htmlToAdf(html: string): Result<AdfDocument> htmlToAdf(html: string): Result<AdfDocument, ParseError>
markdownToHtml(markdown: string): Result<string> // via ADF markdownToHtml(markdown: string): Result<string> // via ADF
htmlToMarkdown(html: string): Result<string> // via ADF htmlToMarkdown(html: string): Result<string> // via ADF
isAdfDocument(v: unknown): v is AdfDocument isAdfDocument(v: unknown): v is AdfDocument
``` ```
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws. `Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
`ConvertError` is `{ code, message, path }`: a code from a closed set, and the path of the node it `ConvertError` is `{ code, message, path, position? }`: a code from a closed set, the path of the
names, from the document root. node it names from the document root, and — parsing — a `{ line, offset }` into the string passed
in, at the start of the line the refused block begins on, `line` counted from 1.
Parsing a source always names where in it the refusal sits, so `markdownToAdf` and `htmlToAdf`
return the narrowed `ParseError`, whose `position` is there to read without a guard. Every other
direction emits, from a document with no source behind it, and carries `path` alone — including
`markdownToHtml` and `htmlToMarkdown`, where half the refusals come from the emit half.
## The guarantees ## The guarantees
+1 -1
View File
@@ -13,7 +13,7 @@
"node": ">=18" "node": ">=18"
}, },
"scripts": { "scripts": {
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=97.9 --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.95 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json" "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json"
}, },
"devDependencies": { "devDependencies": {
+12 -2
View File
@@ -192,11 +192,21 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
}) })
} }
// The position the input itself gives an offset, recomputed rather than trusted from the parser.
function lineStarting(markdown: string, offset: number): { line: number; offset: number } | undefined {
const before = markdown.slice(0, offset)
if (offset !== 0 && !/(?:\r\n|[\n\r])$/.test(before)) return undefined
return { line: before.split(/\r\n|[\n\r]/).length, offset }
}
for (const name of pairedNames(errorsRoot, '.md', '.error')) { for (const name of pairedNames(errorsRoot, '.md', '.error')) {
test(`errors/${name} is refused with the error it names`, () => { test(`errors/${name} is refused with the error it names, at a line of its own input`, () => {
const result = markdownToAdf(readFileSync(join(errorsRoot, `${name}.md`), 'utf8')) const markdown = readFileSync(join(errorsRoot, `${name}.md`), 'utf8')
const result = markdownToAdf(markdown)
assert.ok(!result.ok, result.ok ? `built ${JSON.stringify(result.value)}` : '') assert.ok(!result.ok, result.ok ? `built ${JSON.stringify(result.value)}` : '')
assert.equal(result.error.code, readFileSync(join(errorsRoot, `${name}.error`), 'utf8').trimEnd()) assert.equal(result.error.code, readFileSync(join(errorsRoot, `${name}.error`), 'utf8').trimEnd())
const { position } = result.error
assert.deepEqual(position, lineStarting(markdown, position.offset))
}) })
} }
+1 -1
View File
@@ -1,5 +1,5 @@
export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts' export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
export type { ConvertError, ConvertErrorCode, Result } from './result.ts' export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
export type { JsonValue } from './json-value.ts' export type { JsonValue } from './json-value.ts'
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { isAdfDocument } from './adf/document.ts' export { isAdfDocument } from './adf/document.ts'
+6 -1
View File
@@ -1,4 +1,4 @@
import { readEntityReference } from './entity-references.ts' import { readEntityReference, replacementCharacter } from './entity-references.ts'
export type LinePosition = 'first' | 'later' export type LinePosition = 'first' | 'later'
@@ -27,6 +27,7 @@ const bracketedAutolink = new RegExp(`<(?:${autolinkSource})>`, 'y')
const controlCharacter = new RegExp(`[${controlCharacterRange}]`) const controlCharacter = new RegExp(`[${controlCharacterRange}]`)
const htmlTag = new RegExp(htmlTagSource, 'y') const htmlTag = new RegExp(htmlTagSource, 'y')
const nullCharacter = new RegExp(nullCharacterSource) const nullCharacter = new RegExp(nullCharacterSource)
const nullCharacters = new RegExp(nullCharacterSource, 'g')
const tagName = new RegExp(`^</?(${tagNameSource})[\\s\\S]*$`) const tagName = new RegExp(`^</?(${tagNameSource})[\\s\\S]*$`)
// The opener's own match ends with the terminator where the construct is complete on its own (`<!-->`). // The opener's own match ends with the terminator where the construct is complete on its own (`<!-->`).
const inlineHtmlConstructs = [ const inlineHtmlConstructs = [
@@ -137,6 +138,10 @@ export function holdsNullCharacter(text: string): boolean {
return nullCharacter.test(text) return nullCharacter.test(text)
} }
export function replaceNullCharacters(text: string): string {
return text.replaceAll(nullCharacters, replacementCharacter)
}
function htmlTagName(text: string): string { function htmlTagName(text: string): string {
return text.replace(tagName, '<$1>') return text.replace(tagName, '<$1>')
} }
+6 -1
View File
@@ -25,12 +25,17 @@ function path(result: Result<string>): readonly (number | string)[] {
return result.ok ? ['emitted'] : result.error.path return result.ok ? ['emitted'] : result.error.path
} }
test('names the node a refusal came from', () => { function position(result: Result<string>): unknown {
return result.ok ? 'emitted' : result.error.position
}
test('names the node a refusal came from, and no source the emitter never read', () => {
const unspellable: AdfNode = { text: 'x', type: 'paragraph' } const unspellable: AdfNode = { text: 'x', type: 'paragraph' }
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' } const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0]) assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'text' })))), ['content', 0, 'content', 1]) assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'text' })))), ['content', 0, 'content', 1])
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), []) assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
assert.equal(position(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined)
}) })
test('refuses a value that is not an ADF document', () => { test('refuses a value that is not an ADF document', () => {
+1 -1
View File
@@ -5,7 +5,7 @@ const anchoredEntityReference = new RegExp(`(?:${entityReferenceSource})`, 'y')
const decimalReference = /^&#(\d+);/ const decimalReference = /^&#(\d+);/
const hexadecimalReference = /^&#[Xx]([A-Fa-f0-9]+);/ const hexadecimalReference = /^&#[Xx]([A-Fa-f0-9]+);/
const largestCodePoint = 0x10ffff const largestCodePoint = 0x10ffff
const replacementCharacter = '\ufffd' export const replacementCharacter = '\ufffd'
const surrogates = { first: 0xd800, last: 0xdfff } const surrogates = { first: 0xd800, last: 0xdfff }
// HTML5's named character references (https://html.spec.whatwg.org/entities.json), the semicolon-terminated half // HTML5's named character references (https://html.spec.whatwg.org/entities.json), the semicolon-terminated half
+5 -2
View File
@@ -91,12 +91,15 @@ test('holds a directive container open until the fence that closes it', () => {
{ {
argument: 'info', argument: 'info',
attributes: new Map([['panelColor', { decoded: '#ff0000', spelling: '"#ff0000"' }]]), attributes: new Map([['panelColor', { decoded: '#ff0000', spelling: '"#ff0000"' }]]),
blocks: [{ kind: 'paragraph', text: 'Part.' }], blocks: [{ kind: 'paragraph', position: { line: 2, offset: 37 }, text: 'Part.' }],
kind: 'directive', kind: 'directive',
name: 'panel', name: 'panel',
position: { line: 1, offset: 0 },
}, },
]) ])
assert.deepEqual(parseBlocks('::rule\n').blocks, [{ argument: undefined, attributes: new Map(), blocks: undefined, kind: 'directive', name: 'rule' }]) assert.deepEqual(parseBlocks('::rule\n').blocks, [
{ argument: undefined, attributes: new Map(), blocks: undefined, kind: 'directive', name: 'rule', position: { line: 1, offset: 0 } },
])
}) })
test('names the directive fence a container does not sit longer than', () => { test('names the directive fence a container does not sit longer than', () => {
+80 -49
View File
@@ -1,4 +1,4 @@
import type { ConvertFault } from '../../result.ts' import type { ConvertFault, SourcePosition } from '../../result.ts'
import type { DirectiveAttributes, DirectiveLine } from '../directive-syntax.ts' import type { DirectiveAttributes, DirectiveLine } from '../directive-syntax.ts'
import type { LinkDefinition } from '../link-syntax.ts' import type { LinkDefinition } from '../link-syntax.ts'
import { import {
@@ -12,13 +12,14 @@ import {
markerInterruptsParagraph, markerInterruptsParagraph,
openingCodeFence, openingCodeFence,
openingHtmlBlock, openingHtmlBlock,
replaceNullCharacters,
setextHeadingLevel, setextHeadingLevel,
} from '../commonmark-grammar.ts' } from '../commonmark-grammar.ts'
import { isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts' import { isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
import { malformedDirective, readDirectiveLine } from '../directive-syntax.ts' import { malformedDirective, readDirectiveLine } from '../directive-syntax.ts'
import { readLinkDefinitions } from './link-reference-definitions.ts' import { readLinkDefinitions } from './link-reference-definitions.ts'
export type Block = export type Block = { position: SourcePosition } & (
| { argument: string | undefined; attributes: DirectiveAttributes; blocks: Block[] | undefined; kind: 'directive'; name: string } | { argument: string | undefined; attributes: DirectiveAttributes; blocks: Block[] | undefined; kind: 'directive'; name: string }
| { blocks: Block[]; kind: 'blockquote' } | { blocks: Block[]; kind: 'blockquote' }
| { construct: string; kind: 'html' } | { construct: string; kind: 'html' }
@@ -30,6 +31,7 @@ export type Block =
| { kind: 'paragraph'; text: string } | { kind: 'paragraph'; text: string }
| { kind: 'rule' } | { kind: 'rule' }
| { kind: 'table'; rows: string[][] } | { kind: 'table'; rows: string[][] }
)
export type ParsedBlocks = { blocks: Block[]; definitions: Map<string, LinkDefinition> } export type ParsedBlocks = { blocks: Block[]; definitions: Map<string, LinkDefinition> }
@@ -37,26 +39,27 @@ export type DirectiveBlock = Extract<Block, { kind: 'directive' }>
type ListBlock = Extract<Block, { items: Block[][] }> type ListBlock = Extract<Block, { items: Block[][] }>
type OpenDirective = { blocks: Block[]; colons: number; index: number; kind: 'directive'; parent: Block[] } type OpenDirective = { blocks: Block[]; colons: number; index: number; kind: 'directive'; parent: Block[]; position: SourcePosition }
type OpenContainer = type OpenContainer =
| Extract<Block, { kind: 'blockquote' }> | Extract<Block, { kind: 'blockquote' }>
| OpenDirective | OpenDirective
| { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock } | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
type OpenLeaf = type OpenLeaf = { position: SourcePosition } & (
| { closer: RegExp | undefined; construct: string; kind: 'html' } | { closer: RegExp | undefined; construct: string; kind: 'html' }
| { held: string[]; kind: 'indented-code'; lines: string[] } | { held: string[]; kind: 'indented-code'; lines: string[] }
| { indentation: number; info: string; kind: 'fenced-code'; lines: string[]; marker: string } | { indentation: number; info: string; kind: 'fenced-code'; lines: string[]; marker: string }
| { kind: 'paragraph'; lines: string[] } | { kind: 'paragraph'; lines: string[]; positions: SourcePosition[] }
| { kind: 'pipe-table'; rows: [string[], ...string[][]] } | { kind: 'pipe-table'; rows: [string[], ...string[][]] }
)
type ContainerStart = { kind: 'blockquote'; rest: Line } | { fresh: boolean; indentation: number; kind: 'item'; list: ListBlock; rest: Line } type ContainerStart = { kind: 'blockquote'; rest: Line } | { fresh: boolean; indentation: number; kind: 'item'; list: ListBlock; rest: Line }
// The line from an absolute column on: a tab a cut splits keeps the stop it is measured against. // The line from an absolute column on: a tab a cut splits keeps the stop it is measured against.
type Line = { column: number; text: string } type Line = { column: number; text: string }
type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; stack: OpenContainer[] } type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; position: SourcePosition; stack: OpenContainer[] }
const blankLine = /^[ \t]*$/ const blankLine = /^[ \t]*$/
const indentedCodeColumns = 4 const indentedCodeColumns = 4
@@ -65,8 +68,11 @@ const leafColons = 2
const tabStop = 4 const tabStop = 4
export function parseBlocks(markdown: string): ParsedBlocks { export function parseBlocks(markdown: string): ParsedBlocks {
const walk: Walk = { blocks: [], definitions: new Map(), leaf: undefined, stack: [] } const walk: Walk = { blocks: [], definitions: new Map(), leaf: undefined, position: { line: 1, offset: 0 }, stack: [] }
for (const text of normalizeInput(markdown).split('\n')) readLine(walk, { column: 0, text }) for (const line of sourceLines(markdown)) {
walk.position = line.position
readLine(walk, { column: 0, text: line.text })
}
closeContainers(walk, 0) closeContainers(walk, 0)
return { blocks: walk.blocks, definitions: walk.definitions } return { blocks: walk.blocks, definitions: walk.definitions }
} }
@@ -127,7 +133,7 @@ function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: n
let opened = false let opened = false
let rest = line let rest = line
while (leadingColumns(rest) < indentedCodeColumns) { while (leadingColumns(rest) < indentedCodeColumns) {
const start = containerStart(rest, opened ? false : paragraphOpen, opened ? undefined : unmatched) const start = containerStart(rest, opened ? false : paragraphOpen, opened ? undefined : unmatched, walk.position)
if (start === undefined) break if (start === undefined) break
if (!opened) closeContainers(walk, depth) if (!opened) closeContainers(walk, depth)
opened = true opened = true
@@ -137,15 +143,15 @@ function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: n
return { opened, rest } return { opened, rest }
} }
function containerStart(line: Line, paragraphOpen: boolean, enclosing: OpenContainer | undefined): ContainerStart | undefined { function containerStart(line: Line, paragraphOpen: boolean, enclosing: OpenContainer | undefined, position: SourcePosition): ContainerStart | undefined {
const opener = removeColumns(line, largestOpenerIndentation) const opener = removeColumns(line, largestOpenerIndentation)
const blockquote = blockquoteRest(opener) const blockquote = blockquoteRest(opener)
if (blockquote !== undefined) return { kind: 'blockquote', rest: blockquote } if (blockquote !== undefined) return { kind: 'blockquote', rest: blockquote }
if (isThematicBreak(opener.text)) return undefined if (isThematicBreak(opener.text)) return undefined
return itemStart(line, opener, paragraphOpen, enclosing) return itemStart(line, opener, paragraphOpen, enclosing, position)
} }
function itemStart(line: Line, opener: Line, paragraphOpen: boolean, enclosing: OpenContainer | undefined): ContainerStart | undefined { function itemStart(line: Line, opener: Line, paragraphOpen: boolean, enclosing: OpenContainer | undefined, position: SourcePosition): ContainerStart | undefined {
const marker = listMarker(opener.text) const marker = listMarker(opener.text)
if (marker === undefined) return undefined if (marker === undefined) return undefined
const after: Line = { column: opener.column + marker.width, text: opener.text.slice(marker.width) } const after: Line = { column: opener.column + marker.width, text: opener.text.slice(marker.width) }
@@ -159,19 +165,19 @@ function itemStart(line: Line, opener: Line, paragraphOpen: boolean, enclosing:
fresh: !continued, fresh: !continued,
indentation: leadingColumns(line) + marker.width + padding, indentation: leadingColumns(line) + marker.width + padding,
kind: 'item', kind: 'item',
list: continued ? enclosing.list : openList(marker.start), list: continued ? enclosing.list : openList(marker.start, position),
rest: blank ? after : removeColumns(after, padding), rest: blank ? after : removeColumns(after, padding),
} }
} }
function openList(start: number | undefined): ListBlock { function openList(start: number | undefined, position: SourcePosition): ListBlock {
return start === undefined ? { items: [], kind: 'bulletList' } : { items: [], kind: 'orderedList', start } return start === undefined ? { items: [], kind: 'bulletList', position } : { items: [], kind: 'orderedList', position, start }
} }
function openContainer(walk: Walk, start: ContainerStart): void { function openContainer(walk: Walk, start: ContainerStart): void {
const blocks: Block[] = [] const blocks: Block[] = []
if (start.kind === 'blockquote') { if (start.kind === 'blockquote') {
const blockquote: OpenContainer = { blocks, kind: 'blockquote' } const blockquote: OpenContainer = { blocks, kind: 'blockquote', position: walk.position }
currentBlocks(walk).push(blockquote) currentBlocks(walk).push(blockquote)
walk.stack.push(blockquote) walk.stack.push(blockquote)
return return
@@ -195,7 +201,11 @@ function closeContainers(walk: Walk, depth: number): void {
closeLeaf(walk) closeLeaf(walk)
for (const container of walk.stack.slice(depth)) { for (const container of walk.stack.slice(depth)) {
if (container.kind !== 'directive') continue if (container.kind !== 'directive') continue
container.parent[container.index] = { fault: malformedDirective(`a container fenced with ${container.colons} colons is unclosed`), kind: 'fault' } container.parent[container.index] = {
fault: malformedDirective(`a container fenced with ${container.colons} colons is unclosed`),
kind: 'fault',
position: container.position,
}
} }
dropContainers(walk, depth) dropContainers(walk, depth)
} }
@@ -211,10 +221,12 @@ function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'he
blocks: directive.colons > leafColons ? [] : undefined, blocks: directive.colons > leafColons ? [] : undefined,
kind: 'directive', kind: 'directive',
name: directive.name, name: directive.name,
position: walk.position,
} }
const parent = currentBlocks(walk) const parent = currentBlocks(walk)
parent.push(block) parent.push(block)
if (block.blocks !== undefined) walk.stack.push({ blocks: block.blocks, colons: directive.colons, index: parent.length - 1, kind: 'directive', parent }) const { position } = block
if (block.blocks !== undefined) walk.stack.push({ blocks: block.blocks, colons: directive.colons, index: parent.length - 1, kind: 'directive', parent, position })
} }
function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void { function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
@@ -251,7 +263,7 @@ function innermostDirective(walk: Walk): { container: OpenDirective; depth: numb
} }
function pushFault(walk: Walk, fault: ConvertFault): void { function pushFault(walk: Walk, fault: ConvertFault): void {
currentBlocks(walk).push({ fault, kind: 'fault' }) currentBlocks(walk).push({ fault, kind: 'fault', position: walk.position })
} }
// A claimed line ends the lazy continuation CommonMark would fold it into (spec/flavour.md). // A claimed line ends the lazy continuation CommonMark would fold it into (spec/flavour.md).
@@ -291,7 +303,7 @@ function readBlockLine(walk: Walk, line: Line): void {
return return
} }
if (walk.leaf === undefined && leadingColumns(line) >= indentedCodeColumns) { if (walk.leaf === undefined && leadingColumns(line) >= indentedCodeColumns) {
walk.leaf = { held: [], kind: 'indented-code', lines: [removeColumns(line, indentedCodeColumns).text] } walk.leaf = { held: [], kind: 'indented-code', lines: [removeColumns(line, indentedCodeColumns).text], position: walk.position }
return return
} }
openLeaf(walk, line) openLeaf(walk, line)
@@ -320,14 +332,14 @@ function openLeaf(walk: Walk, line: Line): void {
const cells = pipeCells(opener) const cells = pipeCells(opener)
if (cells !== undefined) { if (cells !== undefined) {
closeLeaf(walk) closeLeaf(walk)
walk.leaf = { kind: 'pipe-table', rows: [cells] } walk.leaf = { kind: 'pipe-table', position: walk.position, rows: [cells] }
return return
} }
if (readLineBlock(walk, opener)) return if (readLineBlock(walk, opener)) return
const fence = openingCodeFence(opener) const fence = openingCodeFence(opener)
if (fence !== undefined) { if (fence !== undefined) {
closeLeaf(walk) closeLeaf(walk)
walk.leaf = { indentation: leadingColumns(line), info: fence.info, kind: 'fenced-code', lines: [], marker: fence.marker } walk.leaf = { indentation: leadingColumns(line), info: fence.info, kind: 'fenced-code', lines: [], marker: fence.marker, position: walk.position }
return return
} }
const html = openingHtmlBlock(opener, walk.leaf?.kind === 'paragraph') const html = openingHtmlBlock(opener, walk.leaf?.kind === 'paragraph')
@@ -336,7 +348,7 @@ function openLeaf(walk: Walk, line: Line): void {
return return
} }
closeLeaf(walk) closeLeaf(walk)
walk.leaf = { closer: html.closer, construct: html.construct, kind: 'html' } walk.leaf = { closer: html.closer, construct: html.construct, kind: 'html', position: walk.position }
if (html.closer?.test(line.text) === true) closeLeaf(walk) if (html.closer?.test(line.text) === true) closeLeaf(walk)
} }
@@ -344,79 +356,98 @@ function openLeaf(walk: Walk, line: Line): void {
function readLineBlock(walk: Walk, opener: string): boolean { function readLineBlock(walk: Walk, opener: string): boolean {
const level = walk.leaf?.kind === 'paragraph' ? setextHeadingLevel(opener) : undefined const level = walk.leaf?.kind === 'paragraph' ? setextHeadingLevel(opener) : undefined
if (level !== undefined) { if (level !== undefined) {
const text = takeParagraph(walk) const paragraph = takeParagraph(walk)
if (text !== undefined) { if (paragraph !== undefined) {
currentBlocks(walk).push({ kind: 'heading', level, text }) currentBlocks(walk).push({ kind: 'heading', level, position: paragraph.position, text: paragraph.text })
return true return true
} }
} }
if (isThematicBreak(opener)) { if (isThematicBreak(opener)) {
closeLeaf(walk) closeLeaf(walk)
currentBlocks(walk).push({ kind: 'rule' }) currentBlocks(walk).push({ kind: 'rule', position: walk.position })
return true return true
} }
const heading = atxHeading(opener) const heading = atxHeading(opener)
if (heading === undefined) return false if (heading === undefined) return false
closeLeaf(walk) closeLeaf(walk)
currentBlocks(walk).push({ kind: 'heading', level: heading.level, text: heading.text }) currentBlocks(walk).push({ kind: 'heading', level: heading.level, position: walk.position, text: heading.text })
return true return true
} }
function appendParagraph(walk: Walk, line: string): void { function appendParagraph(walk: Walk, line: string): void {
const leaf = walk.leaf const leaf = walk.leaf
const text = line.replace(/^[ \t]+/, '') const text = line.replace(/^[ \t]+/, '')
if (leaf?.kind === 'paragraph') leaf.lines.push(text) if (leaf?.kind !== 'paragraph') {
else walk.leaf = { kind: 'paragraph', lines: [text] } walk.leaf = { kind: 'paragraph', lines: [text], position: walk.position, positions: [walk.position] }
return
}
leaf.lines.push(text)
leaf.positions.push(walk.position)
} }
function closeLeaf(walk: Walk): void { function closeLeaf(walk: Walk): void {
const leaf = walk.leaf const leaf = walk.leaf
if (leaf === undefined) return if (leaf === undefined) return
if (leaf.kind === 'paragraph') { if (leaf.kind === 'paragraph') {
const text = takeParagraph(walk) const paragraph = takeParagraph(walk)
if (text !== undefined) currentBlocks(walk).push({ kind: 'paragraph', text }) if (paragraph !== undefined) currentBlocks(walk).push(paragraph)
return return
} }
walk.leaf = undefined walk.leaf = undefined
if (leaf.kind === 'html') currentBlocks(walk).push({ construct: leaf.construct, kind: 'html' }) const { position } = leaf
else if (leaf.kind === 'pipe-table') currentBlocks(walk).push(pipeTableBlock(leaf.rows)) if (leaf.kind === 'html') currentBlocks(walk).push({ construct: leaf.construct, kind: 'html', position })
else currentBlocks(walk).push({ kind: 'code', language: leaf.kind === 'fenced-code' ? decodeTextEscapes(leaf.info) : '', text: leaf.lines.join('\n') }) else if (leaf.kind === 'pipe-table') currentBlocks(walk).push(pipeTableBlock(leaf.rows, position))
else currentBlocks(walk).push({ kind: 'code', language: leaf.kind === 'fenced-code' ? decodeTextEscapes(leaf.info) : '', position, text: leaf.lines.join('\n') })
} }
function pipeTableBlock(rows: readonly [string[], ...string[][]]): Block { function pipeTableBlock(rows: readonly [string[], ...string[][]], position: SourcePosition): Block {
const [header, delimiter, ...body] = rows const [header, delimiter, ...body] = rows
if (delimiter !== undefined && delimiter.some(isPipeAlignment)) { if (delimiter !== undefined && delimiter.some(isPipeAlignment)) {
return faultedBlock('a pipe table carries no column alignment ADF could hold') return faultedBlock('a pipe table carries no column alignment ADF could hold', position)
} }
if (delimiter === undefined || !delimiter.every(isPipeDelimiter)) { if (delimiter === undefined || !delimiter.every(isPipeDelimiter)) {
return faultedBlock('a pipe table underlines its header with a row of `-` runs') return faultedBlock('a pipe table underlines its header with a row of `-` runs', position)
} }
const ragged = [delimiter, ...body].find((row) => row.length !== header.length) const ragged = [delimiter, ...body].find((row) => row.length !== header.length)
if (ragged !== undefined) return faultedBlock(`a pipe table row holds ${ragged.length} cells where its header holds ${header.length}`) if (ragged !== undefined) return faultedBlock(`a pipe table row holds ${ragged.length} cells where its header holds ${header.length}`, position)
return { kind: 'table', rows: [header, ...body] } return { kind: 'table', position, rows: [header, ...body] }
} }
function faultedBlock(message: string): Block { function faultedBlock(message: string, position: SourcePosition): Block {
return { fault: malformedPipeTable(message), kind: 'fault' } return { fault: malformedPipeTable(message), kind: 'fault', position }
} }
function takeParagraph(walk: Walk): string | undefined { // The definitions a paragraph gives up are whole lines, so what is left starts at one this held.
function takeParagraph(walk: Walk): Extract<Block, { kind: 'paragraph' }> | undefined {
const leaf = walk.leaf const leaf = walk.leaf
if (leaf?.kind !== 'paragraph') return undefined if (leaf?.kind !== 'paragraph') return undefined
walk.leaf = undefined walk.leaf = undefined
const text = readLinkDefinitions(walk.definitions, leaf.lines.join('\n')) const text = readLinkDefinitions(walk.definitions, leaf.lines.join('\n'))
return text === '' ? undefined : text if (text === '') return undefined
const kept = leaf.positions[leaf.lines.length - text.split('\n').length]
return { kind: 'paragraph', position: kept ?? leaf.position, text }
} }
function currentBlocks(walk: Walk): Block[] { function currentBlocks(walk: Walk): Block[] {
return walk.stack.at(-1)?.blocks ?? walk.blocks return walk.stack.at(-1)?.blocks ?? walk.blocks
} }
function normalizeInput(markdown: string): string { function* sourceLines(markdown: string): Generator<{ position: SourcePosition; text: string }> {
return markdown let line = 1
.replace(/\r\n?/g, '\n') let start = 0
.replaceAll('\u0000', '\ufffd') for (let index = 0; index < markdown.length; index += 1) {
.replace(/\n$/, '') const character = markdown.charAt(index)
if (character !== '\n' && character !== '\r') continue
yield sourceLine(markdown, line, start, index)
if (character === '\r' && markdown.charAt(index + 1) === '\n') index += 1
line += 1
start = index + 1
}
if (start < markdown.length) yield sourceLine(markdown, line, start, markdown.length)
}
function sourceLine(markdown: string, line: number, start: number, end: number): { position: SourcePosition; text: string } {
return { position: { line, offset: start }, text: replaceNullCharacters(markdown.slice(start, end)) }
} }
function leadingColumns(line: Line): number { function leadingColumns(line: Line): number {
+29 -1
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { Result } from '../../result.ts' import type { ParseError, Result, SourcePosition } from '../../result.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { markdownToAdf } from './markdown-to-adf.ts' import { markdownToAdf } from './markdown-to-adf.ts'
@@ -23,6 +23,10 @@ function path(result: Result<AdfDocument>): readonly (number | string)[] {
return result.ok ? ['built'] : result.error.path return result.ok ? ['built'] : result.error.path
} }
function position(result: Result<AdfDocument, ParseError>): SourcePosition | string {
return result.ok ? 'built' : result.error.position
}
function text(value: string): AdfNode { function text(value: string): AdfNode {
return { text: value, type: 'text' } return { text: value, type: 'text' }
} }
@@ -443,6 +447,30 @@ test('swallows an HTML block ahead of the claim a line inside it would make', ()
assert.equal(code(markdownToAdf('<div>\n| x |\n</div>\n')), 'unmappable-html') assert.equal(code(markdownToAdf('<div>\n| x |\n</div>\n')), 'unmappable-html')
}) })
test('names the line and the offset in the input a refusal sits at, the innermost block winning', () => {
assert.deepEqual(position(markdownToAdf('<div>\n')), { line: 1, offset: 0 })
assert.deepEqual(position(markdownToAdf('Part.\n\n<div>\n')), { line: 3, offset: 7 })
assert.deepEqual(position(markdownToAdf('> Part.\n>\n> a <span>b</span>\n')), { line: 3, offset: 10 })
assert.deepEqual(position(markdownToAdf('- Part.\n- a <span>b</span>\n')), { line: 2, offset: 8 })
assert.deepEqual(position(markdownToAdf('Part.\n\n:::panel info\nMore.\n')), { line: 3, offset: 7 })
assert.deepEqual(position(markdownToAdf('x\n\na <span>b</span>\n===\n')), { line: 3, offset: 3 })
assert.deepEqual(position(markdownToAdf('x\n\n```adf\n{\n```\n')), { line: 3, offset: 3 })
assert.deepEqual(position(markdownToAdf('x\n\n| a |\n')), { line: 3, offset: 3 })
assert.deepEqual(position(markdownToAdf('a\nb <span>c</span>\n')), { line: 1, offset: 0 })
assert.deepEqual(position(markdownToAdf('Part.\r\n\r\n<div>\r\n')), { line: 3, offset: 9 })
assert.deepEqual(position(markdownToAdf('a\u0000b\n\n<div>\n')), { line: 3, offset: 5 })
assert.deepEqual(position(markdownToAdf(':::caption\na <span>b</span>\n:::\n')), { line: 2, offset: 11 })
assert.deepEqual(position(markdownToAdf('x\n\n:::caption\n- a\n:::\n')), { line: 3, offset: 3 })
})
test('names the line the text a paragraph keeps starts on, never a definition line it gave up', () => {
assert.deepEqual(position(markdownToAdf('[a]: /url\n<span>b</span>\n')), { line: 2, offset: 10 })
assert.deepEqual(position(markdownToAdf('[a]: /a\n[b]: /b\n[c]: /c\n[d]: /d\n<span>x</span>\n')), { line: 5, offset: 32 })
assert.deepEqual(position(markdownToAdf('[a]:\n<the url>\n"Title"\n<span>b</span>\n')), { line: 4, offset: 23 })
assert.deepEqual(position(markdownToAdf('> [a]: /url\n> <span>b</span>\n')), { line: 2, offset: 12 })
assert.deepEqual(position(markdownToAdf(':::caption\n[a]: /url\n<span>b</span>\n:::\n')), { line: 3, offset: 21 })
})
test('gives up the link reference definitions a paragraph opens with', () => { 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')), [])
assert.deepEqual(content(markdownToAdf('[a]: /url\n[b]: /other\nPart.\n')), [paragraph('Part.')]) assert.deepEqual(content(markdownToAdf('[a]: /url\n[b]: /other\nPart.\n')), [paragraph('Part.')])
+7 -5
View File
@@ -4,16 +4,18 @@ import type { BlockDirectiveNode } from './directive-nodes.ts'
import type { LinkDefinitions } from './inline-content.ts' import type { LinkDefinitions } from './inline-content.ts'
import { carryName, readCarriedBlock } from '../opaque-carry.ts' import { carryName, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts' import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
import { languageSlot } from '../code-language.ts' import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from './directive-nodes.ts' import { readBlockDirectiveNode } from './directive-nodes.ts'
export function markdownToAdf(markdown: string): Result<AdfDocument> { const documentStart: SourcePosition = { line: 1, offset: 0 }
export function markdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
const parsed = parseBlocks(markdown) const parsed = parseBlocks(markdown)
const content = blockNodes(parsed.blocks, parsed.definitions, [], 0) const content = positioned(blockNodes(parsed.blocks, parsed.definitions, [], 0), documentStart)
if (!content.ok) return content if (!content.ok) return content
return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 }) return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 })
} }
@@ -22,7 +24,7 @@ function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path) 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 blocks.entries()) { for (const [index, block] of blocks.entries()) {
const node = blockNode(block, definitions, [...path, 'content', index], depth) const node = positioned(blockNode(block, definitions, [...path, 'content', index], depth), block.position)
if (!node.ok) return node if (!node.ok) return node
content.push(node.value) content.push(node.value)
} }
@@ -111,7 +113,7 @@ function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: Li
if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} takes the leaf form, ::`, path) if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} takes the leaf form, ::`, path)
const only = blocks.length === 1 ? blocks[0] : undefined const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body`, path) if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body`, path)
return contentNode(node, only.text, definitions, path) return positioned(contentNode(node, only.text, definitions, path), only.position)
} }
function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> { function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
+13 -5
View File
@@ -17,15 +17,18 @@ export type ConvertErrorCode =
export type ConvertErrorPath = readonly (number | string)[] export type ConvertErrorPath = readonly (number | string)[]
export type ConvertError = { export type SourcePosition = { line: number; offset: number }
export type ConvertFault = {
code: ConvertErrorCode code: ConvertErrorCode
message: string message: string
path: ConvertErrorPath
} }
export type ConvertFault = Omit<ConvertError, 'path'> export type ConvertError = ConvertFault & { path: ConvertErrorPath; position?: SourcePosition }
export type Result<T> = { error: ConvertError; ok: false } | { ok: true; value: T } export type ParseError = ConvertError & { position: SourcePosition }
export type Result<T, E extends ConvertError = ConvertError> = { error: E; ok: false } | { ok: true; value: T }
export function failure<T>(code: ConvertErrorCode, message: string, path: ConvertErrorPath): Result<T> { export function failure<T>(code: ConvertErrorCode, message: string, path: ConvertErrorPath): Result<T> {
return { error: { code, message, path }, ok: false } return { error: { code, message, path }, ok: false }
@@ -35,6 +38,11 @@ export function faulted<T>(fault: ConvertFault, path: ConvertErrorPath): Result<
return failure(fault.code, fault.message, path) return failure(fault.code, fault.message, path)
} }
export function success<T>(value: T): Result<T> { export function positioned<T>(result: Result<T>, position: SourcePosition): Result<T, ParseError> {
if (result.ok) return result
return { error: { ...result.error, position: result.error.position ?? position }, ok: false }
}
export function success<T>(value: T): { ok: true; value: T } {
return { ok: true, value } return { ok: true, value }
} }
+20
View File
@@ -392,3 +392,23 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
**Settled** (the maintainer, 2026-09-01): ADF's own `A` is "Atlassian", and "converter" is **Settled** (the maintainer, 2026-09-01): ADF's own `A` is "Atlassian", and "converter" is
the one-way lossy tool §2 exists to replace, where a codec is both directions. It names the the one-way lossy tool §2 exists to replace, where a codec is both directions. It names the
hub, not the formats around it. hub, not the formats around it.
- [x] **5b1 — The error's source position.** A parse error names an ADF path into a document the
caller does not hold yet — `unmappable-html` at `["content", 5]` for a `<span>` on line
12 — and no coordinate into the markdown string it passed in. `ConvertError` gains an
optional `position` the parser carries to every parse-side mint, and the README's published
shape gains it.
**Settled** (the maintainer, 2026-09-03): the position is the parse side's alone — an
emitter has no source string to point into, so emit-side errors keep `path` unchanged. The
representation and where the position is captured are implementation judgment.
The block walk mints it and the node walk attaches it as results return — at `blockNodes`,
and at the inline body a directive holds — so the innermost block wins and the emitter's
own refusals, which the parser re-enters for the CommonMark spelling, get an input
coordinate too. `markdownToAdf` wraps the walk once more, which is what turns the wide
`Result<T>` into the `Result<T, ParseError>` its signature promises rather than guarding
anything: the depth guard under it cannot fire at depth 0. A paragraph names the line its
kept text starts on, never a link reference definition it gave up. Line endings stay as
the input spells them, so an offset indexes the string the caller passed rather than a
normalized copy of it. §8 records the framings the review settled beside it:
`unsupported-node-shape` stays one code across the two directions, `unmappable-html` names
the version rather than the element, and a direction that reads a source returns the
narrowed error type.
+33 -1
View File
@@ -5,7 +5,8 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h
## Milestones ## Milestones
Shipping order: 3h, 3i, 3j, 5a, 5 → `0.1.0`; 4b and 4c → `0.1.1`; 4, 3k → `0.2.0`; 6, 7 → `0.3.0`. Shipping order: 3h, 3i, 3j, 5a, 5b, 5 → `0.1.0`; 4b and 4c → `0.1.1`; 4, 3k → `0.2.0`; 6, 7 →
`0.3.0`.
The numbering is the order the work was planned in, not the order it ships. The numbering is the order the work was planned in, not the order it ships.
- [x] **0 — Scaffold.** - [x] **0 — Scaffold.**
@@ -139,6 +140,37 @@ The numbering is the order the work was planned in, not the order it ships.
`0.1.0` keeps — 3e names three shapes that parse and then refuse — so the release narrows `0.1.0` keeps — 3e names three shapes that parse and then refuse — so the release narrows
that sentence or lists them. that sentence or lists them.
- [x] **5a — Rename to `@larvit/adf-codec`.** - [x] **5a — Rename to `@larvit/adf-codec`.**
- [ ] **5b — The consumer's error surface (`0.1.0`).** A product-owner read of the public surface
found the error result legible to the library and opaque to the consumer holding it, and the
README documenting no part of it. The sub-items are that read's answers, and they land before
5 because §8 freezes the code list at `0.1.0` and 5b3's table is what reads the list before
the freeze closes it.
- [x] **5b1 — The error's source position.**
- [ ] **5b2 — The error messages.** Most state the rule and leave the violation to be inferred —
`a text node holds text` for a node holding none — so `rule: violation` becomes house style
across the sites that do. `not-an-adf-document` gives one sentence of eight words to `null`,
a string, a missing `version`, a `type` that is not `doc` and a REST envelope around the
document; naming the check that failed makes the highest-frequency integrator mistake
self-diagnosing without the library naming a REST shape (§7). The three carve-out claim
messages name the escape that unclaims the line — `\|`, `\~~`, `\:::` — which today only
`spec/flavour.md` holds. `unmappable-html` reads as §8 now frames it: this version converts
no raw HTML, never a permanent judgment on the element.
- [ ] **5b3 — The README's consumer surface.** §8 invites an exhaustive switch on `code` and no
code name appears in the README, so it gains a table — code, when it fires, what the
consumer does — grouped by direction; drafting it is the audit that reads the fifteen names
before 5 freezes them. Four things a reader who has not opened the code cannot know: raw
HTML is core CommonMark and every construct in input is an error until `0.3.0`, which the
guarantees' "three carve-outs and one gap" denies and which is the bot and LLM personas'
most common failure; `adfToHtml`, `htmlToAdf`, `markdownToHtml` and `htmlToMarkdown` sit
unmarked in the code block people copy from, as do the two HTML guarantee bullets, and take
a `0.3.0` mark or leave the block; `adfToMarkdown` is partial on valid ADF — two adjacent
`bulletList` nodes are an error result — which the viewer persona needs told along with
what to do about it; and GFM past tables and strikethrough is literal text, task lists
taking `:::taskList`. One sentence for the LLM persona: `code` is stable across minors,
`message` is free text. The type-level surface freezes at the same moment and gets the same
read: what `index.ts` exports and what it withholds, `ParseError` against `ConvertError`
where a direction reads a source, and `ConvertFault` staying internal — the README table
names the shapes a consumer switches on, so the two audits are one.
- [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity - [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts. scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` / - [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /