5b1: report the line a paragraph's kept text starts on, and stream the source lines
CI / gate (push) Successful in 8s

This commit is contained in:
2026-09-03 17:24:46 +02:00
parent 8a88c50630
commit 7bb4d78266
5 changed files with 36 additions and 18 deletions
+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=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" "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -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 { function lineStarting(markdown: string, offset: number): { line: number; offset: number } | undefined {
const before = markdown.slice(0, offset) const before = markdown.slice(0, offset)
if (offset !== 0 && !/(?:\r\n|[\n\r])$/.test(before)) return undefined if (offset !== 0 && !/(?:\r\n|[\n\r])$/.test(before)) return undefined
+14 -9
View File
@@ -50,7 +50,7 @@ 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[][]] }
) )
@@ -377,8 +377,12 @@ function readLineBlock(walk: Walk, opener: string): boolean {
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], position: walk.position } 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 {
@@ -413,32 +417,33 @@ function faultedBlock(message: string, position: SourcePosition): Block {
return { fault: malformedPipeTable(message), kind: 'fault', position } 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<Block, { kind: 'paragraph' }> | undefined { 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 : { 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[] { function currentBlocks(walk: Walk): Block[] {
return walk.stack.at(-1)?.blocks ?? walk.blocks return walk.stack.at(-1)?.blocks ?? walk.blocks
} }
function sourceLines(markdown: string): { position: SourcePosition; text: string }[] { function* sourceLines(markdown: string): Generator<{ position: SourcePosition; text: string }> {
const lines: { position: SourcePosition; text: string }[] = []
let line = 1 let line = 1
let start = 0 let start = 0
for (let index = 0; index < markdown.length; index += 1) { for (let index = 0; index < markdown.length; index += 1) {
const character = markdown.charAt(index) const character = markdown.charAt(index)
if (character !== '\n' && character !== '\r') continue 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 if (character === '\r' && markdown.charAt(index + 1) === '\n') index += 1
line += 1 line += 1
start = index + 1 start = index + 1
} }
if (start < markdown.length) lines.push(sourceLine(markdown, line, start, markdown.length)) if (start < markdown.length) yield sourceLine(markdown, line, start, markdown.length)
return lines
} }
function sourceLine(markdown: string, line: number, start: number, end: number): { position: SourcePosition; text: string } { function sourceLine(markdown: string, line: number, start: number, end: number): { position: SourcePosition; text: string } {
@@ -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 }) 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.')])
+12 -7
View File
@@ -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 **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 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. 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 The block walk mints it and the node walk attaches it as results return — at `blockNodes`,
`blockNodes`, so the innermost block wins and the emitter's own refusals — which the and at the inline body a directive holds — so the innermost block wins and the emitter's
parser re-enters for the CommonMark spelling get an input coordinate too. Line endings own refusals, which the parser re-enters for the CommonMark spelling, get an input
stay as the input spells them, so an offset indexes the string the caller passed rather coordinate too. `markdownToAdf` wraps the walk once more, which is what turns the wide
than a normalized copy of it. §8 records both framings the review settled beside it: `Result<T>` into the `Result<T, ParseError>` its signature promises rather than guarding
`unsupported-node-shape` stays one code across the two directions, and `unmappable-html` anything: the depth guard under it cannot fire at depth 0. A paragraph names the line its
names the version rather than the element. 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.