diff --git a/CHANGELOG.md b/CHANGELOG.md index c50c94e..ecd5552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,10 @@ - Every reader leaves `[text]`, `[text][]`, `[text][label]` and a definition `[label]: url` literal when the label runs over 999 characters, where it read them as a link or a definition. CommonMark caps a label at 999 characters, an escape counting as two. +- Every reader leaves a link or a definition literal when its unbracketed destination holds a `(` + that is neither escaped nor closed by a `)` before the space ending the destination, where it + read it as a link: `[a](b(c "t")` is now text, as in CommonMark. Markdown the writers wrote reads + as before, since they escape such a parenthesis. - The README compares the package with seven other ADF converters, each checked against its published version. diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 57048cf..7887739 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -494,3 +494,7 @@ Read most (opens and searches): ## Reading cost, chunk 6c9ac781b7a6, finished 2026-10-28T10:49:16Z 306528 new input tokens before the first commit. + +## Reading cost, chunk ccd1a10b3e06, finished 2026-10-28T12:01:54Z + +120318 new input tokens before the first commit. diff --git a/src/markdown/commonmark/link-syntax.test.ts b/src/markdown/commonmark/link-syntax.test.ts index 3328af7..4d0b60c 100644 --- a/src/markdown/commonmark/link-syntax.test.ts +++ b/src/markdown/commonmark/link-syntax.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { readInlineTarget, targetMemo } from './link-syntax.ts' +import { readDestination, readInlineTarget, targetMemo } from './link-syntax.ts' // Every `](` of the text, read in order through one memo, as a reader closes its brackets. function targets(text: string): unknown[] { @@ -28,6 +28,16 @@ test('an unbracketed destination ends at its first unmatched `)`', () => { assert.deepEqual(targets('[a](b\\)c)'), [{ destination: 'b)c', length: 6 }]) }) +test('a destination holding an unbalanced unescaped `(` at a space or the end reads as none', () => { + assert.deepEqual(targets('[a](b(c "t")'), [undefined]) + assert.deepEqual(targets('[a](b(c "t") [d](e)'), [undefined, { destination: 'e', length: 3 }]) + assert.deepEqual(targets('[a](b(c) "t")'), [{ destination: 'b(c)', length: 10, title: 't' }]) + assert.deepEqual(targets('[a](b((c) "t")'), [undefined]) + assert.equal(readDestination('b(c', 0), undefined) + assert.equal(readDestination('b(c d', 0), undefined) + assert.deepEqual(readDestination('b(c) d', 0), { length: 4, value: 'b(c)' }) +}) + test('a target reads whitespace and a title around its destination, bracketed, unbracketed or empty', () => { assert.deepEqual(targets('[a]( b\n "t" ) [c]( \'f\') [g]()'), [ { destination: 'b', length: 10, title: 't' }, diff --git a/src/markdown/commonmark/link-syntax.ts b/src/markdown/commonmark/link-syntax.ts index f899364..25a8e29 100644 --- a/src/markdown/commonmark/link-syntax.ts +++ b/src/markdown/commonmark/link-syntax.ts @@ -72,6 +72,13 @@ export function targetMemo(text: string): TargetMemo { return { destinationEnds: undefined, tails: new Map(), text } } +// Of the starts still open at a stop, only the innermost has its parentheses balanced; the others hold a `(` nothing closed and keep no end. +function stopAt(ends: Int32Array, open: number[], index: number): void { + const innermost = open.pop() + if (innermost !== undefined) ends[innermost] = index + open.length = 0 +} + // `ends[start]` is where an unbracketed destination from `start` stops, filled in one pass for every start a target can have: just after a `(`, a space or a control character, and 0, where `readDestination`'s run begins. function destinationEnds(text: string): Int32Array { const ends = new Int32Array(text.length + 1) @@ -82,10 +89,10 @@ function destinationEnds(text: string): Int32Array { const start = character === ')' ? open.pop() : undefined if (start !== undefined) ends[start] = index if (character !== ' ' && !holdsControlCharacter(character)) continue - for (const stopped of open.splice(0)) ends[stopped] = index + stopAt(ends, open, index) open.push(index + 1) } - for (const stopped of open) ends[stopped] = text.length + stopAt(ends, open, text.length) return ends } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 0ded5fa..9a82994 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -1031,6 +1031,8 @@ test('leaves the bracket pair no link parses as the text it holds', () => { assert.deepEqual(content(losslessMarkdownToAdf('a ] b\n')), [paragraph('a ] b')]) assert.deepEqual(content(losslessMarkdownToAdf('[a](/u\n')), [paragraph('[a](/u')]) assert.deepEqual(content(losslessMarkdownToAdf('[a](/u x)\n')), [paragraph('[a](/u x)')]) + assert.deepEqual(content(losslessMarkdownToAdf('[a](b(c "t")\n')), [paragraph('[a](b(c "t")')]) + assert.deepEqual(content(losslessMarkdownToAdf('[a][r]\n\n[r]: b(c "t"\n')), [paragraph('[a][r]'), paragraph('[r]: b(c "t"')]) assert.deepEqual(content(losslessMarkdownToAdf('[a](