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"
},
"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": {
+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 {
const before = markdown.slice(0, offset)
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' }
| { 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<Block, { kind: 'paragraph' }> | 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 } {
@@ -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\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', () => {
assert.deepEqual(content(markdownToAdf('[a]: /url\n')), [])
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
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<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.