This commit is contained in:
@@ -1,69 +1,269 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct } from '../commonmark-grammar.ts'
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { LinkDefinition } from '../link-syntax.ts'
|
||||
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from '../commonmark-grammar.ts'
|
||||
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts'
|
||||
import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
|
||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||
|
||||
type Run = { nodes: AdfNode[]; text: string; undecodedFrom: number }
|
||||
export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] }
|
||||
|
||||
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
|
||||
|
||||
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
|
||||
|
||||
type Piece = Bracket | { kind: 'nodes'; nodes: AdfNode[] } | { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
|
||||
|
||||
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number }
|
||||
|
||||
type Scan = { definitions: LinkDefinitions; image: AdfNode | undefined; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string }
|
||||
|
||||
const emphasisCharacters = '*_~'
|
||||
const hardBreakSpaces = / {2,}$/
|
||||
const imageAlone = 'an image fits only as a paragraph of its own'
|
||||
const trailingSpace = /[ \t]+$/
|
||||
|
||||
export function parseInlineContent(source: string, path: ConvertErrorPath): Result<AdfNode[]> {
|
||||
const run: Run = { nodes: [], text: '', undecodedFrom: 0 }
|
||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<InlineContent> {
|
||||
const scan: Scan = { definitions, image: undefined, path, pending: '', pieces: [], source }
|
||||
let index = 0
|
||||
while (index < source.length) {
|
||||
const character = source.charAt(index)
|
||||
if (character === '\\' && source.charAt(index + 1) === '\n') {
|
||||
// CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash.
|
||||
takeRun(run, source, index, index + 2, false)
|
||||
pushNode(run, { type: 'hardBreak' })
|
||||
flush(scan, false)
|
||||
pushNode(scan, { type: 'hardBreak' })
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (backslashEscape(source, index) !== undefined) {
|
||||
scan.pending += source.slice(index, index + 2)
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (character === '\n') {
|
||||
const hard = hardBreakSpaces.test(source.slice(run.undecodedFrom, index))
|
||||
takeRun(run, source, index, index + 1, true)
|
||||
if (hard) pushNode(run, { type: 'hardBreak' })
|
||||
else run.text += ' '
|
||||
const hard = hardBreakSpaces.test(scan.pending)
|
||||
flush(scan, true)
|
||||
if (hard) pushNode(scan, { type: 'hardBreak' })
|
||||
else scan.pending = ' '
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (character === '`') {
|
||||
const span = readCodeSpan(source, index)
|
||||
if (span === undefined) {
|
||||
index += backtickRun(source, index)
|
||||
const run = backtickRun(source, index)
|
||||
scan.pending += source.slice(index, index + run)
|
||||
index += run
|
||||
continue
|
||||
}
|
||||
takeRun(run, source, index, span.end, false)
|
||||
pushNode(run, { marks: [{ type: 'code' }], text: span.text, type: 'text' })
|
||||
flush(scan, false)
|
||||
pushNode(scan, { marks: [{ type: 'code' }], text: span.text, type: 'text' })
|
||||
index = span.end
|
||||
continue
|
||||
}
|
||||
if (character === '<') {
|
||||
const autolink = readAutolink(source, index)
|
||||
if (autolink !== undefined) {
|
||||
flush(scan, false)
|
||||
pushNode(scan, autolink.node)
|
||||
index += autolink.length
|
||||
continue
|
||||
}
|
||||
const construct = inlineHtmlConstruct(source, index)
|
||||
if (construct !== undefined) return failure('unmappable-html', `no ADF node carries ${construct}`, path)
|
||||
}
|
||||
index += backslashEscape(source, index) === undefined ? 1 : 2
|
||||
if (character === '[' || (character === '!' && source.charAt(index + 1) === '[')) {
|
||||
const image = character === '!'
|
||||
const width = image ? 2 : 1
|
||||
flush(scan, false)
|
||||
scan.pieces.push({ active: true, image, kind: 'open', start: index + width })
|
||||
index += width
|
||||
continue
|
||||
}
|
||||
if (character === ']') {
|
||||
const closed = closeBracket(scan, index)
|
||||
if (!closed.ok) return closed
|
||||
index = closed.value
|
||||
continue
|
||||
}
|
||||
if (emphasisCharacters.includes(character)) {
|
||||
index = readDelimiterRun(scan, index)
|
||||
continue
|
||||
}
|
||||
scan.pending += character
|
||||
index += 1
|
||||
}
|
||||
takeRun(run, source, source.length, source.length, true)
|
||||
pushText(run)
|
||||
return success(run.nodes)
|
||||
flush(scan, true)
|
||||
return assemble(scan)
|
||||
}
|
||||
|
||||
function takeRun(run: Run, source: string, end: number, resume: number, strip: boolean): void {
|
||||
const raw = source.slice(run.undecodedFrom, end)
|
||||
run.text += decodeTextEscapes(strip ? raw.replace(trailingSpace, '') : raw)
|
||||
run.undecodedFrom = resume
|
||||
function flush(scan: Scan, strip: boolean): void {
|
||||
const raw = strip ? scan.pending.replace(trailingSpace, '') : scan.pending
|
||||
scan.pending = ''
|
||||
if (raw !== '') scan.pieces.push({ kind: 'nodes', nodes: [{ text: decodeTextEscapes(raw), type: 'text' }] })
|
||||
}
|
||||
|
||||
function pushText(run: Run): void {
|
||||
if (run.text !== '') run.nodes.push({ text: run.text, type: 'text' })
|
||||
run.text = ''
|
||||
function pushNode(scan: Scan, node: AdfNode): void {
|
||||
scan.pieces.push({ kind: 'nodes', nodes: [node] })
|
||||
}
|
||||
|
||||
function pushNode(run: Run, node: AdfNode): void {
|
||||
pushText(run)
|
||||
run.nodes.push(node)
|
||||
function assemble(scan: Scan): Result<InlineContent> {
|
||||
if (scan.image !== undefined) {
|
||||
if (scan.pieces.length > 0) return failure('unmappable-image', imageAlone, scan.path)
|
||||
return success({ image: scan.image })
|
||||
}
|
||||
return success({ nodes: resolveNodes(scan.pieces) })
|
||||
}
|
||||
|
||||
function readDelimiterRun(scan: Scan, index: number): number {
|
||||
const character = scan.source.charAt(index)
|
||||
const length = runLength(scan.source, index)
|
||||
const flags = delimiterFlags(character, scan.source.charAt(index - 1), scan.source.charAt(index + length))
|
||||
// spec/flavour.md: strike is exactly two tildes.
|
||||
if ((character === '~' && length !== 2) || (!flags.canOpen && !flags.canClose)) scan.pending += scan.source.slice(index, index + length)
|
||||
else {
|
||||
flush(scan, false)
|
||||
scan.pieces.push({ canClose: flags.canClose, canOpen: flags.canOpen, character, kind: 'run', length })
|
||||
}
|
||||
return index + length
|
||||
}
|
||||
|
||||
function readAutolink(source: string, index: number): { length: number; node: AdfNode } | undefined {
|
||||
const bracketed = readBracketedAutolink(source, index)
|
||||
if (bracketed !== undefined) return { length: bracketed, node: linkedText(source.slice(index + 1, index + bracketed - 1), '') }
|
||||
const email = readEmailAutolink(source, index)
|
||||
if (email === undefined) return undefined
|
||||
return { length: email, node: linkedText(source.slice(index + 1, index + email - 1), 'mailto:') }
|
||||
}
|
||||
|
||||
function linkedText(text: string, scheme: string): AdfNode {
|
||||
return { marks: [{ attrs: { href: `${scheme}${text}` }, type: 'link' }], text, type: 'text' }
|
||||
}
|
||||
|
||||
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) {
|
||||
scan.pieces[open.index] = { kind: 'nodes', nodes: bracketNodes(open.bracket) }
|
||||
return success(literalClose(scan, index))
|
||||
}
|
||||
const inner = scan.pieces.splice(open.index).slice(1)
|
||||
if (open.bracket.image) {
|
||||
const built = buildImage(scan, inner, target.definition)
|
||||
if (!built.ok) return built
|
||||
} else buildLink(scan, inner, target.definition)
|
||||
return success(index + 1 + target.length)
|
||||
}
|
||||
|
||||
function literalClose(scan: Scan, index: number): number {
|
||||
scan.pending += ']'
|
||||
return index + 1
|
||||
}
|
||||
|
||||
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 {
|
||||
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 definition = scan.definitions.get(normalizeLabel(name))
|
||||
if (definition === undefined) return undefined
|
||||
return { definition, length: label?.length ?? 0 }
|
||||
}
|
||||
|
||||
function buildLink(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): void {
|
||||
const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title }
|
||||
const nodes = applyMark(resolveNodes(inner), { attrs, type: 'link' })
|
||||
// CommonMark: no link nests inside another.
|
||||
for (const piece of scan.pieces) if (piece.kind === 'open') piece.active = false
|
||||
scan.pieces.push({ kind: 'nodes', nodes })
|
||||
}
|
||||
|
||||
function buildImage(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
|
||||
if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path)
|
||||
if (scan.pieces.length > 0 || scan.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
||||
const alt = resolveNodes(inner)
|
||||
.map((node) => node.text ?? '')
|
||||
.join('')
|
||||
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
|
||||
scan.image = { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' }
|
||||
return success(null)
|
||||
}
|
||||
|
||||
function resolveNodes(pieces: readonly Piece[]): AdfNode[] {
|
||||
const nodes = pieces.map((piece) => (piece.kind === 'nodes' ? piece.nodes : piece.kind === 'open' ? bracketNodes(piece) : []))
|
||||
const runs: Run[] = []
|
||||
for (const [index, piece] of pieces.entries()) {
|
||||
if (piece.kind === 'run') runs.push({ canClose: piece.canClose, canOpen: piece.canOpen, character: piece.character, index, length: piece.length })
|
||||
}
|
||||
const pairings = matchEmphasis(runs)
|
||||
const heads = new Map<Run, number>()
|
||||
const tails = new Map<Run, number>()
|
||||
for (const pairing of pairings) {
|
||||
heads.set(pairing.closer, pairing.closerOffset + pairing.used)
|
||||
tails.set(pairing.opener, pairing.openerOffset)
|
||||
}
|
||||
for (const run of runs) {
|
||||
const head = heads.get(run) ?? 0
|
||||
const tail = tails.get(run) ?? run.length
|
||||
if (tail > head) nodes[run.index] = [{ text: run.character.repeat(tail - head), type: 'text' }]
|
||||
}
|
||||
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) {
|
||||
nodes[index] = applyMark(nodes[index] ?? [], mark)
|
||||
}
|
||||
}
|
||||
return mergeText(nodes.flat())
|
||||
}
|
||||
|
||||
function markType(character: string, used: number): string {
|
||||
if (character === '~') return 'strike'
|
||||
return used === 2 ? 'strong' : 'em'
|
||||
}
|
||||
|
||||
// CommonMark nests a spelling inside its own kind (`*(*a*)*`); the mark it names is idempotent, and
|
||||
// a node carrying it twice is the shape AGENTS.md §14 has the emitter refuse.
|
||||
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
|
||||
return nodes.map((node) => {
|
||||
const marks = node.marks ?? []
|
||||
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
|
||||
})
|
||||
}
|
||||
|
||||
// Editor-normal (AGENTS.md §2): adjacent text nodes carrying identical marks are one node.
|
||||
function mergeText(nodes: readonly AdfNode[]): AdfNode[] {
|
||||
const merged: AdfNode[] = []
|
||||
for (const node of nodes) {
|
||||
const previous = merged[merged.length - 1]
|
||||
if (previous !== undefined && previous.type === 'text' && node.type === 'text' && markKey(previous) === markKey(node)) {
|
||||
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
|
||||
continue
|
||||
}
|
||||
merged.push(node)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function markKey(node: AdfNode): string {
|
||||
return (node.marks ?? []).map((mark) => `${mark.type}${serializeCanonicalJson(mark.attrs ?? {}, 'compact')}`).join(' ')
|
||||
}
|
||||
|
||||
function readCodeSpan(source: string, index: number): { end: number; text: string } | undefined {
|
||||
|
||||
Reference in New Issue
Block a user