diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb2648..d979343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,11 @@ 20 lines of 40000 tabs under 80000 nested items took 1988 ms. - `losslessMarkdownToAdf` takes time linear in the markdown when an inline directive's `[content]` holds many unclosed backtick runs: 640000 characters of them took one second, now 100 ms. -- Every reader leaves `[text]` and `[text][]` literal when the text runs over 999 characters, where - it read them as a link to the matching definition: CommonMark caps a link label at 999 characters. +- Every reader takes time linear in the inline markdown when emphasis spans many links, or many + closing brackets follow a link: 8000 links inside 8000 `*` took 4.4 seconds, now 170 ms. +- Every reader leaves a link label over 999 characters literal, counting an escape as two, where it + read a link to the matching definition: CommonMark caps a link label at 999 characters. This holds + for `[text]`, `[text][]`, `[text][label]` and the definition `[label]: url` alike. - The README compares the package with seven other ADF converters, each checked against its published version. diff --git a/src/markdown/commonmark/link-syntax.ts b/src/markdown/commonmark/link-syntax.ts index eb41d2b..d6ec45e 100644 --- a/src/markdown/commonmark/link-syntax.ts +++ b/src/markdown/commonmark/link-syntax.ts @@ -9,14 +9,16 @@ export type LinkPart = { length: number; value: string } export type TitleEscapes = 'lossless' | 'portable' | 'portable-table-cell' const bracketedDestination = /<((?:[^\n<>\\]|\\[^\n])*)>/y -const linkLabel = /\[((?:[^[\]\\]|\\[\s\S]){0,999})\]/y +// CommonMark: a link label holds at most this many characters between its brackets, an escape counting as two. +export const labelLimit = 999 +const linkLabel = new RegExp(String.raw`\[((?:[^[\]\\]|\\[\s\S]){0,${labelLimit}})\]`, 'y') const titleClosers: Readonly> = { '"': '"', "'": "'", '(': ')' } // The label is matched by its normalized raw text, escapes and all. export function readLabel(text: string, offset: number): LinkPart | undefined { linkLabel.lastIndex = offset const value = linkLabel.exec(text)?.[1] - return value === undefined ? undefined : { length: value.length + 2, value } + return value === undefined || value.length > labelLimit ? undefined : { length: value.length + 2, value } } export function normalizeLabel(raw: string): string { diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index e70e0b7..5b25084 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -15,7 +15,7 @@ import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../a import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts' import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts' import { matchEmphasis } from '../commonmark/emphasis-matching.ts' -import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts' +import { labelLimit, normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts' import { noSpans, readInlineDirective } from '../directive/syntax.ts' import { openingLinkTakesDirective } from '../emit/spelling-questions.ts' import { readCarriedInline } from '../opaque-carry.ts' @@ -431,9 +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. const labelIsText = label === undefined || label.value === '' - if (labelIsText && index - start > 999) return undefined + if (labelIsText && index - start > labelLimit) return undefined const name = labelIsText ? scan.source.slice(start, index) : label.value const definition = scan.definitions.get(normalizeLabel(name)) if (definition === undefined) return undefined @@ -566,7 +565,7 @@ function markPairings(pieces: readonly Piece[], nodes: Inline[][], pairings: rea } for (const pairing of opening.get(index) ?? []) { const type = markType(pairing.opener.character, pairing.used) - // A type takes its place from the innermost pairing of it. + // Each type once, so a piece's marks stay at most three however deep the pairings nest. const outer = open.at(-1)?.marks.filter((mark) => mark.type !== type) ?? [] open.push({ closer: pairing.closer.index, marks: [...outer, { type }] }) } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 05e555d..1ce6886 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -1078,8 +1078,12 @@ test('reads the reference links a definition resolves, and leaves the rest liter test('reads link text past 999 characters as no label, in every reader', () => { const longest = `foo${' '.repeat(993)}bar` - const tooLong = `foo${' '.repeat(1000)}bar` + const tooLong = `foo${' '.repeat(994)}bar` + const escaped = '\\*'.repeat(499) + const escapedTooLong = '\\*'.repeat(500) for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf]) { + assert.deepEqual(content(read(`[a][${escaped}]\n\n[${escaped}]: /u\n`)), [{ content: [marked('a', link('/u'))], type: 'paragraph' }]) + assert.deepEqual(content(read(`[a][${escapedTooLong}]\n\n[${escapedTooLong}]: /u\n`)), [paragraph(`[a][${'*'.repeat(500)}]`), paragraph(`[${'*'.repeat(500)}]: /u`)]) assert.deepEqual(content(read(`[${longest}]\n\n[foo bar]: /u\n`)), [{ content: [marked(longest, link('/u'))], type: 'paragraph' }]) assert.deepEqual(content(read(`[${longest}][]\n\n[foo bar]: /u\n`)), [{ content: [marked(longest, link('/u'))], type: 'paragraph' }]) assert.deepEqual(content(read(`[${tooLong}]\n\n[foo bar]: /u\n`)), [paragraph(`[${tooLong}]`)])