From becc023b5101ed100d9ba5fa2be20853f2ed797d Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 3 Sep 2026 14:37:39 +0200 Subject: [PATCH 1/5] 5b: split the consumer's error surface into sub-items --- todo.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/todo.md b/todo.md index 994d57f..2b23986 100644 --- a/todo.md +++ b/todo.md @@ -5,7 +5,8 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h ## 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. - [x] **0 — Scaffold.** @@ -139,6 +140,41 @@ 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 that sentence or lists them. - [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. + - [ ] **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 `` 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. + - [ ] **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. - [ ] **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. - [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` / From d3888129b04e6f1de5d54b7f0aca365d1894ee80 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 3 Sep 2026 14:37:39 +0200 Subject: [PATCH 2/5] 5b1: name the line and the offset in the input a parse refusal sits at --- AGENTS.md | 24 +++-- README.md | 6 +- package.json | 2 +- src/corpus.test.ts | 15 ++- src/markdown/emit/adf-to-markdown.test.ts | 7 +- src/markdown/parse/blocks.test.ts | 7 +- src/markdown/parse/blocks.ts | 117 ++++++++++++--------- src/markdown/parse/markdown-to-adf.test.ts | 20 +++- src/markdown/parse/markdown-to-adf.ts | 4 +- src/result.ts | 10 +- 10 files changed, 146 insertions(+), 66 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 11a5001..c4df2f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. Pre-1.0, normal 0.x rules. -The error surface is a contract too. `ConvertError` is `{ code, message, path }` — the code from a -closed list a consumer may switch exhaustively, the message free text, the path the node's position -from the document root. Adding, removing or renaming a code is breaking, so a milestone meeting a -new failure cause reuses a code where one fits; the list is complete at `0.1.0`. A code names the +The error surface is a contract too. `ConvertError` is `{ code, message, path, position? }` — the +code from a closed list a consumer may switch exhaustively, the message free text, the path the +node's place from the document root, the position where a parse read the refusal in its input. +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 `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: @@ -102,11 +103,20 @@ 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 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 -`unsupported-node-shape`, the emitter's code for the same mismatch read the other way. A -refusal found before its position is known — the block walk's, a directive reader's — is a -`ConvertFault`, the code and message without the path; the node walk attaches the path as it +`unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code +across both directions for good, since the call site knows which direction it called and parting +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. +`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. + ## 9. Release automation - `package.json` version on `main` is the source of truth. CI on `main`: tests green and version diff --git a/README.md b/README.md index baef79f..38fda45 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,10 @@ isAdfDocument(v: unknown): v is AdfDocument ``` `Result` 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 -names, from the document root. +`ConvertError` is `{ code, message, path, position? }`: a code from a closed set, the path of the +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. Emitting reads no +source, so an emit error carries no `position`. ## The guarantees diff --git a/package.json b/package.json index fef13f5..75c1579 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "node": ">=18" }, "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=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json" }, "devDependencies": { diff --git a/src/corpus.test.ts b/src/corpus.test.ts index dcd7674..4cb9f01 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -192,11 +192,22 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) { }) } +// The position the input itself gives an offset: undefined where the offset starts no line. +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')) { - test(`errors/${name} is refused with the error it names`, () => { - const result = markdownToAdf(readFileSync(join(errorsRoot, `${name}.md`), 'utf8')) + test(`errors/${name} is refused with the error it names, at a line of its own input`, () => { + const markdown = readFileSync(join(errorsRoot, `${name}.md`), 'utf8') + const result = markdownToAdf(markdown) assert.ok(!result.ok, result.ok ? `built ${JSON.stringify(result.value)}` : '') assert.equal(result.error.code, readFileSync(join(errorsRoot, `${name}.error`), 'utf8').trimEnd()) + const { position } = result.error + assert.ok(position !== undefined, 'the refusal names no position in the input') + assert.deepEqual(position, lineStarting(markdown, position.offset)) }) } diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 9af0e95..adcba84 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -25,12 +25,17 @@ function path(result: Result): readonly (number | string)[] { return result.ok ? ['emitted'] : result.error.path } -test('names the node a refusal came from', () => { +function position(result: Result): 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 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' }, { type: 'text' })))), ['content', 0, 'content', 1]) 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', () => { diff --git a/src/markdown/parse/blocks.test.ts b/src/markdown/parse/blocks.test.ts index 8396337..08cee34 100644 --- a/src/markdown/parse/blocks.test.ts +++ b/src/markdown/parse/blocks.test.ts @@ -91,12 +91,15 @@ test('holds a directive container open until the fence that closes it', () => { { argument: 'info', 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', 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', () => { diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 0564db0..887c761 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -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 { LinkDefinition } from '../link-syntax.ts' import { @@ -18,7 +18,7 @@ import { isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from import { malformedDirective, readDirectiveLine } from '../directive-syntax.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 } | { blocks: Block[]; kind: 'blockquote' } | { construct: string; kind: 'html' } @@ -30,6 +30,7 @@ export type Block = | { kind: 'paragraph'; text: string } | { kind: 'rule' } | { kind: 'table'; rows: string[][] } +) export type ParsedBlocks = { blocks: Block[]; definitions: Map } @@ -37,26 +38,27 @@ export type DirectiveBlock = Extract type ListBlock = Extract -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 = | Extract | OpenDirective | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock } -type OpenLeaf = +type OpenLeaf = { position: SourcePosition } & ( | { closer: RegExp | undefined; construct: string; kind: 'html' } | { held: string[]; kind: 'indented-code'; lines: string[] } | { indentation: number; info: string; kind: 'fenced-code'; lines: string[]; marker: string } | { kind: 'paragraph'; lines: string[] } | { kind: 'pipe-table'; rows: [string[], ...string[][]] } +) 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. 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 indentedCodeColumns = 4 @@ -65,8 +67,11 @@ const leafColons = 2 const tabStop = 4 export function parseBlocks(markdown: string): ParsedBlocks { - const walk: Walk = { blocks: [], definitions: new Map(), leaf: undefined, stack: [] } - for (const text of normalizeInput(markdown).split('\n')) readLine(walk, { column: 0, text }) + const walk: Walk = { blocks: [], definitions: new Map(), leaf: undefined, position: { line: 1, offset: 0 }, stack: [] } + for (const line of sourceLines(markdown)) { + walk.position = line.position + readLine(walk, { column: 0, text: line.text }) + } closeContainers(walk, 0) return { blocks: walk.blocks, definitions: walk.definitions } } @@ -127,7 +132,7 @@ function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: n let opened = false let rest = line 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 (!opened) closeContainers(walk, depth) opened = true @@ -137,15 +142,15 @@ function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: n 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 blockquote = blockquoteRest(opener) if (blockquote !== undefined) return { kind: 'blockquote', rest: blockquote } 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) if (marker === undefined) return undefined const after: Line = { column: opener.column + marker.width, text: opener.text.slice(marker.width) } @@ -159,19 +164,19 @@ function itemStart(line: Line, opener: Line, paragraphOpen: boolean, enclosing: fresh: !continued, indentation: leadingColumns(line) + marker.width + padding, kind: 'item', - list: continued ? enclosing.list : openList(marker.start), + list: continued ? enclosing.list : openList(marker.start, position), rest: blank ? after : removeColumns(after, padding), } } -function openList(start: number | undefined): ListBlock { - return start === undefined ? { items: [], kind: 'bulletList' } : { items: [], kind: 'orderedList', start } +function openList(start: number | undefined, position: SourcePosition): ListBlock { + return start === undefined ? { items: [], kind: 'bulletList', position } : { items: [], kind: 'orderedList', position, start } } function openContainer(walk: Walk, start: ContainerStart): void { const blocks: Block[] = [] if (start.kind === 'blockquote') { - const blockquote: OpenContainer = { blocks, kind: 'blockquote' } + const blockquote: OpenContainer = { blocks, kind: 'blockquote', position: walk.position } currentBlocks(walk).push(blockquote) walk.stack.push(blockquote) return @@ -195,7 +200,11 @@ function closeContainers(walk: Walk, depth: number): void { closeLeaf(walk) for (const container of walk.stack.slice(depth)) { 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) } @@ -211,10 +220,12 @@ function openDirective(walk: Walk, directive: Extract leafColons ? [] : undefined, kind: 'directive', name: directive.name, + position: walk.position, } const parent = currentBlocks(walk) 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 { @@ -251,7 +262,7 @@ function innermostDirective(walk: Walk): { container: OpenDirective; depth: numb } 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). @@ -291,7 +302,7 @@ function readBlockLine(walk: Walk, line: Line): void { return } 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 } openLeaf(walk, line) @@ -320,14 +331,14 @@ function openLeaf(walk: Walk, line: Line): void { const cells = pipeCells(opener) if (cells !== undefined) { closeLeaf(walk) - walk.leaf = { kind: 'pipe-table', rows: [cells] } + walk.leaf = { kind: 'pipe-table', position: walk.position, rows: [cells] } return } if (readLineBlock(walk, opener)) return const fence = openingCodeFence(opener) if (fence !== undefined) { 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 } const html = openingHtmlBlock(opener, walk.leaf?.kind === 'paragraph') @@ -336,7 +347,7 @@ function openLeaf(walk: Walk, line: Line): void { return } 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) } @@ -344,21 +355,21 @@ function openLeaf(walk: Walk, line: Line): void { function readLineBlock(walk: Walk, opener: string): boolean { const level = walk.leaf?.kind === 'paragraph' ? setextHeadingLevel(opener) : undefined if (level !== undefined) { - const text = takeParagraph(walk) - if (text !== undefined) { - currentBlocks(walk).push({ kind: 'heading', level, text }) + const paragraph = takeParagraph(walk) + if (paragraph !== undefined) { + currentBlocks(walk).push({ kind: 'heading', level, position: paragraph.position, text: paragraph.text }) return true } } if (isThematicBreak(opener)) { closeLeaf(walk) - currentBlocks(walk).push({ kind: 'rule' }) + currentBlocks(walk).push({ kind: 'rule', position: walk.position }) return true } const heading = atxHeading(opener) if (heading === undefined) return false 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 } @@ -366,57 +377,69 @@ function appendParagraph(walk: Walk, line: string): void { const leaf = walk.leaf const text = line.replace(/^[ \t]+/, '') if (leaf?.kind === 'paragraph') leaf.lines.push(text) - else walk.leaf = { kind: 'paragraph', lines: [text] } + else walk.leaf = { kind: 'paragraph', lines: [text], position: walk.position } } function closeLeaf(walk: Walk): void { const leaf = walk.leaf if (leaf === undefined) return if (leaf.kind === 'paragraph') { - const text = takeParagraph(walk) - if (text !== undefined) currentBlocks(walk).push({ kind: 'paragraph', text }) + const paragraph = takeParagraph(walk) + if (paragraph !== undefined) currentBlocks(walk).push(paragraph) return } walk.leaf = undefined - if (leaf.kind === 'html') currentBlocks(walk).push({ construct: leaf.construct, kind: 'html' }) - else if (leaf.kind === 'pipe-table') currentBlocks(walk).push(pipeTableBlock(leaf.rows)) - else currentBlocks(walk).push({ kind: 'code', language: leaf.kind === 'fenced-code' ? decodeTextEscapes(leaf.info) : '', text: leaf.lines.join('\n') }) + const { position } = leaf + if (leaf.kind === 'html') currentBlocks(walk).push({ construct: leaf.construct, kind: 'html', position }) + 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 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)) { - 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) - if (ragged !== undefined) return faultedBlock(`a pipe table row holds ${ragged.length} cells where its header holds ${header.length}`) - return { kind: 'table', rows: [header, ...body] } + if (ragged !== undefined) return faultedBlock(`a pipe table row holds ${ragged.length} cells where its header holds ${header.length}`, position) + return { kind: 'table', position, rows: [header, ...body] } } -function faultedBlock(message: string): Block { - return { fault: malformedPipeTable(message), kind: 'fault' } +function faultedBlock(message: string, position: SourcePosition): Block { + return { fault: malformedPipeTable(message), kind: 'fault', position } } -function takeParagraph(walk: Walk): string | undefined { +function takeParagraph(walk: Walk): Extract | undefined { const leaf = walk.leaf if (leaf?.kind !== 'paragraph') return undefined walk.leaf = undefined const text = readLinkDefinitions(walk.definitions, leaf.lines.join('\n')) - return text === '' ? undefined : text + return text === '' ? undefined : { kind: 'paragraph', position: leaf.position, text } } function currentBlocks(walk: Walk): Block[] { return walk.stack.at(-1)?.blocks ?? walk.blocks } -function normalizeInput(markdown: string): string { - return markdown - .replace(/\r\n?/g, '\n') - .replaceAll('\u0000', '\ufffd') - .replace(/\n$/, '') +// The line ending stays as the input spells it: an offset indexes the string the caller passed. +function sourceLines(markdown: string): { position: SourcePosition; text: string }[] { + const source = markdown.replaceAll('\u0000', '\ufffd') + const lines: { position: SourcePosition; text: string }[] = [] + let line = 1 + let start = 0 + for (let index = 0; index < source.length; index += 1) { + const character = source.charAt(index) + if (character !== '\n' && character !== '\r') continue + lines.push({ position: { line, offset: start }, text: source.slice(start, index) }) + if (character === '\r' && source.charAt(index + 1) === '\n') index += 1 + line += 1 + start = index + 1 + } + if (start < source.length) lines.push({ position: { line, offset: start }, text: source.slice(start) }) + return lines } function leadingColumns(line: Line): number { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index b93f805..97c8308 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' -import type { Result } from '../../result.ts' +import type { Result, SourcePosition } from '../../result.ts' import { largestNesting } from '../../nesting.ts' import { markdownToAdf } from './markdown-to-adf.ts' @@ -23,6 +23,11 @@ function path(result: Result): readonly (number | string)[] { return result.ok ? ['built'] : result.error.path } +function position(result: Result): SourcePosition | string { + if (result.ok) return 'built' + return result.error.position ?? 'no position' +} + function text(value: string): AdfNode { return { text: value, type: 'text' } } @@ -443,6 +448,19 @@ test('swallows an HTML block ahead of the claim a line inside it would make', () assert.equal(code(markdownToAdf('
\n| x |\n
\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('
\n')), { line: 1, offset: 0 }) + assert.deepEqual(position(markdownToAdf('Part.\n\n
\n')), { line: 3, offset: 7 }) + assert.deepEqual(position(markdownToAdf('> Part.\n>\n> a b\n')), { line: 3, offset: 10 }) + assert.deepEqual(position(markdownToAdf('- Part.\n- a b\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 b\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 c\n')), { line: 1, offset: 0 }) + assert.deepEqual(position(markdownToAdf('Part.\r\n\r\n
\r\n')), { line: 3, offset: 9 }) +}) + 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.')]) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index a98be1f..82a1bc2 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -4,7 +4,7 @@ import type { BlockDirectiveNode } from './directive-nodes.ts' import type { LinkDefinitions } from './inline-content.ts' import { carryName, readCarriedBlock } from '../opaque-carry.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 Result } from '../../result.ts' import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { parseBlocks } from './blocks.ts' @@ -22,7 +22,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) const content: AdfNode[] = [] 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 content.push(node.value) } diff --git a/src/result.ts b/src/result.ts index 316e778..d6413cf 100644 --- a/src/result.ts +++ b/src/result.ts @@ -17,13 +17,16 @@ export type ConvertErrorCode = export type ConvertErrorPath = readonly (number | string)[] +export type SourcePosition = { line: number; offset: number } + export type ConvertError = { code: ConvertErrorCode message: string path: ConvertErrorPath + position?: SourcePosition } -export type ConvertFault = Omit +export type ConvertFault = Omit export type Result = { error: ConvertError; ok: false } | { ok: true; value: T } @@ -35,6 +38,11 @@ export function faulted(fault: ConvertFault, path: ConvertErrorPath): Result< return failure(fault.code, fault.message, path) } +export function positioned(result: Result, position: SourcePosition): Result { + if (result.ok || result.error.position !== undefined) return result + return { error: { ...result.error, position }, ok: false } +} + export function success(value: T): Result { return { ok: true, value } } From 51009ac3f6b8f3a2b8c4ddbe37eb7701eb65a272 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 3 Sep 2026 14:38:27 +0200 Subject: [PATCH 3/5] 5b1: tick the item and move its text to the history --- todo-history.md | 15 +++++++++++++++ todo.md | 9 +-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/todo-history.md b/todo-history.md index d04b1fa..4010090 100644 --- a/todo-history.md +++ b/todo-history.md @@ -392,3 +392,18 @@ Under **3 — `markdownToAdf` (`0.1.0`)**: **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 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 `` 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, one call in + `blockNodes`, 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. 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 both framings the review settled beside it: + `unsupported-node-shape` stays one code across the two directions, and `unmappable-html` + names the version rather than the element. diff --git a/todo.md b/todo.md index 2b23986..a01b1f1 100644 --- a/todo.md +++ b/todo.md @@ -145,14 +145,7 @@ The numbering is the order the work was planned in, not the order it ships. 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. - - [ ] **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 `` 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. + - [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`, From 8a88c5063048c6742a08653242af1cd49aec905a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 3 Sep 2026 17:09:14 +0200 Subject: [PATCH 4/5] 5b1: guarantee the parse direction's position in the type, and name it one level deeper --- AGENTS.md | 7 +++++++ README.md | 12 ++++++++---- src/corpus.test.ts | 1 - src/index.ts | 2 +- src/markdown/commonmark-grammar.ts | 7 ++++++- src/markdown/entity-references.ts | 2 +- src/markdown/parse/blocks.ts | 17 ++++++++++------- src/markdown/parse/markdown-to-adf.test.ts | 10 ++++++---- src/markdown/parse/markdown-to-adf.ts | 10 ++++++---- src/result.ts | 18 +++++++++--------- todo.md | 5 ++++- 11 files changed, 58 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c4df2f5..504f9a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,13 @@ begins on — the offset indexing the string the caller passed, the line counted 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`, and a direction reading a source returns +`Result` — `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`, since half their refusals come from an emit stage that read no source. + ## 9. Release automation - `package.json` version on `main` is the source of truth. CI on `main`: tests green and version diff --git a/README.md b/README.md index 38fda45..cdfb90f 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compos ```ts adfToMarkdown(doc: AdfDocument): Result -markdownToAdf(markdown: string): Result +markdownToAdf(markdown: string): Result adfToHtml(doc: AdfDocument): Result -htmlToAdf(html: string): Result +htmlToAdf(html: string): Result markdownToHtml(markdown: string): Result // via ADF htmlToMarkdown(html: string): Result // via ADF isAdfDocument(v: unknown): v is AdfDocument @@ -35,8 +35,12 @@ isAdfDocument(v: unknown): v is AdfDocument `Result` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws. `ConvertError` is `{ code, message, path, position? }`: a code from a closed set, the path of the 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. Emitting reads no -source, so an emit error carries no `position`. +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 diff --git a/src/corpus.test.ts b/src/corpus.test.ts index 4cb9f01..2e72235 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -206,7 +206,6 @@ for (const name of pairedNames(errorsRoot, '.md', '.error')) { assert.ok(!result.ok, result.ok ? `built ${JSON.stringify(result.value)}` : '') assert.equal(result.error.code, readFileSync(join(errorsRoot, `${name}.error`), 'utf8').trimEnd()) const { position } = result.error - assert.ok(position !== undefined, 'the refusal names no position in the input') assert.deepEqual(position, lineStarting(markdown, position.offset)) }) } diff --git a/src/index.ts b/src/index.ts index 4210fb3..d761f20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ 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 { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' export { isAdfDocument } from './adf/document.ts' diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index 7c4e074..0fbefe1 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -1,4 +1,4 @@ -import { readEntityReference } from './entity-references.ts' +import { readEntityReference, replacementCharacter } from './entity-references.ts' export type LinePosition = 'first' | 'later' @@ -27,6 +27,7 @@ const bracketedAutolink = new RegExp(`<(?:${autolinkSource})>`, 'y') const controlCharacter = new RegExp(`[${controlCharacterRange}]`) const htmlTag = new RegExp(htmlTagSource, 'y') const nullCharacter = new RegExp(nullCharacterSource) +const nullCharacters = new RegExp(nullCharacterSource, 'g') const tagName = new RegExp(`^`). const inlineHtmlConstructs = [ @@ -137,6 +138,10 @@ export function holdsNullCharacter(text: string): boolean { return nullCharacter.test(text) } +export function replaceNullCharacters(text: string): string { + return text.replaceAll(nullCharacters, replacementCharacter) +} + function htmlTagName(text: string): string { return text.replace(tagName, '<$1>') } diff --git a/src/markdown/entity-references.ts b/src/markdown/entity-references.ts index 8354f9a..d048864 100644 --- a/src/markdown/entity-references.ts +++ b/src/markdown/entity-references.ts @@ -5,7 +5,7 @@ const anchoredEntityReference = new RegExp(`(?:${entityReferenceSource})`, 'y') const decimalReference = /^&#(\d+);/ const hexadecimalReference = /^&#[Xx]([A-Fa-f0-9]+);/ const largestCodePoint = 0x10ffff -const replacementCharacter = '\ufffd' +export const replacementCharacter = '\ufffd' const surrogates = { first: 0xd800, last: 0xdfff } // HTML5's named character references (https://html.spec.whatwg.org/entities.json), the semicolon-terminated half diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 887c761..8e93651 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -12,6 +12,7 @@ import { markerInterruptsParagraph, openingCodeFence, openingHtmlBlock, + replaceNullCharacters, setextHeadingLevel, } from '../commonmark-grammar.ts' import { isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts' @@ -424,24 +425,26 @@ function currentBlocks(walk: Walk): Block[] { return walk.stack.at(-1)?.blocks ?? walk.blocks } -// The line ending stays as the input spells it: an offset indexes the string the caller passed. function sourceLines(markdown: string): { position: SourcePosition; text: string }[] { - const source = markdown.replaceAll('\u0000', '\ufffd') const lines: { position: SourcePosition; text: string }[] = [] let line = 1 let start = 0 - for (let index = 0; index < source.length; index += 1) { - const character = source.charAt(index) + for (let index = 0; index < markdown.length; index += 1) { + const character = markdown.charAt(index) if (character !== '\n' && character !== '\r') continue - lines.push({ position: { line, offset: start }, text: source.slice(start, index) }) - if (character === '\r' && source.charAt(index + 1) === '\n') index += 1 + lines.push(sourceLine(markdown, line, start, index)) + if (character === '\r' && markdown.charAt(index + 1) === '\n') index += 1 line += 1 start = index + 1 } - if (start < source.length) lines.push({ position: { line, offset: start }, text: source.slice(start) }) + if (start < markdown.length) lines.push(sourceLine(markdown, line, start, markdown.length)) return lines } +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 { let columns = 0 for (const character of line.text) { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 97c8308..073f0cd 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' -import type { Result, SourcePosition } from '../../result.ts' +import type { ParseError, Result, SourcePosition } from '../../result.ts' import { largestNesting } from '../../nesting.ts' import { markdownToAdf } from './markdown-to-adf.ts' @@ -23,9 +23,8 @@ function path(result: Result): readonly (number | string)[] { return result.ok ? ['built'] : result.error.path } -function position(result: Result): SourcePosition | string { - if (result.ok) return 'built' - return result.error.position ?? 'no position' +function position(result: Result): SourcePosition | string { + return result.ok ? 'built' : result.error.position } function text(value: string): AdfNode { @@ -459,6 +458,9 @@ test('names the line and the offset in the input a refusal sits at, the innermos assert.deepEqual(position(markdownToAdf('x\n\n| a |\n')), { line: 3, offset: 3 }) assert.deepEqual(position(markdownToAdf('a\nb c\n')), { line: 1, offset: 0 }) assert.deepEqual(position(markdownToAdf('Part.\r\n\r\n
\r\n')), { line: 3, offset: 9 }) + assert.deepEqual(position(markdownToAdf('a\u0000b\n\n
\n')), { line: 3, offset: 5 }) + assert.deepEqual(position(markdownToAdf(':::caption\na b\n:::\n')), { line: 2, offset: 11 }) + assert.deepEqual(position(markdownToAdf('x\n\n:::caption\n- a\n:::\n')), { line: 3, offset: 3 }) }) test('gives up the link reference definitions a paragraph opens with', () => { diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 82a1bc2..2fe0bf5 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -4,16 +4,18 @@ import type { BlockDirectiveNode } from './directive-nodes.ts' import type { LinkDefinitions } from './inline-content.ts' import { carryName, readCarriedBlock } from '../opaque-carry.ts' import { commonMarkSpelling } from '../emit/adf-to-markdown.ts' -import { failure, faulted, positioned, 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 { largestNesting } from '../../nesting.ts' import { parseBlocks } from './blocks.ts' import { parseInlineContent } from './inline-content.ts' import { readBlockDirectiveNode } from './directive-nodes.ts' -export function markdownToAdf(markdown: string): Result { +const documentStart: SourcePosition = { line: 1, offset: 0 } + +export function markdownToAdf(markdown: string): Result { 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 return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 }) } @@ -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) 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) - 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 { diff --git a/src/result.ts b/src/result.ts index d6413cf..62ba6e8 100644 --- a/src/result.ts +++ b/src/result.ts @@ -19,16 +19,16 @@ export type ConvertErrorPath = readonly (number | string)[] export type SourcePosition = { line: number; offset: number } -export type ConvertError = { +export type ConvertFault = { code: ConvertErrorCode message: string - path: ConvertErrorPath - position?: SourcePosition } -export type ConvertFault = Omit +export type ConvertError = ConvertFault & { path: ConvertErrorPath; position?: SourcePosition } -export type Result = { error: ConvertError; ok: false } | { ok: true; value: T } +export type ParseError = ConvertError & { position: SourcePosition } + +export type Result = { error: E; ok: false } | { ok: true; value: T } export function failure(code: ConvertErrorCode, message: string, path: ConvertErrorPath): Result { return { error: { code, message, path }, ok: false } @@ -38,11 +38,11 @@ export function faulted(fault: ConvertFault, path: ConvertErrorPath): Result< return failure(fault.code, fault.message, path) } -export function positioned(result: Result, position: SourcePosition): Result { - if (result.ok || result.error.position !== undefined) return result - return { error: { ...result.error, position }, ok: false } +export function positioned(result: Result, position: SourcePosition): Result { + if (result.ok) return result + return { error: { ...result.error, position: result.error.position ?? position }, ok: false } } -export function success(value: T): Result { +export function success(value: T): { ok: true; value: T } { return { ok: true, value } } diff --git a/todo.md b/todo.md index a01b1f1..0171392 100644 --- a/todo.md +++ b/todo.md @@ -167,7 +167,10 @@ The numbering is the order the work was planned in, not the order it ships. `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. + `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 scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts. - [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` / From 7bb4d7826601af89c71f946f5e74c593c73fe385 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 3 Sep 2026 17:24:46 +0200 Subject: [PATCH 5/5] 5b1: report the line a paragraph's kept text starts on, and stream the source lines --- package.json | 2 +- src/corpus.test.ts | 2 +- src/markdown/parse/blocks.ts | 23 +++++++++++++--------- src/markdown/parse/markdown-to-adf.test.ts | 8 ++++++++ todo-history.md | 19 +++++++++++------- 5 files changed, 36 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 75c1579..5f62007 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "node": ">=18" }, "scripts": { - "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=98 --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" }, "devDependencies": { diff --git a/src/corpus.test.ts b/src/corpus.test.ts index 2e72235..a13a597 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -192,7 +192,7 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) { }) } -// The position the input itself gives an offset: undefined where the offset starts no line. +// 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 diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 8e93651..c08cf76 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -50,7 +50,7 @@ type OpenLeaf = { position: SourcePosition } & ( | { closer: RegExp | undefined; construct: string; kind: 'html' } | { held: string[]; kind: 'indented-code'; lines: string[] } | { indentation: number; info: string; kind: 'fenced-code'; lines: string[]; marker: string } - | { kind: 'paragraph'; lines: string[] } + | { kind: 'paragraph'; lines: string[]; positions: SourcePosition[] } | { kind: 'pipe-table'; rows: [string[], ...string[][]] } ) @@ -377,8 +377,12 @@ function readLineBlock(walk: Walk, opener: string): boolean { function appendParagraph(walk: Walk, line: string): void { const leaf = walk.leaf const text = line.replace(/^[ \t]+/, '') - if (leaf?.kind === 'paragraph') leaf.lines.push(text) - else walk.leaf = { kind: 'paragraph', lines: [text], position: walk.position } + if (leaf?.kind !== 'paragraph') { + 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 { @@ -413,32 +417,33 @@ function faultedBlock(message: string, position: SourcePosition): Block { return { fault: malformedPipeTable(message), kind: 'fault', position } } +// The definitions a paragraph gives up are whole lines, so what is left starts at one this held. function takeParagraph(walk: Walk): Extract | undefined { const leaf = walk.leaf if (leaf?.kind !== 'paragraph') return undefined walk.leaf = undefined const text = readLinkDefinitions(walk.definitions, leaf.lines.join('\n')) - return text === '' ? undefined : { kind: 'paragraph', position: leaf.position, 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[] { return walk.stack.at(-1)?.blocks ?? walk.blocks } -function sourceLines(markdown: string): { position: SourcePosition; text: string }[] { - const lines: { position: SourcePosition; text: string }[] = [] +function* sourceLines(markdown: string): Generator<{ position: SourcePosition; text: string }> { let line = 1 let start = 0 for (let index = 0; index < markdown.length; index += 1) { const character = markdown.charAt(index) if (character !== '\n' && character !== '\r') continue - lines.push(sourceLine(markdown, line, start, index)) + 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) lines.push(sourceLine(markdown, line, start, markdown.length)) - return lines + 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 } { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 073f0cd..db7c926 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -463,6 +463,14 @@ test('names the line and the offset in the input a refusal sits at, the innermos 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\nb\n')), { line: 2, offset: 10 }) + assert.deepEqual(position(markdownToAdf('[a]: /a\n[b]: /b\n[c]: /c\n[d]: /d\nx\n')), { line: 5, offset: 32 }) + assert.deepEqual(position(markdownToAdf('[a]:\n\n"Title"\nb\n')), { line: 4, offset: 23 }) + assert.deepEqual(position(markdownToAdf('> [a]: /url\n> b\n')), { line: 2, offset: 12 }) + assert.deepEqual(position(markdownToAdf(':::caption\n[a]: /url\nb\n:::\n')), { line: 3, offset: 21 }) +}) + 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.')]) diff --git a/todo-history.md b/todo-history.md index 4010090..9e94390 100644 --- a/todo-history.md +++ b/todo-history.md @@ -400,10 +400,15 @@ Under **3 — `markdownToAdf` (`0.1.0`)**: **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, one call in - `blockNodes`, 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. 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 both framings the review settled beside it: - `unsupported-node-shape` stays one code across the two directions, and `unmappable-html` - names the version rather than the element. + 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` into the `Result` 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.