3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-14 20:40:25 +01:00

Answer architecture round 1: 0 🔴, 7 🟡 (0 misread); the read-back loop parts from the verdict, the reader takes the opening-link consult, the reading is absent on the parse path, a refused link escapes no backtick; root imports, highlight pairing and closeBracket filed as items 153 to 155

This commit is contained in:
2026-10-28 14:42:09 +01:00
committed by Lillem4n
parent b0bf362ef9
commit 5ede92bf91
8 changed files with 91 additions and 45 deletions
@@ -351,6 +351,11 @@ test('escapes every text bracket in a line where one opens link text, and every
assert.equal(emitted('Use ` or ``code`` syntax'), 'Use \\` or \\`\\`code\\`\\` syntax\n')
})
test('keeps a text backtick bare in a line where a text bracket opens a link the reader refuses', () => {
const refused = paragraph({ text: 'a ` [', type: 'text' }, { attrs: { id: '1' }, type: 'mention' }, { text: '](u)', type: 'text' })
assert.equal(markdown(adfToLosslessMarkdown(document(refused))), 'a ` \\[!adf:mention{id=1}](u)\n')
})
test('escapes a heading closing sequence', () => {
const heading = (text: string): string => markdown(adfToLosslessMarkdown(document({ attrs: { level: 2 }, content: [{ text, type: 'text' }], type: 'heading' })))
assert.equal(heading('done #'), '## done \\#\n')
+23 -7
View File
@@ -35,6 +35,9 @@ type Escapings = (index: number) => InlineEscaping | undefined
// Where a segment's first character landed in the output.
type PlacedSegment = { segment: InlineSegment; start: number }
// The escaped line once reading it back asks for no further escape.
type ReadBack = { output: string; placed: PlacedSegment[]; reading: InlineReading }
// Which of a run's characters escape: every escapable one, or every one before `end`.
type RunEscapes = { end: number; escapable: false } | { escapable: true }
@@ -87,6 +90,11 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
}
function escape(segments: readonly InlineSegment[], container: LineContainer, claims: Claims): AssembledLine {
const { output, placed, reading } = readBack(segments, container, claims)
return { ...guardLinkDefinition(output, placed, container), unspellableRuns: unspellableRuns(placed, reading) }
}
function readBack(segments: readonly InlineSegment[], container: LineContainer, claims: Claims): ReadBack {
const scan = segments.map((segment) => segment.text).join('')
// One byte a character, never an array element or Set entry: a line may run to hundreds of millions of characters.
const codes = new Uint8Array(scan.length)
@@ -105,18 +113,26 @@ function escape(segments: readonly InlineSegment[], container: LineContainer, cl
for (;;) {
const { output, placed } = backslashed(segments, scan, withTextEscapes(scan, escapings, claimed, escapes))
const { faulted, reading } = readLineBack(output, container, claims)
const opened = faulted ? { backticks: true, brackets: true } : textOpeners(placed, output.length, reading)
const shown = textOpeners(placed, output.length, reading)
// A code span a directive slot swallowed leaves no record before the directive fault.
const opened = faulted && !shown.backticks && !shown.brackets ? { backticks: true, brackets: true } : shown
const next = { backticks: escapes.backticks || opened.backticks, brackets: escapes.brackets || opened.brackets }
if (next.backticks === escapes.backticks && next.brackets === escapes.brackets) {
const unspellable = unspellableRuns(placed, reading)
if (container !== 'paragraph' || !opensLinkDefinition(output)) return { line: output, unspellableRuns: unspellable }
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRuns: [] }
return { line: `\\${output}`, unspellableRuns: unspellable }
}
if (next.backticks === escapes.backticks && next.brackets === escapes.brackets) return { output, placed, reading }
escapes = next
}
}
// spec/flavour.md, Canonical form: a paragraph opening with what reads as a link reference definition escapes its `[`, or spells the emitter's opening link as a directive.
function guardLinkDefinition(output: string, placed: readonly PlacedSegment[], container: LineContainer): { line: string; openingLinkAsDirective?: true } {
if (container !== 'paragraph' || !opensLinkDefinition(output)) return { line: output }
return opensWithLink(placed) ? { line: output, openingLinkAsDirective: true } : { line: `\\${output}` }
}
// Only the emitter's link opener is a `[` segment spelling nodes.
function opensWithLink([first]: readonly PlacedSegment[]): boolean {
return first?.segment.nodes !== undefined && first.segment.text === '['
}
function withTextEscapes(scan: string, escapings: Escapings, claimed: Uint8Array, { backticks, brackets }: TextEscapes): Uint8Array {
if (!backticks && !brackets) return claimed
const escaped = claimed.slice()
+26 -16
View File
@@ -69,7 +69,7 @@ type Piece =
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number; offset: number }
| { closes: boolean; kind: 'highlight'; offset: number; opens: boolean }
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number; offset: number }
type Run = Extract<Piece, { kind: 'run' }> & { index: number }
// What the reader took where, by offset into the outermost content, so a writer can check its line reads as it meant.
export type InlineReading = {
@@ -81,6 +81,8 @@ export type InlineReading = {
readonly links: number[]
}
type ReadingEntry = { at: number; closer: number; kind: 'emphasis'; width: number } | { at: number; closer: number; kind: 'highlights' } | { at: number; kind: 'codeSpans' | 'links' }
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
type Scan = {
// CommonMark's bracket stack, innermost last.
@@ -98,7 +100,8 @@ type Scan = {
path: ConvertErrorPath
pending: string
pieces: Piece[]
reading: InlineReading
// Only a read-back takes a reading; parsing records none.
reading: InlineReading | undefined
source: string
spans: NestedSpans
targets: TargetMemo
@@ -113,30 +116,37 @@ const hreflessLink = 'the link mark spells its href: this one spells none'
const noDefinitions: LinkDefinitions = new Map()
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
const unlinkable = 'the text of this link holds only nodes that take no link: write the link around text beside them'
export const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
const textBreak: TextBreak = { kind: 'textBreak' }
// The outermost content: only here does a text break see both its neighbours. Whether an opening link CommonMark spells may take the directive form is the emitter's to answer.
export function readInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent & { openingSpellableLink: boolean }> {
const scan = freshScan(source, { definitions, path, reading: emptyReading(), targets: targetMemo(source) }, { claims: flavourClaims[flavour], container, inLink: false, offset: 0, spans: noSpans })
// The outermost content: only here does a text break see both its neighbours. Whether an opening link CommonMark spells may take the directive form is the emitter's to answer, so the caller passes its answer in.
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour, openingLinkTakesDirective: (nodes: readonly AdfNode[], path: ConvertErrorPath) => Result<boolean>): Result<InlineContent> {
const scan = freshScan(source, { definitions, path, reading: undefined, 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)
if (!nodes.ok) return nodes
if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, path)
if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
}
const { image } = scanned.value
const { openingSpellableLink } = scan
return success(image === undefined ? { nodes: nodes.value, openingSpellableLink } : { image, nodes: nodes.value, openingSpellableLink })
return success(image === undefined ? { nodes: nodes.value } : { image, nodes: nodes.value })
}
// A line that fails to read still holds what the reader took before the fault.
export function readLineBack(line: string, container: LineContainer, claims: Claims): { faulted: boolean; reading: InlineReading } {
const reading = emptyReading()
const reading: InlineReading = { codeSpans: [], emphasis: new Map(), highlights: new Map(), links: [] }
const scan = freshScan(line, { definitions: noDefinitions, path: [], reading, targets: targetMemo(line) }, { claims, container, inLink: false, offset: 0, spans: noSpans })
return { faulted: !scanInline(scan).ok, reading }
}
function emptyReading(): InlineReading {
return { codeSpans: [], emphasis: new Map(), highlights: new Map(), links: [] }
function record({ reading }: Pick<Scan, 'reading'>, entry: ReadingEntry): void {
if (reading === undefined) return
if (entry.kind === 'emphasis') reading.emphasis.set(entry.at, { closer: entry.closer, width: entry.width })
else if (entry.kind === 'highlights') reading.highlights.set(entry.at, entry.closer)
else reading[entry.kind].push(entry.at)
}
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'claims' | 'container' | 'inLink' | 'offset' | 'spans'>): Scan {
@@ -173,7 +183,7 @@ function readToken(scan: Scan, inline: InlineSource, index: number, token: Inlin
return closeBracket(scan, index)
case 'code-span':
flush(scan, false)
scan.reading.codeSpans.push(scan.offset + index)
record(scan, { at: scan.offset + index, kind: 'codeSpans' })
pushNode(scan, { marks: [{ type: 'code' }], text: codeSpanText(scan.source.slice(index + token.opener, end - token.opener)), type: 'text' })
return success(end)
case 'delimiter-run':
@@ -420,7 +430,7 @@ function closeBracket(scan: Scan, index: number): Result<number> {
if (!open.image) scan.linkOpenersFrom = scan.brackets.length
// Before `linkPieces`' own-marks guard: brackets that go literal mark nothing, so a carry inside them is no error.
if (!open.image && holdsLink(inner)) return success(unopened(scan, open, index))
scan.reading.links.push(scan.offset + open.start - 1)
record(scan, { at: scan.offset + open.start - 1, kind: 'links' })
const closed = open.image ? imagePieces(scan, inner, target.definition()) : linkPieces(scan, inner, target.definition())
if (!closed.ok) return closed
scan.pieces.length = open.index
@@ -512,11 +522,11 @@ function markedNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean):
const nodes = pieces.map(pieceNodes)
const runs = delimiterRuns(pieces)
const pairings = matchEmphasis(runs)
for (const { closer, closerOffset, opener, openerOffset, used } of pairings) scan.reading.emphasis.set(opener.offset + openerOffset, { closer: closer.offset + closerOffset, width: used })
for (const { closer, closerOffset, opener, openerOffset, used } of pairings) record(scan, { at: opener.offset + openerOffset, closer: closer.offset + closerOffset, kind: 'emphasis', width: used })
writeUnpaired(nodes, runs, pairings)
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
const paired = highlights ? pairedHighlights(pieces, nodes) : []
for (const { closerOffset, openerOffset } of paired) scan.reading.highlights.set(openerOffset, closerOffset)
for (const { closerOffset, openerOffset } of paired) record(scan, { at: openerOffset, closer: closerOffset, kind: 'highlights' })
markHighlights(pieces, nodes, paired)
return success(nodes)
}
@@ -563,7 +573,7 @@ function pieceNodes(piece: Piece): Inline[] {
function delimiterRuns(pieces: readonly Piece[]): Run[] {
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, offset: piece.offset })
if (piece.kind === 'run') runs.push({ ...piece, index })
}
return runs
}
+4 -17
View File
@@ -2,9 +2,8 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from '../directive/node-reading.ts'
import type { ConvertFault } from '../../result.ts'
import type { Flavour } from '../flavour.ts'
import type { InlineContent, LinkDefinitions } from '../inline-content.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinitions } from '../inline-content.ts'
import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling, openingLinkTakesDirective, type SpellingMemo } from '../emit/spelling-questions.ts'
import { documentAttribute, documentName, documentSpelling } from '../directive/block.ts'
@@ -15,7 +14,7 @@ import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { notAString } from '../../not-a-string.ts'
import { parseBlocks } from './blocks.ts'
import { readInlineContent, spellableLink } from '../inline-content.ts'
import { parseInlineContent } from '../inline-content.ts'
import { readBlockDirectiveNode } from '../directive/node-reading.ts'
import { spellStringAttribute, unsupportedNodeShape } from '../directive/syntax.ts'
import { spellsEmpty } from '../directive/empty-keys.ts'
@@ -247,26 +246,14 @@ function codeBlockNode(language: string, text: string, reading: Reading, path: C
// spec/flavour.md, The CommonMark image: only a `paragraph` block gives a lone image the block it needs.
function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour)
const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour, openingLinkTakesDirective)
if (!content.ok) return content
const image = content.value.image
return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image)
}
export function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour)
const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour, openingLinkTakesDirective)
if (!content.ok) return content
return success(withContent(node, content.value.nodes))
}
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
const read = readInlineContent(source, definitions, path, container, flavour)
if (!read.ok) return read
const { image, nodes, openingSpellableLink } = read.value
if (openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes, path)
if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
}
return success(image === undefined ? { nodes } : { image, nodes })
}
@@ -2,10 +2,12 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block } from '../parse/blocks.ts'
import type { ConvertError, ConvertErrorPath, ParseError, Result, SourcePosition } from '../../result.ts'
import type { FlavourBlockReaders, FlavourReading } from '../parse/markdown-to-adf.ts'
import { containerNode, contentNode, listNode, parseInlineContent, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts'
import { containerNode, contentNode, listNode, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts'
import { largestNesting } from '../../nesting.ts'
import { leadingMarker, readAlertMarker, readTaskMarker } from './conventions.ts'
import { mintTaskIds } from './task-ids.ts'
import { openingLinkTakesDirective } from '../emit/spelling-questions.ts'
import { parseInlineContent } from '../inline-content.ts'
import { pathTo, splitTaskList } from './task-list.ts'
import { positioned, success } from '../../result.ts'
import { withinEngineLimits } from '../../length-bound.ts'
@@ -55,7 +57,7 @@ function quoteReader(foldedType: 'expand' | 'nestedExpand'): FlavourBlockReaders
const { folded, panelType } = led.marker
const { line, rest } = markerLine(led.text)
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1))
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour)
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour, openingLinkTakesDirective)
if (!title.ok) return title
const text = titleText(title.value.nodes)
return filledNode(text === '' ? { type: foldedType } : { attrs: { title: text }, type: foldedType }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, blocks: expandBlockReaders }, path, depth + 1))