From 935012d475ba8bd16e51823daea5a4f1b3aa90b8 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 28 Oct 2026 12:44:27 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20stability=20round=201:=201=20?= =?UTF-8?q?=F0=9F=94=B4,=201=20=F0=9F=9F=A1=20(0=20misread);=20nested=20di?= =?UTF-8?q?rective=20contents=20share=20their=20paragraph's=20memo,=20and?= =?UTF-8?q?=20a=20title=20after=20=20needs=20whitespace,=20pi?= =?UTF-8?q?nned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/markdown/commonmark/link-syntax.test.ts | 3 +- src/markdown/commonmark/link-syntax.ts | 31 +++++++++++++-------- src/markdown/directive/syntax.ts | 5 ++++ src/markdown/parse/inline-content.ts | 29 ++++++++++--------- src/markdown/parse/markdown-to-adf.test.ts | 7 +++++ 5 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/markdown/commonmark/link-syntax.test.ts b/src/markdown/commonmark/link-syntax.test.ts index 34a322c..3328af7 100644 --- a/src/markdown/commonmark/link-syntax.test.ts +++ b/src/markdown/commonmark/link-syntax.test.ts @@ -8,7 +8,7 @@ function targets(text: string): unknown[] { const memo = targetMemo(text) const read: unknown[] = [] for (let offset = text.indexOf('](') + 1; offset > 0; offset = text.indexOf('](', offset) + 1) { - const target = readInlineTarget(text, offset, memo) + const target = readInlineTarget(memo, offset) read.push(target === undefined ? undefined : { ...target.definition(), length: target.length }) } return read @@ -34,4 +34,5 @@ test('a target reads whitespace and a title around its destination, bracketed, u { destination: 'd e', length: 11, title: 'f' }, { destination: '', length: 2 }, ]) + assert.deepEqual(targets('[a]("t")'), [undefined]) }) diff --git a/src/markdown/commonmark/link-syntax.ts b/src/markdown/commonmark/link-syntax.ts index 6a6c463..8ac7bed 100644 --- a/src/markdown/commonmark/link-syntax.ts +++ b/src/markdown/commonmark/link-syntax.ts @@ -10,8 +10,8 @@ export type LinkPart = { length: number; value: string } // Every destination ending at one index shares what follows it: the title and the closing `)`. type Tail = { end: number; title: string | undefined } -// `destinationEnds[start]` is where an unbracketed destination starting there stops, for every start a target can have: the index just after a `(`, a space or a control character. -export type TargetMemo = { destinationEnds: Int32Array; tails: Map } +// What the targets read in `text` share; `destinationEnds` is walked at the first one. +export type TargetMemo = { destinationEnds: Int32Array | undefined; tails: Map; text: string } // The lossless flavour escapes a quote alone, writing a title holding a backslash or an entity reference as a directive (spec/flavour.md, Marks); the portable flavour writes no directive, so it escapes those too, and a table cell's `|` (README, Portable markdown). export type TitleEscapes = 'lossless' | 'portable' | 'portable-table-cell' @@ -47,14 +47,15 @@ export function readDestination(text: string, offset: number): LinkPart | undefi destinationRun.lastIndex = offset destinationRun.test(text) const run = text.slice(offset, destinationRun.lastIndex) - const length = targetMemo(run).destinationEnds[0] ?? 0 + const length = destinationEnds(run)[0] ?? 0 return length === 0 ? undefined : { length, value: decodeTextEscapes(run.slice(0, length)) } } // `offset` sits on the `(` a link text closes into. The definition is built only when called, so a link the reader then refuses never decodes its destination. -export function readInlineTarget(text: string, offset: number, memo: TargetMemo): InlineTarget | undefined { +export function readInlineTarget(memo: TargetMemo, offset: number): InlineTarget | undefined { + const { text } = memo const start = skipLinkWhitespace(text, offset + 1) - const end = destinationEnd(text, start, memo) + const end = destinationEnd(memo, start) if (end === undefined) return undefined const tail = memo.tails.has(end) ? memo.tails.get(end) : readTail(text, end) memo.tails.set(end, tail) @@ -67,30 +68,36 @@ export function readInlineTarget(text: string, offset: number, memo: TargetMemo) return { definition, length: tail.end - offset } } -// Every unbracketed destination's end, in one pass. Index 0 is a start too, for `readDestination`, which reads from its text's first character. export function targetMemo(text: string): TargetMemo { - const destinationEnds = new Int32Array(text.length + 1) + return { destinationEnds: undefined, tails: new Map(), text } +} + +// Where an unbracketed destination starting at each index stops, in one pass, for every start a target can have: the index just after a `(`, a space or a control character. Index 0 is a start too, for `readDestination`, which reads from its text's first character. +function destinationEnds(text: string): Int32Array { + const ends = new Int32Array(text.length + 1) const open = [0] for (let index = 0; index < text.length; index += backslashEscape(text, index) === undefined ? 1 : 2) { const character = text.charAt(index) if (character === '(') open.push(index + 1) const start = character === ')' ? open.pop() : undefined - if (start !== undefined) destinationEnds[start] = index + if (start !== undefined) ends[start] = index if (character !== ' ' && !holdsControlCharacter(character)) continue - for (const stopped of open.splice(0)) destinationEnds[stopped] = index + for (const stopped of open.splice(0)) ends[stopped] = index open.push(index + 1) } - for (const stopped of open) destinationEnds[stopped] = text.length - return { destinationEnds, tails: new Map() } + for (const stopped of open) ends[stopped] = text.length + return ends } -function destinationEnd(text: string, start: number, memo: TargetMemo): number | undefined { +function destinationEnd(memo: TargetMemo, start: number): number | undefined { + const { text } = memo if (text.charAt(start) === ')') return start if (text.charAt(start) === '<') { bracketedDestination.lastIndex = start const bracketed = bracketedDestination.exec(text)?.[0] return bracketed === undefined ? undefined : start + bracketed.length } + memo.destinationEnds ??= destinationEnds(text) const end = memo.destinationEnds[start] ?? start return end > start ? end : undefined } diff --git a/src/markdown/directive/syntax.ts b/src/markdown/directive/syntax.ts index 1000036..de5c8f3 100644 --- a/src/markdown/directive/syntax.ts +++ b/src/markdown/directive/syntax.ts @@ -83,6 +83,11 @@ export function claimDirectivePrefix(text: string, index: number): ClaimedPrefix return claimsDirectivePrefix(text, index) ? { [claimedPrefix]: true, index } : undefined } +// Where a span's [content] starts, counted from its `!adf:`. +export function contentOffset(span: DirectiveSpan): number { + return directivePrefix.length + span.name.length + 1 +} + export function holdsDirectivePrefix(text: string): boolean { return text.includes(directivePrefix) } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index bda03dd..6c3fae5 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -17,7 +17,7 @@ import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts' import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts' import { labelLimit, normalizeLabel, readInlineTarget, readLabel, targetMemo } from '../commonmark/link-syntax.ts' import { matchEmphasis } from '../commonmark/emphasis-matching.ts' -import { noSpans, readInlineDirective } from '../directive/syntax.ts' +import { contentOffset, noSpans, readInlineDirective } from '../directive/syntax.ts' import { openingLinkTakesDirective } from '../emit/spelling-questions.ts' import { readCarriedInline } from '../opaque-carry.ts' import { readDirectiveMark } from '../directive/mark-reading.ts' @@ -81,17 +81,19 @@ type Scan = { inLink: boolean // Link openers below this index of `brackets` were open when a link closed, so they stay literal: no link holds another. linkOpenersFrom: number + // Where `source` starts in the outermost content, the text `targets` reads. + offset: number openingSpellableLink: boolean path: ConvertErrorPath pending: string pieces: Piece[] source: string spans: NestedSpans - targets: TargetMemo | undefined + targets: TargetMemo } // What a directive's content slot inherits from the content holding it. -type SharedScan = Pick +type SharedScan = Pick const ownMarksInMark = 'move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks' const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } @@ -103,7 +105,7 @@ const textBreak: TextBreak = { kind: 'textBreak' } // The outermost content: only here does a text break see both its neighbours. export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result { - const scan = freshScan(source, { definitions, path }, { claims: flavourClaims[flavour], container, inLink: false, spans: noSpans }) + const scan = freshScan(source, { definitions, path, targets: targetMemo(source) }, { claims: flavourClaims[flavour], container, inLink: false, offset: 0, spans: noSpans }) const scanned = scanInline(scan) if (!scanned.ok) return scanned const nodes = partText(contentNodes(scanned.value.pieces), scan) @@ -117,8 +119,8 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions, return success(image === undefined ? { nodes: nodes.value } : { image, nodes: nodes.value }) } -function freshScan(source: string, shared: SharedScan, own: Pick): Scan { - return { ...shared, ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], source, targets: undefined } +function freshScan(source: string, shared: SharedScan, own: Pick): Scan { + return { ...shared, ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], source } } function scanInline(scan: Scan): Result { @@ -212,7 +214,7 @@ function directivePieces(scan: Scan, span: DirectiveSpan, index: number): Result if (text !== undefined) return success([{ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] }]) const mark = readDirectiveMark(span.name, span.attributes, scan.path) // A node's slot is the node's own content, never the text of a link around the node. - const slot = slotContent(scan, span, mark !== undefined && (scan.inLink || span.name === 'link')) + const slot = slotContent(scan, span, index, mark !== undefined && (scan.inLink || span.name === 'link')) if (!slot.ok) return slot if (mark !== undefined) return mark.ok ? directiveMarkPieces(scan, span.name, mark.value, slot.value, index) : mark const content = slot.value === undefined ? undefined : contentNodes(slot.value) @@ -251,10 +253,11 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[] return undefined } -function slotContent(scan: Scan, span: DirectiveSpan, inLink: boolean): Result { +function slotContent(scan: Scan, span: DirectiveSpan, index: number, inLink: boolean): Result { if (span.content === undefined) return success(undefined) - const { claims, definitions, path } = scan - const parsed = scanInline(freshScan(span.content, { definitions, path }, { claims, container: undefined, inLink, spans: span.spans })) + const { claims, definitions, path, targets } = scan + const offset = scan.offset + index + contentOffset(span) + const parsed = scanInline(freshScan(span.content, { definitions, path, targets }, { claims, container: undefined, inLink, offset, spans: span.spans })) if (!parsed.ok) return parsed return success(parsed.value.pieces) } @@ -429,9 +432,9 @@ function bracketNodes(bracket: Bracket | OpenBracket): AdfNode[] { function resolveTarget(scan: Scan, start: number, index: number): InlineTarget | undefined { const after = index + 1 if (scan.source.charAt(after) === '(') { - scan.targets ??= targetMemo(scan.source) - const inline = readInlineTarget(scan.source, after, scan.targets) - if (inline !== undefined) return inline + const inline = readInlineTarget(scan.targets, scan.offset + after) + // A target read in the outermost content holds in a directive's content exactly when it ends inside it. + if (inline !== undefined && after + inline.length <= scan.source.length) return inline } const label = scan.source.charAt(after) === '[' ? readLabel(scan.source, after) : undefined const labelIsText = label === undefined || label.value === '' diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 7c3ed19..0ded5fa 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -1038,6 +1038,13 @@ test('leaves the bracket pair no link parses as the text it holds', () => { ]) }) +test('leaves a link literal whose target runs past the directive content holding it', () => { + assert.deepEqual(firstContent('!adf:underline[[a](b]c)\n'), [marked('[a](b', underline), text('c)')]) + assert.deepEqual(firstContent('!adf:underline[[a]()\n'), [marked('[a]()')]) + assert.deepEqual(firstContent('!adf:underline[[a](b "t]")\n'), [marked('[a](b "t', underline), text('")')]) + assert.deepEqual(firstContent('!adf:underline[[a](b]) [c](d)\n'), [marked('[a](b', underline), text(') '), marked('c', link('d'))]) +}) + test('leaves the brackets of a link whose text already holds one the text they are', () => { const held: AdfMark = { attrs: { collection: 'c', href: '/u' }, type: 'link' } assert.deepEqual(content(losslessMarkdownToAdf('[](/v)\n')), [