mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-13 12:18:28 +01:00
Answer stability round 1: 1 🔴, 1 🟡 (0 misread); nested directive contents share their paragraph's memo, and a title after <destination> needs whitespace, pinned
This commit is contained in:
@@ -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](<b>"t")'), [undefined])
|
||||
})
|
||||
|
||||
@@ -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<number, Tail | undefined> }
|
||||
// What the targets read in `text` share; `destinationEnds` is walked at the first one.
|
||||
export type TargetMemo = { destinationEnds: Int32Array | undefined; tails: Map<number, Tail | undefined>; 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Scan, 'definitions' | 'path'>
|
||||
type SharedScan = Pick<Scan, 'definitions' | 'path' | 'targets'>
|
||||
|
||||
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<InlineContent> {
|
||||
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, 'claims' | 'container' | 'inLink' | 'spans'>): Scan {
|
||||
return { ...shared, ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], source, targets: undefined }
|
||||
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'claims' | 'container' | 'inLink' | 'offset' | 'spans'>): Scan {
|
||||
return { ...shared, ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], source }
|
||||
}
|
||||
|
||||
function scanInline(scan: Scan): Result<Scanned> {
|
||||
@@ -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<Resolved[] | undefined> {
|
||||
function slotContent(scan: Scan, span: DirectiveSpan, index: number, inLink: boolean): Result<Resolved[] | undefined> {
|
||||
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 === ''
|
||||
|
||||
@@ -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](<b]>)\n'), [marked('[a](<b', underline), text('>)')])
|
||||
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('[<http://x/>](/v)\n')), [
|
||||
|
||||
Reference in New Issue
Block a user