diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index e74ba7a..024d12d 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -6,7 +6,7 @@ import type { InlineSource, InlineToken } from '../inline-tokens.ts' import type { LineContainer } from '../line-container.ts' import type { LinkDefinition } from '../commonmark/link-syntax.ts' import { centeredImage, externalMedia, imageAside } from '../external-image.ts' -import { commonMarkLink, linkHref, linkMark } from '../mark-spellings.ts' +import { commonMarkLink, linkDefinition, linkHref, linkMark } from '../mark-spellings.ts' import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { flavourClaims } from '../flavour.ts' @@ -227,9 +227,8 @@ function directiveMarkPieces(scan: Scan, name: string, mark: AdfMark, slot: Slot } const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, nodes, index) : undefined if (refused !== undefined) return refused - const attrs = nodeAttrs(mark) - const title = attrs['title'] - const imageLinks = mark.type === 'link' ? besideLink(slot.imageLinks, linkHref(attrs) ?? '', typeof title === 'string' ? title : undefined) : slot.imageLinks + const link = mark.type === 'link' ? linkDefinition(nodeAttrs(mark)) : undefined + const imageLinks = link === undefined ? slot.imageLinks : besideLink(slot.imageLinks, link) return success([...slot.pieces.map((piece) => ({ ...piece, nodes: applyMark(piece.nodes, mark) })), ...imageLinks]) } @@ -298,17 +297,17 @@ function linkedAlt(piece: Piece, inLink: boolean): Piece[] { if (inLink && piece.kind === 'imageLink') return [] if (piece.kind !== 'image' || (inLink && piece.link === undefined)) return [piece] const alt: Piece = { kind: 'nodes', nodes: linkTo(piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }], piece.link ?? piece.definition) } - return piece.link === undefined ? [alt] : [alt, ...besideLink([imageLink(piece.definition)], piece.link.destination, piece.link.title)] + return piece.link === undefined ? [alt] : [alt, ...besideLink([imageLink(piece.definition)], piece.link)] } // As adfToPortableMarkdown writes it: a link to the image itself needs no (image), unless the image holds a title the link does not. -function besideLink(imageLinks: readonly ImageLink[], href: string, title: string | undefined): ImageLink[] { - return imageLinks.filter(({ image }) => image.destination !== href || (image.title !== undefined && image.title !== '' && image.title !== title)) +function besideLink(imageLinks: readonly ImageLink[], link: LinkDefinition): ImageLink[] { + return imageLinks.filter(({ image }) => image.destination !== link.destination || (image.title !== undefined && image.title !== '' && image.title !== link.title)) } function linkedImages(pieces: readonly Piece[]): ImageLink[] { return pieces.flatMap((piece) => { - if (piece.kind === 'image') return piece.link === undefined ? [imageLink(piece.definition)] : [] + if (piece.kind === 'image') return [imageLink(piece.definition)] return piece.kind === 'imageLink' ? [piece] : [] }) } @@ -372,9 +371,9 @@ function holdsOwnMarks(item: Inline | Piece): boolean { return 'kind' in item && item.kind === 'ownMarks' } -// Only a link a `nodes` piece spells, an image's, or an (image)'s makes the outer brackets literal; an `ownMarks` node's link is its own, so the own-marks guard in `closeLink` refuses it. +// Only a link a `nodes` piece spells, or an (image)'s, makes the outer brackets literal; an `ownMarks` node's link is its own, so the own-marks guard in `closeLink` refuses it. function holdsLink(pieces: readonly Piece[]): boolean { - return pieces.some((piece) => piece.kind === 'imageLink' || (piece.kind === 'nodes' && marksLink(piece.nodes)) || (piece.kind === 'image' && piece.link !== undefined)) + return pieces.some((piece) => piece.kind === 'imageLink' || (piece.kind === 'nodes' && marksLink(piece.nodes))) } function marksLink(nodes: readonly Inline[]): boolean { @@ -461,7 +460,7 @@ function linkPieces(scan: Scan, inner: readonly Piece[], definition: LinkDefinit if (!nodes.value.some((node) => marksItem(node, 'link')) && nodes.value.some((node) => isNode(node) && node.type !== 'hardBreak')) { return failure('unsupported-node-shape', unlinkable, scan.path) } - return success([{ kind: 'nodes', nodes: linkTo(nodes.value, definition) }, ...besideLink(linkedImages(inner), definition.destination, definition.title)]) + return success([{ kind: 'nodes', nodes: linkTo(nodes.value, definition) }, ...besideLink(linkedImages(inner), definition)]) } // CommonMark: no link nests inside another, though an image's description holds one. diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 2570d19..f638721 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -1189,7 +1189,12 @@ test('writes no (image) after a link to the image itself, unless the image holds assert.deepEqual(firstContent('See [![a](/u)](/u "t").\n'), [text('See '), marked('a', link('/u', 't')), text('.')]) assert.deepEqual(firstContent('See [![a](/u "t")](/u).\n'), [text('See '), marked('a', link('/u')), text(' ('), marked('image', link('/u', 't')), text(').')]) assert.deepEqual(firstContent('[b ![a](/u) ![c](/w)](/u)\n'), [marked('b a c', link('/u')), text(' ('), marked('image', link('/w')), text(')')]) + assert.deepEqual(firstContent('See [![a](/u "")](/u).\n'), [text('See '), marked('a', link('/u')), text('.')]) + const titled = { attrs: { href: 'a\nb', title: 't' }, type: 'link' } assert.deepEqual(firstContent('!adf:link[b ![a](a b)]{href="a\\nb"}\n'), [marked('b a', { attrs: { href: 'a\nb' }, type: 'link' })]) + assert.deepEqual(firstContent('!adf:link[b ![a](a b "t")]{href="a\\nb" title=t}\n'), [marked('b a', titled)]) + assert.deepEqual(firstContent('!adf:link[!adf:underline[![a]()]]{href="a\\nb"}\n'), [marked('a', { attrs: { href: 'a\nb' }, type: 'link' }, { type: 'underline' }), text(' ('), marked('image', link('')), text(')')]) + assert.deepEqual(firstContent('!adf:link[b ![a](a b "u")]{href="a\\nb" title=t}\n'), [marked('b a', titled), text(' ('), marked('image', link('a\nb', 'u')), text(')')]) }) test('carries the mark a spelling nested inside its own kind names once', () => { diff --git a/todo.md b/todo.md index b84a4f9..5e55543 100644 --- a/todo.md +++ b/todo.md @@ -6,19 +6,20 @@ `Bar = 0.1` -`Next ID = 129` +`Next ID = 130` ## Items | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| +| 129 | 0.3.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.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 | | 125 | 0.3.0 | | | **Anchor each level of L, E and I in `docs/score-anchors.md` with two or three shipped items.** | 3 | 2 | 3 | 6 | 4 | code health by 2 | 8.7 | | 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 | | 109 | 0.3.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.3.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.3.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.3.0 | 33, 91, 95, 109, 115, 125 | | **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.3.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 | | 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 | | 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 | | 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 | @@ -54,6 +55,22 @@ ## Details +### 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 every piece a bracket that went literal + left: `'['.repeat(n) + '[b a](/v)' + '](/w)'.repeat(n)`. Drop such a bracket from the search, as + CommonMark's bracket stack does. + +The seats found `closeBracket` and `resolveNodes` hard to read; leave both easier to read. Found by +the stability review of item 122, 2026-10-23. + ### 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