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.