diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index affced8..aef86c0 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -78,7 +78,7 @@ type Scan = { definitions: LinkDefinitions // Whether this content is an `!adf:link`'s text, where an image reads as its plain alt text. inLink: boolean - // A link opener deeper in `brackets` than this is active; one below it stood open while a link closed, and no link holds another. + // A link opener at this index of `brackets` or above is active; one below it stood open while a link closed, and no link holds another. linkOpenersFrom: number openingSpellableLink: boolean path: ConvertErrorPath @@ -386,23 +386,22 @@ function linkedText(text: string, scheme: string): AdfNode { function closeBracket(scan: Scan, index: number): Result { flush(scan, false) const open = popBracket(scan) - const target = open?.active === true ? resolveTarget(scan, open.start, index) : undefined - if (open === undefined || target === undefined) return success(literalClose(scan, index)) + if (open === undefined) return success(literalClose(scan, index)) + const target = open.active ? resolveTarget(scan, open.start, index) : undefined + if (target === undefined) return success(unopened(scan, open, index)) const inner = scan.pieces.slice(open.index + 1) + // A link closing, or one inside these brackets, leaves every link opener still open literal. + if (!open.image) scan.linkOpenersFrom = scan.brackets.length // Ahead of the own-marks guard in `linkPieces`: brackets going literal put the carry inside no mark for it to refuse. - if (!open.image && holdsLink(inner)) { - scan.linkOpenersFrom = scan.brackets.length - return success(literalClose(scan, index)) - } + if (!open.image && holdsLink(inner)) return success(unopened(scan, open, index)) const closed = open.image ? imagePieces(scan, inner, target.definition) : linkPieces(scan, inner, target.definition) if (!closed.ok) return closed - if (!open.image) scan.linkOpenersFrom = scan.brackets.length scan.pieces.length = open.index for (const piece of closed.value) scan.pieces.push(piece) return success(index + 1 + target.length) } -// Every close takes the innermost bracket off the stack, whether it closes a link, an image or nothing; its piece reads as literal text unless the close replaces it. +// Every close takes the innermost bracket off the stack, whether it closes a link, an image or nothing. function popBracket(scan: Scan): (OpenBracket & { active: boolean }) | undefined { const open = scan.brackets.pop() if (open === undefined) return undefined @@ -416,7 +415,12 @@ function literalClose(scan: Scan, index: number): number { return index + 1 } -function bracketNodes(bracket: Bracket): AdfNode[] { +function unopened(scan: Scan, open: OpenBracket, index: number): number { + scan.pieces[open.index] = { kind: 'nodes', nodes: bracketNodes(open) } + return literalClose(scan, index) +} + +function bracketNodes(bracket: Bracket | OpenBracket): AdfNode[] { return [{ text: bracket.image ? '![' : '[', type: 'text' }] } @@ -427,6 +431,8 @@ function resolveTarget(scan: Scan, start: number, index: number): { definition: if (inline !== undefined) return { definition: inline.definition, length: inline.length } } const label = scan.source.charAt(after) === '[' ? readLabel(scan.source, after) : undefined + // CommonMark: a label holds at most 999 characters, so longer link text names no definition. + if (label === undefined && index - start > 999) return undefined const name = label === undefined || label.value === '' ? scan.source.slice(start, index) : label.value const definition = scan.definitions.get(normalizeLabel(name)) if (definition === undefined) return undefined @@ -546,15 +552,9 @@ function writeUnpaired(nodes: Inline[][], runs: readonly Run[], pairings: readon } } -// Pairings nest and come innermost first, so one walk holding the open ones on a stack marks each piece once. -// A type takes its place from the innermost pairing of it, and the marks array runs outermost first (spec/flavour.md, Marks). +// Pairings nest, so one walk holding the open ones on a stack marks each piece once, the marks outermost first (spec/flavour.md, Marks). function markPairings(pieces: readonly Piece[], nodes: Inline[][], pairings: readonly Pairing[]): boolean { - const opening = new Map() - for (const pairing of pairings) { - const opened = opening.get(pairing.opener.index) - if (opened === undefined) opening.set(pairing.opener.index, [pairing]) - else opened.push(pairing) - } + const opening = pairingsByOpener(pairings) const open: { closer: number; marks: AdfMark[] }[] = [] for (const [index, piece] of pieces.entries()) { while (open.at(-1)?.closer === index) open.pop() @@ -563,15 +563,28 @@ function markPairings(pieces: readonly Piece[], nodes: Inline[][], pairings: rea if (piece.kind === 'ownMarks') return false nodes[index] = marks.reduceRight((marked, mark) => applyMark(marked, mark), nodes[index] ?? []) } - for (const pairing of [...(opening.get(index) ?? [])].reverse()) { + for (const pairing of opening.get(index) ?? []) { const type = markType(pairing.opener.character, pairing.used) - const outer = open.at(-1)?.marks ?? [] - open.push({ closer: pairing.closer.index, marks: [...outer.filter((mark) => mark.type !== type), { type }] }) + // A type takes its place from the innermost pairing of it. + const outer = open.at(-1)?.marks.filter((mark) => mark.type !== type) ?? [] + open.push({ closer: pairing.closer.index, marks: [...outer, { type }] }) } } return true } +// `matchEmphasis` lists a run's pairings innermost first, so each list here is reversed to stack the outermost first. +function pairingsByOpener(pairings: readonly Pairing[]): Map { + const opening = new Map() + for (const pairing of pairings) { + const opened = opening.get(pairing.opener.index) + if (opened === undefined) opening.set(pairing.opener.index, [pairing]) + else opened.push(pairing) + } + for (const opened of opening.values()) opened.reverse() + return opening +} + function highlightDelimiters(pieces: readonly Piece[], nodes: readonly Inline[][]): HighlightDelimiter[] { const found: HighlightDelimiter[] = [] // A stretch is a run of text no other inline node breaks. @@ -603,7 +616,10 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly Inline[][]): if (!opener.opens || opener.position < resume) continue const earliest = opener.position + highlightDelimiter.length + 1 let candidate = found[closer] - while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)] + while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) { + closer += 1 + candidate = found[closer] + } if (candidate === undefined) break if (candidate.stretch !== opener.stretch || !identicalMarks(nodeMarks(opener.holder), nodeMarks(candidate.holder))) continue paired.push({ closer: candidate.index, opener: opener.index }) diff --git a/todo.md b/todo.md index d9eb34e..2646670 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 0.1` -`Next ID = 140` +`Next ID = 141` ## Items @@ -18,14 +18,14 @@ | 133 | 0.3.0 | 131 | | **Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 | | 132 | 0.3.0 | 131 | | **Write tables as `` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 | | 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 | +| 140 | 0.4.0 | | defect | **Read an inline link's target in time linear in the inline markdown, in every reader.** | 3 | 1 | 2 | 4 | 3 | 9 by 3 | 19.6 | | 75 | 0.4.0 | | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 | -| 129 | 0.4.0 | | defect | **Read emphasis and link brackets in time linear in the inline markdown, in every reader.** | 3 | 1 | 2 | 4 | 4 | 9 by 3 | 13.1 | | 95 | 0.4.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 | | 33 | 0.4.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 | | 109 | 0.4.0 | | defect | **Spell nested lists and quotes in time linear in the markdown.** | 3 | 1 | 2 | 4 | 5 | 9 by 3 | 7.8 | | 115 | 0.4.0 | | defect | **Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.** | 2 | 1 | 2 | 3 | 3 | 4 by 3 | 4.6 | | 91 | 0.4.0 | 95 | | **Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 | -| 73 | 0.4.0 | 33, 91, 95, 109, 115, 125, 129 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 | +| 73 | 0.4.0 | 33, 91, 95, 109, 115, 125, 140 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 | | 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 | | 68 | 0.4.0 | | defect | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 2 | 1 | 2 | 3 | 3 | 1 by 2 | 13.1 | | 127 | 0.4.0 | | principle | **Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text.** | 2 | 2 | 1 | 3 | 3 | code health by 2 | 13.1 | @@ -109,6 +109,15 @@ on 2026-10-22, so the gaps predate the move: `tryTaskList` and `taskBlocks` in ` `src/markdown/portable/portable-markdown-to-adf.ts`. Each test takes its expected value from `spec/flavour.md` or `docs/decisions.md`, never from the code. +### 140. Read an inline link's target in time linear in the inline markdown, in every reader. + +`'[a](b'.repeat(n)` reads in time quadratic in n: each `](` after an open bracket reads its +destination to the end of the line through `readDestination` (`src/markdown/commonmark/link-syntax.ts`), +finds no `)` and fails. `skipLinkWhitespace` also copies the rest of the text on every call. +`losslessMarkdownToAdf` takes 1.6 s at n = 4000 and 25 s at n = 16 000 on Node 24, 4× per doubling. +cmark's pathological tests name the shape "unclosed links B". Found while shipping item 129, +2026-10-23. + ### 75. Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile. `blockCarry` and `hostileBlockCarry` (`src/conformance/markdown-property.test.ts`) write a fence @@ -117,21 +126,6 @@ type", that is an ordinary code language, so the property reaches `readCarriedBl where a random edit lands in a canonical document. Write `adf:` and `adf:` fences, canonical and hostile. Technical principle: for pure logic over many inputs, prefer a property test, so the property must reach the fenced carry's refusals itself. Found by the technical-principles audit, 2026-10-04. -### 129. Read emphasis and link brackets in time linear in the inline markdown, in every reader. - -The inline reader in `src/markdown/parse/inline-content.ts` is quadratic in two shapes, counted in -operations (§Cost fixes are measured, never timed), 4× per doubling: about 255 000, 1 010 000 and -4 020 000 at n = 500, 1000 and 2000. - -- `markPairings`, called through `markedNodes`, re-marks every piece each emphasis pairing spans: - `'*'.repeat(n) + '[b a](/v)'.repeat(n) + '*'.repeat(n)`. Pair by range instead, outermost - pairing first, marking each piece once. -- `lastBracket`, called by `closeBracket`, walks back over the pieces each literal bracket leaves: - `'['.repeat(n) + '[b a](/v)' + '](/w)'.repeat(n)`. Drop a bracket from the search once it goes - literal, as CommonMark's bracket stack does. - -The seats found `closeBracket` and `resolveNodes` hard to read; leave both easier to read. - ### 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 @@ -351,8 +345,8 @@ agree. Found by the technical-principles audit, 2026-10-04. Technical principle "One owner per value": the `Walk` record passes through functions that mutate it and return `void`; `walk.leaf` alone is written in seven places, and `openedList` reads the `endedLists` that `closeLeaf` fills. `ContainerStack` in the same file shows the shape to follow. `inline-content.ts`'s `Scan` has the same shape: about fifteen -functions write `pending`, `pieces`, `deactivatedBefore` and `openingSpellableLink` and return -`void`, and `parseInlineContent` reads a flag `scanInline` leaves on it. The same shape recurs in +functions write `pending`, `pieces`, `brackets`, `linkOpenersFrom` and `openingSpellableLink` on the `Scan` +they are handed, most of them returning `void`, and `parseInlineContent` reads a flag `scanInline` leaves on it. The same shape recurs in `mintTaskIds` (`portable/task-ids.ts`), which writes `attrs` on the document `readDocument` built; `takeFallback` (`emit/inline-line.ts`), which writes the record the next `lineSegments` pass reads; `writeUnpaired`, `markPairings` and `markHighlights` (`inline-content.ts`), whose order changes the