3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 19:58:28 +01:00

Read emphasis and link brackets in time linear in the inline markdown

This commit is contained in:
2026-10-23 10:51:47 +02:00
committed by Lillem4n
parent 5d301b7032
commit 759c522767
+58 -70
View File
@@ -38,7 +38,7 @@ type Scanned = { image?: AdfNode; pieces: Resolved[] }
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
type Bracket = { image: boolean; kind: 'open' }
// `link` is the link whose text is this image alone.
type Image = { alt: string; definition: LinkDefinition; kind: 'image'; link?: LinkDefinition }
@@ -52,6 +52,9 @@ type Resolved = ImageLink | Nodes
type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; opens: boolean; position: number; stretch: number }
// `index` is the bracket's piece, `start` the index of the source its text starts at.
type OpenBracket = { image: boolean; index: number; start: number }
type Pairing = EmphasisPairing<Run>
type Piece =
@@ -68,13 +71,15 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
type Scan = {
// CommonMark's bracket stack, innermost last.
brackets: OpenBracket[]
claims: Claims
container: LineContainer | undefined
// Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed.
deactivatedBefore: number
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.
linkOpenersFrom: number
openingSpellableLink: boolean
path: ConvertErrorPath
pending: string
@@ -111,7 +116,7 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions,
}
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'claims' | 'container' | 'inLink' | 'spans'>): Scan {
return { ...shared, ...own, deactivatedBefore: 0, openingSpellableLink: false, pending: '', pieces: [], source }
return { ...shared, ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], source }
}
function scanInline(scan: Scan): Result<Scanned> {
@@ -137,7 +142,8 @@ function readToken(scan: Scan, inline: InlineSource, index: number, token: Inlin
return success(end)
case 'bracket':
flush(scan, false)
scan.pieces.push({ active: true, image: token.image, kind: 'open', start: end })
scan.brackets.push({ image: token.image, index: scan.pieces.length, start: end })
scan.pieces.push({ image: token.image, kind: 'open' })
return success(end)
case 'bracket-close':
return closeBracket(scan, index)
@@ -364,7 +370,7 @@ function holdsOwnMarks(item: Inline | Piece): boolean {
return 'kind' in item && item.kind === 'ownMarks'
}
// 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.
// 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 `linkPieces` refuses it.
function holdsLink(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'imageLink' || (piece.kind === 'nodes' && marksLink(piece.nodes)))
}
@@ -379,19 +385,30 @@ function linkedText(text: string, scheme: string): AdfNode {
function closeBracket(scan: Scan, index: number): Result<number> {
flush(scan, false)
const open = lastBracket(scan.pieces)
if (open === undefined) return success(literalClose(scan, index))
const target = open.bracket.active ? resolveTarget(scan, open.bracket, index) : undefined
if (target === undefined) return success(unopened(scan, open, index))
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))
const inner = scan.pieces.slice(open.index + 1)
if (open.bracket.image) {
const closed = closeImage(scan, open.index, inner, target.definition)
if (!closed.ok) return closed
return success(index + 1 + target.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))
}
const closed = closeLink(scan, open.index, inner, target.definition)
const closed = open.image ? imagePieces(scan, inner, target.definition) : linkPieces(scan, inner, target.definition)
if (!closed.ok) return closed
return success(closed.value ? index + 1 + target.length : unopened(scan, open, index))
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.
function popBracket(scan: Scan): (OpenBracket & { active: boolean }) | undefined {
const open = scan.brackets.pop()
if (open === undefined) return undefined
const active = open.image || scan.brackets.length >= scan.linkOpenersFrom
scan.linkOpenersFrom = Math.min(scan.linkOpenersFrom, scan.brackets.length)
return { ...open, active }
}
function literalClose(scan: Scan, index: number): number {
@@ -399,53 +416,26 @@ function literalClose(scan: Scan, index: number): number {
return index + 1
}
function unopened(scan: Scan, open: { bracket: Bracket; index: number }, index: number): number {
scan.pieces[open.index] = { kind: 'nodes', nodes: bracketNodes(open.bracket) }
return literalClose(scan, index)
}
function bracketNodes(bracket: Bracket): AdfNode[] {
return [{ text: bracket.image ? '![' : '[', type: 'text' }]
}
function lastBracket(pieces: readonly Piece[]): { bracket: Bracket; index: number } | undefined {
for (let index = pieces.length - 1; index >= 0; index -= 1) {
const piece = pieces[index]
if (piece?.kind === 'open') return { bracket: piece, index }
}
return undefined
}
function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definition: LinkDefinition; length: number } | undefined {
function resolveTarget(scan: Scan, start: number, index: number): { definition: LinkDefinition; length: number } | undefined {
const after = index + 1
if (scan.source.charAt(after) === '(') {
const inline = readInlineTarget(scan.source, after)
if (inline !== undefined) return { definition: inline.definition, length: inline.length }
}
const label = scan.source.charAt(after) === '[' ? readLabel(scan.source, after) : undefined
const name = label === undefined || label.value === '' ? scan.source.slice(bracket.start, index) : label.value
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
return { definition, length: label?.length ?? 0 }
}
function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<boolean> {
// Ahead of the guard below: brackets going literal put the carry inside no mark for it to refuse.
if (holdsLink(inner)) {
deactivateOpeners(scan, at)
return success(false)
}
if (inner.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
const pieces = linkPieces(scan, inner, definition)
if (!pieces.ok) return pieces
truncatePieces(scan, at)
deactivateOpeners(scan, at)
for (const piece of pieces.value) scan.pieces.push(piece)
return success(true)
}
// An image alone in the link text keeps its piece, so a paragraph holding nothing else builds the linked image.
function linkPieces(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): Result<Piece[]> {
if (inner.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
const [only] = inner
if (inner.length === 1 && only?.kind === 'image') return success([{ ...only, link: definition }])
const nodes = resolveNodes(inner, scan, true)
@@ -457,26 +447,9 @@ function linkPieces(scan: Scan, inner: readonly Piece[], definition: LinkDefinit
return success([{ kind: 'nodes', nodes: linkTo(nodes.value, definition) }, ...besideLink(images, definition)])
}
// CommonMark: no link nests inside another, though an image's description holds one.
function deactivateOpeners(scan: Scan, before: number): void {
for (let index = scan.deactivatedBefore; index < before; index += 1) {
const piece = scan.pieces[index]
if (piece?.kind === 'open' && !piece.image) piece.active = false
}
scan.deactivatedBefore = before
}
function truncatePieces(scan: Scan, to: number): void {
scan.pieces.length = to
scan.deactivatedBefore = Math.min(scan.deactivatedBefore, to)
}
function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
function imagePieces(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): Result<Piece[]> {
const alt = imageAlt(inner, scan)
if (!alt.ok) return alt
truncatePieces(scan, at)
scan.pieces.push({ alt: alt.value, definition, kind: 'image' })
return success(null)
return alt.ok ? success([{ alt: alt.value, definition, kind: 'image' }]) : alt
}
function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> {
@@ -500,6 +473,7 @@ function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean)
return nodes.ok ? success(mergeReadText(nodes.value.flat())) : nodes
}
// `nodes[index]` holds piece `index`'s nodes, which each step below rewrites in place.
function markedNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean): Result<Inline[][]> {
const nodes = pieces.map(pieceNodes)
const runs = delimiterRuns(pieces)
@@ -572,13 +546,27 @@ function writeUnpaired(nodes: Inline[][], runs: readonly Run[], pairings: readon
}
}
// Innermost pairing first, so prepending leaves the marks array outermost first (spec/flavour.md, Marks).
// 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).
function markPairings(pieces: readonly Piece[], nodes: Inline[][], pairings: readonly Pairing[]): boolean {
const opening = new Map<number, Pairing[]>()
for (const pairing of pairings) {
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) {
if (pieces[index]?.kind === 'ownMarks') return false
nodes[index] = applyMark(nodes[index] ?? [], mark)
const opened = opening.get(pairing.opener.index)
if (opened === undefined) opening.set(pairing.opener.index, [pairing])
else opened.push(pairing)
}
const open: { closer: number; marks: AdfMark[] }[] = []
for (const [index, piece] of pieces.entries()) {
while (open.at(-1)?.closer === index) open.pop()
const marks = open.at(-1)?.marks
if (marks !== undefined) {
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()) {
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 }] })
}
}
return true