3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-13 04:08:29 +01:00

Read a link destination holding an unbalanced unescaped parenthesis as no destination

This commit is contained in:
2026-10-28 13:04:54 +01:00
committed by Lillem4n
parent 744476658c
commit 6fe527c945
6 changed files with 30 additions and 13 deletions
+4
View File
@@ -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.
+4
View File
@@ -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.
+11 -1
View File
@@ -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](<d e> \'f\') [g]()'), [
{ destination: 'b', length: 10, title: 't' },
+9 -2
View File
@@ -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
}
@@ -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](<u\n')), [paragraph('[a](<u')])
assert.deepEqual(content(losslessMarkdownToAdf('![a\n')), [paragraph('![a')])
assert.deepEqual(content(losslessMarkdownToAdf('[a [b](/u) c](/v)\n')), [
-10
View File
@@ -12,7 +12,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 151 | 0.3.0 | | defect | **Read a link destination holding an unbalanced unescaped parenthesis as no destination, in every markdown reader.** | 2 | 2 | 2 | 4 | 2 | 5 by 2 | 17.4 |
| 95 | 0.3.0 | | decision | **Find the code-span closers an escape forms, the mark runs a line cannot spell and the text `[` that opens link text by reading the line back, in place of `escapeClosedRuns`, `unspellableRuns` and `lastLinkClose` predicting them.** | 4 | 1 | 3 | 6 | 6 | code health by 7 | 10.2 |
| 146 | 0.3.0 | | | **Open the README with what an app developer choosing an ADF converter needs: what the package converts, which call to take, and that it runs without React, Atlaskit or any runtime dependency, in Node, Forge, Deno, Bun and browsers.** | 2 | 2 | 1 | 3 | 3 | 3 by 6 | 9.5 |
| 33 | 0.3.0 | | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 3 | 1 | 2 | 4 | 5 | 9 by 3 | 7.8 |
@@ -69,15 +68,6 @@
## Details
### 151. Read a link destination holding an unbalanced unescaped parenthesis as no destination, in every markdown reader.
CommonMark lets an unbracketed destination hold a parenthesis only escaped or in a balanced pair,
but `[a](b(c "t")` reads as a link to `b(c` titled `t`; cmark and commonmark.js read it as text.
`targetMemo` (`src/markdown/commonmark/link-syntax.ts`), which `readDestination` reads through,
stops at a space with parentheses still open and keeps the destination. The writers escape an
unbalanced parenthesis, so markdown they wrote reads the same after the fix. Found while shipping
item 140, 2026-10-28.
### 95. Find the code-span closers an escape forms, the mark runs a line cannot spell and the text `[` that opens link text by reading the line back, in place of `escapeClosedRuns`, `unspellableRuns` and `lastLinkClose` predicting them.
`escapeClosedRuns` works out which backtick strings an escape leaves, `unspellableRuns` rebuilds the