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

Answer architecture round 1: 0 🔴, 11 🟡 (0 misread); one line policy, one reduction run, named sweep stages, spelling memo, block text, link options, HTML refusals

This commit is contained in:
2026-11-08 20:30:08 +01:00
committed by Lillem4n
parent 8e6d7efff1
commit e9de121425
20 changed files with 329 additions and 215 deletions
+10 -5
View File
@@ -109,14 +109,19 @@ function heldTextLength(nodes: readonly AdfNode[]): number {
}
export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
const merged: AdfNode[] = []
for (const node of nodes) {
return mergedTextRuns(nodes, joins).map(({ node }) => node)
}
// `first`: the index of the first of the nodes merged into `node`.
export function mergedTextRuns(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): { first: number; node: AdfNode }[] {
const merged: { first: number; node: AdfNode }[] = []
for (const [index, node] of nodes.entries()) {
const previous = merged[merged.length - 1]
if (previous !== undefined && joins(previous, node)) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
if (previous !== undefined && joins(previous.node, node)) {
previous.node = { ...previous.node, text: `${previous.node.text ?? ''}${node.text ?? ''}` }
continue
}
merged.push(node)
merged.push({ first: index, node })
}
return merged
}
+13 -17
View File
@@ -6,35 +6,31 @@ import { nodeAttrs, nodeContent } from '../adf/document.ts'
import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../adf/shown-text.ts'
import { shownValue } from './escaping.ts'
// The content a carry element shows of one block; `code` the code block holding it, if any.
type ShownBlock = { code: AdfNode | undefined; html: string }
// One object per block a node stands in, so a block's content after a nested block parts from its content before; `code` the code block it is, if any.
type BlockPart = { code: AdfNode | undefined }
type ShownBlock = { html: string; part: BlockPart }
// What a carry element shows of the node it holds, and all `htmlToAdf` accepts there, so a change here breaks reading stored HTML (docs/decisions.md §The formats are API).
export function carryContent(node: AdfNode, tag: 'div' | 'span'): string {
if (tag === 'span') return shownBlocks(node, '<br>').map(({ html }) => html).join('<br>')
return shownBlocks(node, '\n').map(({ code, html }) => (code === undefined ? `<p>${html}</p>` : `<pre><code${languageClass(nodeAttrs(code)['language']) ?? ''}>${html}</code></pre>`)).join('')
return shownBlocks(node, '\n').map(({ html, part: { code } }) => (code === undefined ? `<p>${html}</p>` : `<pre><code${languageClass(nodeAttrs(code)['language']) ?? ''}>${html}</code></pre>`)).join('')
}
// What a carry element shows, block by block, as the writer panels picked it (docs/decisions.md §The HTML dialect).
function shownBlocks(root: AdfNode, codeLineFeed: string): ShownBlock[] {
const shown: ShownBlock[] = []
let current: ShownBlock = { code: undefined, html: '' }
// Each entry holds the code block it stands in, none where another block stands between them; a `null` node ends a block, and what follows stands in that entry's `code`, the code block around the block that ended.
const pending: { code: AdfNode | undefined; node: AdfNode | null }[] = [{ code: undefined, node: root }]
const pending: { node: AdfNode; part: BlockPart }[] = [{ node: root, part: { code: undefined } }]
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
const { node } = next
const code = node?.type === 'codeBlock' ? node : node !== null && partsShownText(node.type) ? undefined : next.code
if (node === null || partsShownText(node.type)) {
if (current.html !== '') shown.push(current)
current = { code, html: '' }
}
if (node === null) continue
current = { ...current, html: current.html + shownPiece(node, current.code === undefined ? '<br>' : codeLineFeed) }
if (partsShownText(node.type)) pending.push({ code: next.code, node: null })
for (const child of [...nodeContent(node)].reverse()) pending.push({ code, node: child })
const part = partsShownText(node.type) ? { code: node.type === 'codeBlock' ? node : undefined } : next.part
const piece = shownPiece(node, part.code === undefined ? '<br>' : codeLineFeed)
const last = shown.at(-1)
if (last?.part === part) last.html += piece
else shown.push({ html: piece, part })
for (const child of [...nodeContent(node)].reverse()) pending.push({ node: child, part })
}
if (current.html !== '') shown.push(current)
return shown
return shown.filter(({ html }) => html !== '')
}
// The node's own part of what its carry element shows.
+16 -9
View File
@@ -22,13 +22,13 @@ export function dataAttributes(node: AdfNode, vocabulary: AttributeVocabulary, w
return attributes
}
// What `dataAttributes` wrote, its pairs' values unescaped; the caller reads `leading`, an attribute the element writes ahead of them, where it stands first. A pair it does not write in that order is no HTML of the dialect; a name or value no attribute of the node spells is the node's shape, as a directive's is (docs/decisions.md §Which code a cause takes).
export function readDataAttributes(pairs: readonly (readonly [string, string])[], element: string, vocabulary: AttributeVocabulary, written: readonly string[], leading?: string): Read<AdfAttributes> {
// What `dataAttributes` wrote, its pairs' values unescaped. A pair it does not write in that order is no HTML of the dialect; a name or value no attribute of the node spells is the node's shape, as a directive's is (docs/decisions.md §Which code a cause takes).
export function readDataAttributes(pairs: readonly (readonly [string, string])[], element: string, vocabulary: AttributeVocabulary, written: readonly string[]): Read<AdfAttributes> {
const attrs: AdfAttributes = {}
let previous = ''
for (const [name, spelled] of leading !== undefined && pairs[0]?.[0] === leading ? pairs.slice(1) : pairs) {
for (const [name, spelled] of pairs) {
const [key, kind] = dataKey(name, vocabulary, written) ?? []
if (key === undefined || kind === undefined) return { fault: nameFault(name, element, leading, previous) }
if (key === undefined || kind === undefined) return { fault: nameFault(name, element) }
if (key <= previous) return { fault: unmappable(`the data-* attributes stand once each, sorted by the attribute they name: found ${name} after ${dataName(previous)}`) }
const value = dataValue(spelled, kind)
if (value === undefined) return { fault: { code: 'unsupported-node-shape', message: `${name} holds no ${kind} written as compact JSON` } }
@@ -38,6 +38,15 @@ export function readDataAttributes(pairs: readonly (readonly [string, string])[]
return { value: attrs }
}
// As `readDataAttributes`, past `leading`, an attribute the element writes ahead of them, where it stands first; the caller reads `leading`.
export function readDataAttributesAfter(leading: string, pairs: readonly (readonly [string, string])[], element: string, vocabulary: AttributeVocabulary, written: readonly string[]): Read<AdfAttributes> {
const repeated = pairs.findIndex(([name], index) => index > 0 && name === leading)
const data = readDataAttributes(pairs.slice(pairs[0]?.[0] === leading ? 1 : 0, repeated < 0 ? pairs.length : repeated), element, vocabulary, written)
const before = repeated < 0 ? undefined : pairs[repeated - 1]
if (data.fault !== undefined || before === undefined) return data
return { fault: unmappable(`<${element}> takes ${leading} once, before its data-* attributes: found ${leading} after ${before[0]}`) }
}
// The attribute `name` spells, among those the element itself does not write.
function dataKey(name: string, vocabulary: AttributeVocabulary, written: readonly string[]): [string, AttributeKind] | undefined {
return Object.entries(vocabulary).find(([candidate]) => !written.includes(candidate) && dataName(candidate) === name)
@@ -53,11 +62,9 @@ export function dataValue(spelled: string, kind: AttributeKind): JsonValue | und
return value !== undefined && holdsKind(value, kind) && spellsCanonicalJson(value, 'compact', spelled) ? value : undefined
}
// At a `leading`, `previous` is empty only where another `leading` stood first.
function nameFault(name: string, element: string, leading: string | undefined, previous: string): ConvertFault {
if (name.startsWith('data-')) return { code: 'unsupported-node-shape', message: `<${element}> takes no ${name} attribute` }
if (name !== leading) return unmappable(`<${element}> takes no ${name} attribute`)
return unmappable(`<${element}> takes ${name} once, before its data-* attributes: found ${name} after ${previous === '' ? name : dataName(previous)}`)
function nameFault(name: string, element: string): ConvertFault {
const message = `<${element}> takes no ${name} attribute`
return name.startsWith('data-') ? { code: 'unsupported-node-shape', message } : unmappable(message)
}
function unmappable(message: string): ConvertFault {
+11 -5
View File
@@ -18,7 +18,7 @@ import { positioned, success } from '../../result.ts'
import { readAnchorAttributes } from '../anchor-element.ts'
import { readCarry } from './carry-element.ts'
import { readCarryAttributes } from '../carry-attributes.ts'
import { readDataAttributes } from '../data-attributes.ts'
import { readDataAttributes, readDataAttributesAfter } from '../data-attributes.ts'
import { readImageAttributes } from '../image-element.ts'
import { readLanguageClass } from '../language-class.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
@@ -242,7 +242,8 @@ function readContainer(html: string, tag: StartTag, type: ContainerType, place:
const at = tag.start + `<${tag.name} start="`.length
return refused('unmappable-html', `an ordered list starts at an integer, written as JavaScript writes it: ${foundAt(html, at)}`, html, at, place)
}
const data = readDataAttributes(tag.attributes, tag.name, blockNodes[type].attributes, ordered ? ['order'] : [], ordered ? 'start' : undefined)
const vocabulary = blockNodes[type].attributes
const data = ordered ? readDataAttributesAfter('start', tag.attributes, tag.name, vocabulary, ['order']) : readDataAttributes(tag.attributes, tag.name, vocabulary, [])
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
const node = built(type, order === undefined ? data.value : { order, ...data.value }, [])
const closer = `</${tag.name}>`
@@ -320,9 +321,7 @@ function readItemLine(html: string, index: number, element: string, open: Open):
if (!line.ok) return line
const { end, node } = line.value
if (!html.startsWith(closer, end) && !html.startsWith('\n', end)) {
const lineEnd = `the line ends at ${closer}, or at a line feed before the next block`
// After a line's `</p>`, or before a block or the next item, no inline element can stand.
const stop = line.value.form === 'bare' && !isBlockStart(html, end) && startTagName(html, end) !== element ? misread(html, end, `${lineEnd}, and its text spells < as &lt;`, 'no inline element of the dialect') : { at: end, message: `${lineEnd}: ${foundAt(html, end)}` }
const stop = lineEndRefusal(html, end, line.value.form, element)
return refused('unmappable-html', stop.message, html, stop.at, open.place)
}
const paragraph = asFirstParagraph({ end, node }, line.value.form, html, index, place)
@@ -331,6 +330,13 @@ function readItemLine(html: string, index: number, element: string, open: Open):
return success({ end: end + 1, kind: 'opened', open: { ...open, content: [node] } })
}
function lineEndRefusal(html: string, end: number, form: FirstParagraphForm, element: string): { at: number; message: string } {
const lineEnd = `the line ends at </${element}>, or at a line feed before the next block`
// After a line's `</p>`, or before a block or the next item, no inline element can stand.
if (form !== 'bare' || isBlockStart(html, end) || startTagName(html, end) === element) return { at: end, message: `${lineEnd}: ${foundAt(html, end)}` }
return misread(html, end, `${lineEnd}, and its text spells < as &lt;`, 'no inline element of the dialect')
}
// The paragraph on an item's or cell's line and the form it stands in there, up to its closer or to the line feed before its next block.
function readLineParagraph(html: string, index: number, place: Place): Result<{ end: number; form: FirstParagraphForm; node: AdfNode }, ParseError> {
const tag = readStartTag(html, index)
+3 -2
View File
@@ -1,7 +1,7 @@
import type { AdfNode } from '../../adf/document.ts'
import { foundAt, misspelledText, refused, type Place, type ReadNode, type StartTag } from './source.ts'
import { nodeTypeOfElement } from '../node-element.ts'
import { readDataAttributes } from '../data-attributes.ts'
import { readDataAttributes, readDataAttributesAfter } from '../data-attributes.ts'
import { success } from '../../result.ts'
import { misspelledAt, unescapedValue } from '../escaping.ts'
@@ -13,7 +13,8 @@ export function readNodeElement(html: string, tag: StartTag, place: Place): Read
const [type, model] = found
const name = `<${tag.name} class="${model.class}">`
if (model.leading !== undefined && rest[0]?.[0] !== model.leading.name) return refused('unmappable-html', `${name} holds ${model.leading.name} first, before its data-* attributes: ${foundAt(html, tag.start)}`, html, tag.start, place)
const data = readDataAttributes(rest, `${tag.name} class="${model.class}"`, model.vocabulary, model.written, model.leading?.name)
const element = `${tag.name} class="${model.class}"`
const data = model.leading === undefined ? readDataAttributes(rest, element, model.vocabulary, model.written) : readDataAttributesAfter(model.leading.name, rest, element, model.vocabulary, model.written)
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
const closer = `</${tag.name}>`
const stop = html.indexOf('<', tag.end)
+27 -16
View File
@@ -34,8 +34,9 @@ export type PlacedBlock = SpelledBlock & { node: AdfNode }
export type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
// CommonMark starts a new list where the marker changes, so every second list in a run of one kind takes `alternateText`.
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: BlockText } | { alternateText: BlockText; interruptsParagraph: boolean; ordered: boolean; spelling: 'list'; text: BlockText }
// Keyed by reference: only a caller building one object per position (the parse, the portable reduction) passes one; a consumer's document may share a node.
export type SpellingMemo = Map<AdfNode, KeptSpelling>
type Spell = () => Result<EmittedBlock> | undefined
// ask: spell the node, keeping the spelling unless refused; take: spell the node, reading and dropping what is kept.
export type SpellingMemo = Readonly<Record<'ask' | 'take', (node: AdfNode, depth: number, spell: Spell) => Result<EmittedBlock> | undefined>>
type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
export type WrittenAs = { flavour: 'lossless' } | { blocks: FlavourBlockWriters; flavour: 'portable' }
export type Writing = WrittenAs & { memo: SpellingMemo | undefined }
@@ -110,14 +111,14 @@ function blankLineBetween(previous: PlacedBlock, next: PlacedBlock, container: B
export function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const model = blockNodeModel(node.type)
if (model === undefined) return carriedFence(carriedBlock(node, path, depth))
const readable = readableBlock(node, path, depth, writing, 'walked')
const readable = readableBlock(node, path, depth, writing)
if (readable !== undefined) return readable
return emitDirectiveBlock(node, model, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1, writing))
}
// Whether the node takes a CommonMark spelling, rather than its directive.
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing & { memo: SpellingMemo }): Result<boolean> {
const readable = readableBlock(node, path, depth, writing, 'asked')
const readable = writing.memo.ask(node, depth, () => spellReadableBlock(node, path, depth, writing))
if (readable === undefined) return success(false)
if (!readable.ok) return readable
const fits = withinLongest(readable.value.text.length, readable.value)
@@ -125,20 +126,30 @@ export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth:
return success(readable.value.spelling !== 'directive')
}
// Only an `asked` call keeps a spelling, and any read drops it: the ancestor that reads it spells it into its own text, so no two levels hold that text.
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, caller: 'asked' | 'walked'): Result<EmittedBlock> | undefined {
const { memo } = writing
const kept = memo?.get(node)
memo?.delete(node)
if (kept !== undefined) {
if (kept.block === undefined) return undefined
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
const spell = () => spellReadableBlock(node, path, depth, writing)
return writing.memo === undefined ? spell() : writing.memo.take(node, depth, spell)
}
// Keyed by reference: only a caller building one object per position (the parse, the portable reduction) makes one; a consumer's document may share a node.
export function spellingMemo(): SpellingMemo {
const kept = new Map<AdfNode, KeptSpelling>()
// Any read drops the entry: the ancestor that reads it spells it into its own text, so no two levels hold that text.
const take: SpellingMemo['take'] = (node, depth, spell) => {
const entry = kept.get(node)
kept.delete(node)
if (entry === undefined) return spell()
if (entry.block === undefined) return undefined
// An entry holds the spelling at its fill depth. A read at or above it rebases headroom; a read below re-spells, because a hit skips the walk's depth guards.
if (depth <= kept.depth) return success({ ...kept.block, headroom: kept.block.headroom + kept.depth - depth })
return depth <= entry.depth ? success({ ...entry.block, headroom: entry.block.headroom + entry.depth - depth }) : spell()
}
const spelled = spellReadableBlock(node, path, depth, writing)
if (caller === 'asked' && spelled === undefined) memo?.set(node, { block: undefined, depth })
if (caller === 'asked' && spelled?.ok === true) memo?.set(node, { block: spelled.value, depth })
return spelled
const ask: SpellingMemo['ask'] = (node, depth, spell) => take(node, depth, () => {
const spelled = spell()
if (spelled === undefined) kept.set(node, { block: undefined, depth })
else if (spelled.ok) kept.set(node, { block: spelled.value, depth })
return spelled
})
return { ask, take }
}
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
+30 -18
View File
@@ -5,7 +5,7 @@ export type BlockText = Readonly<Lines & { first: FirstLine; later: LaterLines;
type Lines =
| { body: BlockText; head: string; indent: string; kind: 'prefixed' }
| { body: BlockText; kind: 'quoted' }
| { kind: 'joined'; parts: readonly BlockText[] }
| { kind: 'joined'; parts: readonly [BlockText, ...BlockText[]] }
| { kind: 'text'; lines: readonly string[] }
type Prefix = Lines & { kind: 'prefixed' | 'quoted' }
type OpenPrefix = { innermostQuote: number; prefix: Prefix }
@@ -17,37 +17,49 @@ type LaterLines = Readonly<{ blank: number; empty: number; written: number }>
export const emptyLine = blockText('')
export function blockText(text: string): BlockText {
const [first = '', ...rest] = text.split('\n')
const empty = rest.filter((line) => line === '').length
const later = { blank: rest.filter((line) => line !== '' && isBlankLine(line)).length, empty, written: rest.length - empty }
return { first: { blank: first !== '' && isBlankLine(first), character: first.charAt(0), ruleCharacters: holdsOnlyRuleCharacters(first), text: first }, kind: 'text', later, length: text.length, lines: [first, ...rest] }
return measured({ kind: 'text', lines: text.split('\n') })
}
export function joinedText(parts: readonly BlockText[]): BlockText {
const [first, ...others] = parts
if (first === undefined) return emptyLine
let { blank, empty, written } = first.later
let { length } = first
for (const part of others) {
blank += part.later.blank + (part.first.blank ? 1 : 0)
empty += part.later.empty + (part.first.text === '' ? 1 : 0)
written += part.later.written + (part.first.text === '' ? 0 : 1)
length += 1 + part.length
}
return { first: first.first, kind: 'joined', later: { blank, empty, written }, length, parts }
return first === undefined ? emptyLine : measured({ kind: 'joined', parts: [first, ...others] })
}
// head, never blank, leads the first line; indent, spaces alone, leads every later line holding text.
export function prefixedText(head: string, indent: string, body: BlockText): BlockText {
const length = body.length + head.length + indent.length * body.later.written
return { body, first: headedLine(head, body.first), head, indent, kind: 'prefixed', later: body.later, length }
return measured({ body, head, indent, kind: 'prefixed' })
}
// Each line takes `> `, an empty one `>`.
export function quotedText(body: BlockText): BlockText {
return measured({ body, kind: 'quoted' })
}
function measured(lines: Lines): BlockText {
if (lines.kind === 'text') {
const [first = '', ...rest] = lines.lines
const empty = rest.filter((line) => line === '').length
const later = { blank: rest.filter((line) => line !== '' && isBlankLine(line)).length, empty, written: rest.length - empty }
const length = rest.reduce((sum, line) => sum + 1 + line.length, first.length)
return { ...lines, first: { blank: first !== '' && isBlankLine(first), character: first.charAt(0), ruleCharacters: holdsOnlyRuleCharacters(first), text: first }, later, length }
}
if (lines.kind === 'joined') {
const [first, ...others] = lines.parts
let { blank, empty, written } = first.later
let { length } = first
for (const part of others) {
blank += part.later.blank + (part.first.blank ? 1 : 0)
empty += part.later.empty + (part.first.text === '' ? 1 : 0)
written += part.later.written + (part.first.text === '' ? 0 : 1)
length += 1 + part.length
}
return { ...lines, first: first.first, later: { blank, empty, written }, length }
}
const { body } = lines
if (lines.kind === 'prefixed') return { ...lines, first: headedLine(lines.head, body.first), later: body.later, length: body.length + lines.head.length + lines.indent.length * body.later.written }
const head = body.first.text === '' ? '>' : '> '
const later = { blank: 0, empty: 0, written: body.later.empty + body.later.written }
return { body, first: headedLine(head, body.first), kind: 'quoted', later, length: body.length + head.length + 2 * body.later.written + body.later.empty }
return { ...lines, first: headedLine(head, body.first), later, length: body.length + head.length + 2 * body.later.written + body.later.empty }
}
// A line holds only rule characters exactly when each of its parts does.
+48 -19
View File
@@ -30,6 +30,15 @@ type Emission = { carries: [NodeRange, ...NodeRange[]]; segments?: undefined } |
type Fallback = [NodeRange, ...NodeRange[]] | 'opening-link'
// How a line is judged and what its fallbacks do: the lossless writer carries nodes, the portable reduction drops marks.
export type FallbackAction<Line, LineFallback, Written> = {
giveUpEmphasis: (line: Line) => Line
judge: (line: Line, grouping: RunGrouping) => Result<LineJudgement<Line, LineFallback, Written>>
// A line whose fallbacks rewrite its nodes judges its spanned line again on every pass.
spansEachPass: boolean
take: (line: Line, fallback: LineFallback) => Result<Line>
}
type InlineContext = {
atBlockEnd: boolean
bracketed: boolean
@@ -44,10 +53,13 @@ type InlineContext = {
type LineAttempt = { fallback: Fallback; line?: undefined; segments?: undefined } | { fallback?: undefined; line: string; segments: InlineSegment[] }
// What one grouping writes, or the fallback it needs and the line that fallback's indexes name.
export type LineJudgement<Line, LineFallback, Written> = { fallback: LineFallback; kind: 'fallback'; line: Line } | { kind: 'written'; written: Written }
type LineFallbacks = { carried: Set<number>; flavour: WrittenFlavour; openingLinkAsDirective: boolean }
// docs/decisions.md §After 32 passes, a line carries or drops every em, strike and strong mark.
export const readBackPasses = 32
const readBackPasses = 32
export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
@@ -93,28 +105,44 @@ export function tryImageLine(alt: string | undefined, url: string, flavour: Writ
}
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result<EmittedLine> {
const fallbacks = withheldMarkFallbacks(nodes, flavour)
// spec/flavour.md, Marks: the spanned line where it reads back, else the parted line with every fallback it needs.
const spanned = linePass(nodes, container, path, fallbacks, 'spanned')
if (!spanned.ok) return spanned
const { fallback, line, segments } = spanned.value
if (fallback === undefined) return success({ line, openingLinkAsDirective: false, segments })
return partedLine(nodes, container, path, fallbacks)
return settledLine(withheldMarkFallbacks(nodes, flavour), {
giveUpEmphasis: (fallbacks) => carryEveryEmphasis(fallbacks, nodes),
judge: (fallbacks, grouping) => writerJudgement(nodes, container, path, fallbacks, grouping),
spansEachPass: false,
take: (fallbacks, fallback) => {
const taken = takeFallback(fallbacks, fallback, path)
return taken.ok ? success(fallbacks) : taken
},
})
}
function partedLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result<EmittedLine> {
// Terminates because takeFallback refuses a pass that took no new fallback.
for (let pass = 1; ; pass += 1) {
const attempt = linePass(nodes, container, path, fallbacks, 'parted')
if (!attempt.ok) return attempt
const { fallback, line, segments } = attempt.value
if (fallback === undefined) return success({ line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments })
const taken = takeFallback(fallbacks, fallback, path)
// spec/flavour.md, Marks: the spanned line where it reads back, else the parted line, taking one fallback a pass.
export function settledLine<Line, LineFallback, Written>(line: Line, action: FallbackAction<Line, LineFallback, Written>): Result<Written> {
// Terminates because `take` refuses a pass that changes nothing.
for (let current = line, pass = 1; ; pass += 1) {
let parting = current
if (pass === 1 || action.spansEachPass) {
const spanned = action.judge(current, 'spanned')
if (!spanned.ok) return spanned
if (spanned.value.kind === 'written') return success(spanned.value.written)
parting = spanned.value.line
}
const parted = action.judge(parting, 'parted')
if (!parted.ok) return parted
if (parted.value.kind === 'written') return success(parted.value.written)
const taken = action.take(parted.value.line, parted.value.fallback)
if (!taken.ok) return taken
if (pass === readBackPasses) carryEveryEmphasis(fallbacks, nodes)
current = pass === readBackPasses ? action.giveUpEmphasis(taken.value) : taken.value
}
}
function writerJudgement(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, grouping: RunGrouping): Result<LineJudgement<LineFallbacks, Fallback, EmittedLine>> {
const attempt = linePass(nodes, container, path, fallbacks, grouping)
if (!attempt.ok) return attempt
const { fallback, line, segments } = attempt.value
return success(fallback === undefined ? { kind: 'written', written: { line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments } } : { fallback, kind: 'fallback', line: fallbacks })
}
export function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, grouping: RunGrouping): Result<LineAttempt> {
const emission = lineSegments(nodes, container, path, fallbacks, grouping)
if (!emission.ok) return emission
@@ -122,8 +150,9 @@ export function linePass(nodes: readonly AdfNode[], container: LineContainer, pa
return success(segments === undefined ? { fallback: carries } : attemptLine(segments, container, fallbacks.flavour))
}
function carryEveryEmphasis(fallbacks: LineFallbacks, nodes: readonly AdfNode[]): void {
function carryEveryEmphasis(fallbacks: LineFallbacks, nodes: readonly AdfNode[]): LineFallbacks {
for (const [index, node] of nodes.entries()) if (nodeMarks(node).some(isEmphasis)) fallbacks.carried.add(index)
return fallbacks
}
// Seeds the withheld-mark carries once per line, so the run at each mark depth does not rescan every node's marks.
@@ -359,7 +388,7 @@ function tryLink(nodes: readonly [RunNode, ...RunNode[]], mark: AdfMark, depth:
const href = linkHref(nodeAttrs(mark))
if (href === undefined) return carriedRun(range, context)
const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective
const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes.map(({ node }) => node), nodes[0].outer, context.bracketed, titleEscapes(context.flavour, context.container))
const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes.map(({ node }) => node), { bracketed: context.bracketed, escapes: titleEscapes(context.flavour, context.container), marksInside: nodes[0].outer })
if (commonMark === undefined) return undefined
if (commonMark.form === 'autolink') return success({ segments: [syntax(`<${href}>`)] })
const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true })
+1 -1
View File
@@ -1,3 +1,3 @@
// The only file `parse/` imports from `emit/` (docs/decisions.md §The source parts by ADF and format).
export { commonMarkSpelling, type SpellingMemo } from './adf-to-markdown.ts'
export { commonMarkSpelling, spellingMemo, type SpellingMemo } from './adf-to-markdown.ts'
export { openingLinkTakesDirective } from './inline-line.ts'
+34 -22
View File
@@ -14,35 +14,47 @@ type EmphasisPair = { closer: number; delimiter: string; nodes: MarkRun; opener:
// The pairs in closing order, and what the sweep has settled about them.
type PairSweep = {
readonly fallback: RunFallback
// 1 where the pair is settled to fall.
readonly fallen: Uint8Array
readonly first: number
// The bounds of every run whose drop may merge runs: its first node's index and the index past its last.
readonly mergingEnds: ReadonlySet<number>
readonly output: string
// The pair each placed segment delimits, -1 where it delimits none.
readonly pairAt: Int32Array
readonly pairs: readonly EmphasisPair[]
readonly placed: readonly PlacedSegment[]
readonly verdicts: Verdict[]
}
// How another pair stands when the judged one falls: `moves` where a carry or a merge may move its delimiters, `unsettled` where it may stand or have fallen.
type Standing = 'fallen' | 'moves' | 'stands' | 'unsettled'
// The runs this pass takes back: the first emphasis run the line misreads and every run that falls with it, else every misflanked highlight run. docs/decisions.md §With the first misread emphasis run, `unspellableRuns` returns only runs that, taken back early, leave the line as the one-run-per-pass reference writes it.
// `merges` where a drop may merge runs at the pair's ends, so its delimiters may move; `falls` where it goes back with the first misread pair, itself included unless it merges; `kept` otherwise.
type Verdict = 'falls' | 'kept' | 'merges'
// The runs this pass takes back: the first emphasis run the line misreads and every run that falls with it, else every misflanked highlight run.
export function unspellableRuns(placed: readonly PlacedSegment[], reading: InlineReading, output: string, fallback: RunFallback): MarkRun[] {
const { pairAt, pairs } = emphasisPairs(placed)
const first = pairs.findIndex((pair) => !readAsWritten(reading, placed, pair))
const firstPair = pairs[first]
if (firstPair === undefined) return misflankedHighlights(placed, output)
const sweep: PairSweep = { fallback, fallen: new Uint8Array(pairs.length), first, mergingEnds: mergingEnds(pairs, fallback), output, pairAt, pairs, placed }
sweep.fallen[first] = 1
const verdicts = pairVerdicts({ fallback, first, output, pairAt, pairs, placed })
return pairs.reduce(
(runs, pair, index) => {
if (index !== first && verdicts[index] === 'falls') runs.push(pair.nodes)
return runs
},
[firstPair.nodes],
)
}
// docs/decisions.md §With the first misread emphasis run, `unspellableRuns` returns only runs that, taken back early, leave the line as the one-run-per-pass reference writes it.
function pairVerdicts(line: Omit<PairSweep, 'verdicts'>): Verdict[] {
const { fallback, first, pairs } = line
const ends = mergingEnds(pairs, fallback)
const verdicts = pairs.map((pair, index): Verdict => (mergesAt(ends, pair) ? 'merges' : index === first ? 'falls' : 'kept'))
const sweep: PairSweep = { ...line, verdicts }
// Each verdict reads the verdicts settled before it, so the sweep settles forward from the first pair, then back.
for (let judged = first + 1; judged < pairs.length; judged += 1) if (fallsWithFirst(sweep, judged)) sweep.fallen[judged] = 1
for (let judged = first - 1; judged >= 0; judged -= 1) if (fallsWithFirst(sweep, judged)) sweep.fallen[judged] = 1
const runs = [firstPair.nodes]
for (const [index, pair] of pairs.entries()) if (index !== first && sweep.fallen[index] === 1) runs.push(pair.nodes)
return runs
for (let judged = first + 1; judged < pairs.length; judged += 1) if (fallsWithFirst(sweep, judged)) verdicts[judged] = 'falls'
for (let judged = first - 1; judged >= 0; judged -= 1) if (fallsWithFirst(sweep, judged)) verdicts[judged] = 'falls'
return verdicts
}
// The pairs in closing order.
@@ -97,18 +109,18 @@ function endsAlone(edge: AdfNode | undefined, beside: AdfNode | undefined, mark:
return marks.slice(marks.findIndex((held) => identicalMark(held, mark)) + 1).every((held) => !besideTypes.has(held.type))
}
// A pair falls with the first only where that leaves the line as the one-run-per-pass reference writes it: it falls cleanly, and cannot read whichever character it is written with.
// A pair falls with the first where it falls cleanly, and cannot read whichever character it is written with.
function fallsWithFirst(sweep: PairSweep, judged: number): boolean {
if (!fallsCleanly(sweep, judged)) return false
const spellings = pairSpellings(sweep, judged, judged)
return spellings !== undefined && spellings.every((character) => cannotRead(sweep, judged, character))
}
// A carry splits every pair holding it, so a pair falls cleanly under a carry only where it holds no pair spanning fewer nodes, and under a drop only where no drop may merge runs at its ends.
function fallsCleanly({ fallback, mergingEnds, pairs }: PairSweep, judged: number): boolean {
// A carry splits every pair holding it, so a pair falls cleanly under a carry only where it holds no pair spanning fewer nodes, and under a drop only where it does not merge.
function fallsCleanly({ fallback, pairs, verdicts }: PairSweep, judged: number): boolean {
const pair = pairs[judged]
if (pair === undefined) return false
return fallback.kind === 'drop' ? !mergesAt(mergingEnds, pair) : !holdsPairBefore(pairs, judged, pair)
if (pair === undefined || verdicts[judged] === 'merges') return false
return fallback.kind === 'drop' || !holdsPairBefore(pairs, judged, pair)
}
function mergesAt(mergingEnds: ReadonlySet<number>, { nodes }: EmphasisPair): boolean {
@@ -122,12 +134,12 @@ function cannotRead(sweep: PairSweep, judged: number, character: string): boolea
}
// Whether the judged pair holds a pair not settled to fall that may be written with `character`. Pairs close in order, so the pairs it holds close just before it.
function holdsAlike({ fallen, pairs }: PairSweep, judged: number, character: string): boolean {
function holdsAlike({ first, pairs, verdicts }: PairSweep, judged: number, character: string): boolean {
const pair = pairs[judged]
for (let held = judged - 1; pair !== undefined && held >= 0; held -= 1) {
const inner = pairs[held]
if (inner === undefined || inner.closer < pair.opener) return false
if (fallen[held] === 0 && mayBeWrittenWith(inner, character)) return true
if (held !== first && verdicts[held] !== 'falls' && mayBeWrittenWith(inner, character)) return true
}
return false
}
@@ -205,13 +217,13 @@ function delimiterEdge(placed: readonly PlacedSegment[], at: number, step: -1 |
}
// Derived case by case in docs/decisions.md §With the first misread emphasis run, `unspellableRuns` returns only runs that, taken back early, leave the line as the one-run-per-pass reference writes it.
function pairStanding({ fallback, fallen, first, mergingEnds, pairs }: PairSweep, judged: number, other: number): Standing {
function pairStanding({ fallback, first, pairs, verdicts }: PairSweep, judged: number, other: number): Standing {
const pair = pairs[other]
if (other === judged) return 'stands'
if (pair === undefined || mergesAt(mergingEnds, pair)) return 'moves'
if (pair === undefined || verdicts[other] === 'merges') return 'moves'
if (other > judged && judged > first) return 'stands'
if (fallback.kind === 'carry') return spanSameNodes(pair, pairs[judged]) ? 'stands' : 'moves'
return fallen[other] === 1 ? 'fallen' : 'unsettled'
return verdicts[other] === 'kept' ? 'unsettled' : 'fallen'
}
// The characters the other pair may be written with when the judged one falls: em takes `_`, turning `*` beside a word character.
+1 -1
View File
@@ -276,7 +276,7 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[]
const href = linkHref(nodeAttrs(mark))
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.shared.path)
if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.shared.path)
if (commonMarkLink(nodeAttrs(mark), href, adfNodes(nodes), 0, scan.container === undefined, 'lossless') === undefined) return undefined
if (commonMarkLink(nodeAttrs(mark), href, adfNodes(nodes), { bracketed: scan.container === undefined, escapes: 'lossless', marksInside: 0 }) === undefined) return undefined
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.shared.path)
scan.openingSpellableLink = true
return undefined
+5 -2
View File
@@ -63,8 +63,11 @@ export function linkHref(attrs: AdfAttributes): string | undefined {
return typeof href === 'string' ? href : undefined
}
// spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's `outer`, the parser's 0.
export function commonMarkLink(attrs: AdfAttributes, href: string, nodes: readonly AdfNode[], marksInside: number, bracketed: boolean, escapes: TitleEscapes): CommonMarkLink | undefined {
// `marksInside` counts the marks the link's nodes carry within it: the emitter's `outer`, the parser's 0.
type LinkSurroundings = { bracketed: boolean; escapes: TitleEscapes; marksInside: number }
// spec/flavour.md, Marks.
export function commonMarkLink(attrs: AdfAttributes, href: string, nodes: readonly AdfNode[], { bracketed, escapes, marksInside }: LinkSurroundings): CommonMarkLink | undefined {
if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined
const title = attrs['title']
const autolinkHolds = !bracketed || (!href.includes('`') && !holdsDirectivePrefix(href) && escapeUnbalanced(href, '[', ']') === href)
+2 -2
View File
@@ -5,7 +5,7 @@ import type { ConvertFault } from '../../result.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 { commonMarkSpelling, openingLinkTakesDirective, spellingMemo, type SpellingMemo } from '../emit/spelling-questions.ts'
import { documentAttribute, documentName, documentSpelling } from '../directive/block.ts'
import { emptyKeys, nodeAttrs } from '../../adf/document.ts'
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result } from '../../result.ts'
@@ -55,7 +55,7 @@ function readMarkdown(markdown: string, readAs: ReadAs): Result<AdfDocument> {
const fault = documentFault(only)
return fault === undefined ? success({ type: 'doc', version: 1 }) : positioned(faulted(fault, []), only.position)
}
const reading: Reading = { ...readAs, definitions: parsed.definitions, memo: new Map() }
const reading: Reading = { ...readAs, definitions: parsed.definitions, memo: spellingMemo() }
const content = readBlocks(parsed.blocks, reading, [], 0)
return content.ok ? success({ content: content.value, type: 'doc', version: 1 }) : content
}
@@ -70,6 +70,7 @@ test('refuses what the document guard refuses, and nothing else', () => {
let deepInline: AdfNode = text('x')
for (let level = 0; level <= largestNesting; level += 1) deepInline = { content: [deepInline], type: 'unknownInline' }
assert.match(portableDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /)
assert.match(portableDocument(document(paragraph(text('a'), text('b'), deepInline))), /^unsupported-nesting-depth at \/content\/0\/content\/2(\/content\/0)+$/)
})
test('refuses a paragraph whose text would pass V8\'s longest string, joining none of it', () => {
@@ -1,10 +1,10 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts'
import { centeredImage, externalMedia } from '../../adf/plain-image.ts'
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { commonMarkSpelling, largestListMarker, spellingMemo, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims } from '../flavour.ts'
import { inlineLeaves, portableLink, reduceInline, writableHref } from './inline-reduction.ts'
import { inlineLeaves, portableLink, reduceInline, writableHref, type ReductionPlace } from './inline-reduction.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { isBlockNodeType, oneLine } from '../../adf/shown-text.ts'
import { languageSlot } from '../code-language.ts'
@@ -14,11 +14,10 @@ import { nodeAttrs, nodeContent, nodeMarks, writableContent } from '../../adf/do
import { portableWriting } from './block-spelling.ts'
import { splitTaskList } from './task-list.ts'
import { taskMarker } from './conventions.ts'
import { toEditorNormal } from './editor-normal.ts'
import { editorNormalForm, sourceIndex, toEditorNormal } from './editor-normal.ts'
import { withinEngineLimits } from '../../length-bound.ts'
// depth: the level the node reduced stands at, counted as the emitter counts it.
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
type Reduction = ReductionPlace & { memo: SpellingMemo }
type BlockReducer = (node: AdfNode, reduction: Reduction) => Result<AdfNode[]>
@@ -41,7 +40,7 @@ const blockReducers: Readonly<Record<string, BlockReducer>> = {
extension: paragraphOfNode,
heading: reduceHeading,
media: reduceMedia,
mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => reduceStanding(child, childReduction(reduction, index)))),
mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index, children) => reduceStanding(child, childReduction(reduction, children, index)))),
nestedExpand: reduceExpand,
orderedList: reduceList,
panel: reducePanel,
@@ -64,12 +63,9 @@ export function reduceToPortable(document: AdfDocument): Result<AdfDocument> {
const checked = writableContent(document)
if (!checked.ok) return checked
// Two documents the editor holds equal write the same portable markdown (docs/decisions.md §Equality is deep).
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
if (!blocks.ok) {
// Merging text renumbers siblings, so a refusal's path comes from the caller's document.
const raw = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
return !raw.ok && raw.error.code === blocks.error.code ? raw : blocks
}
const { normal, sources } = editorNormalForm(document)
const blocks = reduceBlocks(nodeContent(normal), { depth: 0, memo: spellingMemo(), path: [], sources })
if (!blocks.ok) return blocks
return success(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 }))
}
@@ -83,7 +79,7 @@ function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<A
}
const sourced: Sourced[] = []
for (const entry of placed) {
const at = { ...reduction, path: [...reduction.path, 'content', entry.index] }
const at = { ...reduction, path: [...reduction.path, 'content', sourceIndex(nodes, entry.index, reduction.sources)] }
const blocks = entry.loose === undefined ? reduceNode(entry.node, at) : paragraphOf(entry.loose, reduction)
if (!blocks.ok) return blocks
for (const block of blocks.value) sourced.push({ block, path: at.path })
@@ -112,8 +108,8 @@ function reduceBody(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
return reduceBlocks(nodeContent(node), { ...reduction, depth: reduction.depth + 1 })
}
function childReduction(reduction: Reduction, index: number): Reduction {
return { ...reduction, depth: reduction.depth + 1, path: [...reduction.path, 'content', index] }
function childReduction(reduction: Reduction, children: readonly AdfNode[], index: number): Reduction {
return { ...reduction, depth: reduction.depth + 1, path: [...reduction.path, 'content', sourceIndex(children, index, reduction.sources)] }
}
function concatenated(results: readonly Result<AdfNode[]>[]): Result<AdfNode[]> {
@@ -183,7 +179,7 @@ function listOf(items: readonly AdfNode[], type: string): AdfNode[] {
}
function paragraphOf(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
const content = reduceInline(nodes, 'paragraph', reduction.path, reduction.depth)
const content = reduceInline(nodes, 'paragraph', reduction)
return content.ok ? success(content.value.length === 0 ? [] : [paragraph(content.value)]) : content
}
@@ -217,12 +213,12 @@ function withoutTrailingBlanks(text: string): string {
function reduceHeading(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const level = nodeAttrs(node)['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return paragraphOf(nodeContent(node), reduction)
const content = reduceInline(nodeContent(node), 'heading', reduction.path, reduction.depth)
const content = reduceInline(nodeContent(node), 'heading', reduction)
return content.ok ? success([{ attrs: { level }, content: content.value, type: 'heading' }]) : content
}
function reduceCodeBlock(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const leaves = inlineLeaves(nodeContent(node), 'paragraph', reduction.path, reduction.depth)
const leaves = inlineLeaves(nodeContent(node), 'paragraph', reduction)
if (!leaves.ok) return leaves
const code = leaves.value.map((leaf) => leaf.text ?? '\n').join('')
const slot = languageSlot(nodeAttrs(node)['language'], flavourClaims.portable)
@@ -239,7 +235,7 @@ function reduceList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
}
function reduceItems(node: AdfNode, reduction: Reduction, itemBlocks: (child: AdfNode, at: Reduction) => Result<AdfNode[]>): Result<AdfNode[]> {
const items = concatenated(nodeContent(node).map((child, index) => listItem(itemBlocks(child, childReduction(reduction, index)))))
const items = concatenated(nodeContent(node).map((child, index, children) => listItem(itemBlocks(child, childReduction(reduction, children, index)))))
return items.ok ? success(listOf(items.value, node.type === 'orderedList' ? 'orderedList' : 'bulletList')) : items
}
@@ -266,8 +262,9 @@ function blankedLines(code: readonly AdfNode[]): AdfNode[] {
// A task list opening with a task and holding tasks and task lists alone keeps its spelling; any other keeps its markers as text. A child reducing to nothing counts for neither.
function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const kept: { blocks: AdfNode[]; child: AdfNode }[] = []
for (const [index, child] of nodeContent(node).entries()) {
const at = childReduction(reduction, index)
const children = nodeContent(node)
for (const [index, child] of children.entries()) {
const at = childReduction(reduction, children, index)
const reduced = isTaskItem(child) ? reduceTask(child, at) : reduceStanding(child, at)
if (!reduced.ok) return reduced
if (reduced.value.length > 0) kept.push({ blocks: reduced.value, child })
@@ -285,7 +282,7 @@ function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]>
function reduceTask(task: AdfNode, at: Reduction): Result<AdfNode[]> {
const attrs = { state: nodeAttrs(task)['state'] === 'DONE' ? 'DONE' : 'TODO' }
if (task.type === 'taskItem') {
const content = reduceInline(nodeContent(task), 'paragraph', at.path, at.depth)
const content = reduceInline(nodeContent(task), 'paragraph', at)
return content.ok ? success([{ attrs, content: content.value, type: 'taskItem' }]) : content
}
const blocks = reduceBlocks(nodeContent(task), at)
@@ -301,11 +298,13 @@ function marked(blocks: readonly AdfNode[], marker: string): AdfNode[] {
function reduceTable(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const rows: PlacedCell[][] = []
for (const [rowIndex, row] of nodeContent(node).entries()) {
const rowReduction = childReduction(reduction, rowIndex)
const rowNodes = nodeContent(node)
for (const [rowIndex, row] of rowNodes.entries()) {
const rowReduction = childReduction(reduction, rowNodes, rowIndex)
const cells: PlacedCell[] = []
for (const [cellIndex, cell] of (row.type === 'tableRow' ? nodeContent(row) : [row]).entries()) {
const paragraph = cellParagraph(cell, childReduction(rowReduction, cellIndex))
const rowCells = row.type === 'tableRow' ? nodeContent(row) : [row]
for (const [cellIndex, cell] of rowCells.entries()) {
const paragraph = cellParagraph(cell, childReduction(rowReduction, rowCells, cellIndex))
if (!paragraph.ok) return paragraph
cells.push({ colspan: span(nodeAttrs(cell)['colspan']), paragraph: paragraph.value, rowspan: span(nodeAttrs(cell)['rowspan']) })
}
@@ -354,7 +353,7 @@ function setCell(grid: (AdfNode | undefined)[][], row: number, column: number, c
function cellParagraph(cell: AdfNode, reduction: Reduction): Result<AdfNode> {
const blocks = cell.type === 'tableCell' || cell.type === 'tableHeader' ? nodeContent(cell) : [cell]
const content = reduceInline(blocks, 'table-cell', reduction.path, reduction.depth)
const content = reduceInline(blocks, 'table-cell', reduction)
return content.ok ? success(content.value.length === 0 ? { type: 'paragraph' } : paragraph(content.value)) : content
}
+10 -1
View File
@@ -3,7 +3,7 @@ import test from 'node:test'
import type { AdfNode } from '../../adf/document.ts'
import type { JsonValue } from '../../json-value.ts'
import { toEditorNormal } from './editor-normal.ts'
import { editorNormalForm, sourceIndex, toEditorNormal } from './editor-normal.ts'
test('merges adjacent text nodes carrying identical marks, at every level', () => {
const content: AdfNode[] = [
@@ -83,3 +83,12 @@ test('drops the content a text node holds, and joins the node as any other', ()
assert.deepEqual(normal([holding, { text: 'b', type: 'text' }]), [{ content: [{ text: 'ab', type: 'text' }], type: 'paragraph' }])
assert.deepEqual(normal([{ text: 'b', type: 'text' }, holding]), [{ content: [{ text: 'ba', type: 'text' }], type: 'paragraph' }])
})
test('counts a node\'s index in a run of its siblings, or alone, in the document it was normalized from', () => {
const rule: AdfNode = { type: 'rule' }
const { normal, sources } = editorNormalForm({ content: [rule, { text: 'a', type: 'text' }, { text: 'b', type: 'text' }, { type: 'mention' }, { type: 'rule' }], type: 'doc', version: 1 })
const content = normal.content ?? []
assert.deepEqual(content.map((_, index) => sourceIndex(content, index, sources)), [0, 1, 3, 4])
assert.deepEqual([sourceIndex(content.slice(1, 3), 1, sources), sourceIndex(content.slice(2, 3), 0, sources)], [2, 0])
assert.equal(sourceIndex([rule], 0, sources), 0)
})
+22 -4
View File
@@ -1,12 +1,15 @@
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { JsonValue } from '../../json-value.ts'
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { mergedTextRuns, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { joinsWhenRead } from '../adjacent-text.ts'
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
type NodeHolder = { content?: AdfNode[] }
// Each editor-normal node's index among its siblings in the document it was normalized from.
export type SourceIndexes = ReadonlyMap<AdfNode, number>
// The editor joins two text nodes where the reader would join their editor-normal forms.
export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean {
if (previous.type !== 'text' || node.type !== 'text') return false
@@ -14,13 +17,19 @@ export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean
}
export function toEditorNormal(document: AdfDocument): AdfDocument {
return editorNormalForm(document).normal
}
export function editorNormalForm(document: AdfDocument): { normal: AdfDocument; sources: SourceIndexes } {
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
const sources = new Map<AdfNode, number>()
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
const content = mergeAdjacentText(nodeContent(entry.source), joinsWhenEditorNormal)
const content = mergedTextRuns(nodeContent(entry.source), joinsWhenEditorNormal)
if (content.length === 0) continue
entry.holder.content = content.map((source) => {
entry.holder.content = content.map(({ first, node: source }) => {
const holder = normalNode(source)
sources.set(holder, first)
// Atlassian's schema forbids content on a text node, so the editor drops it.
if (source.type !== 'text') pending.push({ holder, source })
return holder
@@ -28,7 +37,16 @@ export function toEditorNormal(document: AdfDocument): AdfDocument {
}
// ADF's schema requires content on doc, so an empty one stays.
if (document.content !== undefined && normal.content === undefined) normal.content = []
return normal
return { normal, sources }
}
// A node's index in a list of siblings, or in a list of one, counted in the document the siblings were normalized from.
export function sourceIndex(nodes: readonly AdfNode[], index: number, sources: SourceIndexes): number {
const first = nodes[0]
const node = nodes[index]
const from = first === undefined ? undefined : sources.get(first)
const at = node === undefined ? undefined : sources.get(node)
return from === undefined || at === undefined ? index : at - from
}
function normalNode(node: AdfNode): AdfNode {
+13 -11
View File
@@ -6,13 +6,13 @@ import type { AdfNode } from '../../adf/document.ts'
import type { LineContainer } from '../line-container.ts'
import type { PortableLineFallback } from '../emit/inline-line.ts'
import { lineContainer, markedLine, propertyRuns } from '../../conformance/property-harness.ts'
import { judgedLine, portableLeaves, reduceInline, trimmedEdges, withoutFallback } from './inline-reduction.ts'
import { portableJudgement, portableLeaves, reduceInline, withoutFallback } from './inline-reduction.ts'
import { success, type Result } from '../../result.ts'
test('reduces a line alike dropping every run a pass may or the first alone', () => {
fc.assert(
fc.property(markedLine, lineContainer, (nodes, container) => {
assert.deepEqual(reduceInline(nodes, container, [], 0), firstRunLine(nodes, container))
assert.deepEqual(reduceInline(nodes, container, { depth: 0, path: [], sources: new Map() }), firstRunLine(nodes, container))
}),
// Lines the fixed seed rarely generates: a later run must not drop with the first, or one pass drops every copy of a repeated mark run.
{
@@ -58,18 +58,20 @@ test('refuses an unspellable-run fallback that takes no mark', () => {
assert.equal(refused.ok ? 'kept' : refused.error.code, 'unsupported-node-shape')
})
// Drops only the first run each pass finds, with every misflanked highlight, and takes no cap.
// Judges each pass spanned, else parted, dropping only the first run it finds, with every misflanked highlight, and takes no cap.
function firstRunLine(nodes: readonly AdfNode[], container: LineContainer): Result<AdfNode[]> {
const leaves = portableLeaves(nodes, container, [], 0)
const leaves = portableLeaves(nodes, container, { depth: 0, path: [], sources: new Map() })
if (!leaves.ok) return leaves
for (let current = leaves.value; ; ) {
const judged = judgedLine(current, container, [])
if (!judged.ok) return judged
const { fallback, leaves: line } = judged.value
if (fallback === undefined) return success(line)
const fixed = withoutFallback(line, firstRun(fallback), [])
for (let current: readonly AdfNode[] = leaves.value; ; ) {
const spanned = portableJudgement(current, 'spanned', container, [])
if (!spanned.ok) return spanned
if (spanned.value.kind === 'written') return success(spanned.value.written)
const parted = portableJudgement(spanned.value.line, 'parted', container, [])
if (!parted.ok) return parted
if (parted.value.kind === 'written') return success(parted.value.written)
const fixed = withoutFallback(parted.value.line, firstRun(parted.value.fallback), [])
if (!fixed.ok) return fixed
current = trimmedEdges(fixed.value, 'spanned')
current = fixed.value
}
}
+53 -52
View File
@@ -8,41 +8,54 @@ import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } f
import { imageAside } from '../image-aside.ts'
import { insideRun, runsSharedWithNext } from '../emit/inline-runs.ts'
import { isEmphasis } from '../mark-spellings.ts'
import { joinsWhenEditorNormal } from './editor-normal.ts'
import { joinsWhenEditorNormal, sourceIndex, type SourceIndexes } from './editor-normal.ts'
import { largestNesting } from '../../nesting.ts'
import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../../adf/shown-text.ts'
import { portableLineFallback, readBackPasses, type PortableLineFallback } from '../emit/inline-line.ts'
import { portableLineFallback, settledLine, type LineJudgement, type PortableLineFallback } from '../emit/inline-line.ts'
import { spellDestination } from '../commonmark/link-syntax.ts'
// What stands beside a leaf's edge: whether a line edge does, and how many runs hold the leaf and its neighbour both.
type Beside = { lineEdge: boolean; sharedRuns: number }
// `leaves` is the line `fallback` was judged on.
type JudgedLine = { fallback: PortableLineFallback | undefined; leaves: AdfNode[] }
// How many of the leaf's marks edge whitespace keeps, or that it drops at a line edge.
type EdgeOutcome = { kind: 'drop' } | { depth: number; kind: 'keep' }
type PlacedLeaf = { after: Beside; before: Beside; leaf: AdfNode }
// Where a reduction stands: its path, counted in the caller's document by `sources`, and its level as the emitter counts it.
export type ReductionPlace = { depth: number; path: ConvertErrorPath; sources: SourceIndexes }
const highlight = 'backgroundColor'
const highlightMark: AdfMark = { type: highlight }
const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong']
const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link']
export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
const leaves = portableLeaves(nodes, container, path, depth)
export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, place: ReductionPlace): Result<AdfNode[]> {
const leaves = portableLeaves(nodes, container, place)
if (!leaves.ok) return leaves
return spellableLine(leaves.value, container, path)
const { path } = place
// Ends: withoutFallback refuses a pass that drops no mark, and trimmedEdges adds none.
return settledLine<readonly AdfNode[], PortableLineFallback, AdfNode[]>(leaves.value, {
giveUpEmphasis: withoutEmphasis,
judge: (line, grouping) => portableJudgement(line, grouping, container, path),
spansEachPass: true,
take: (line, fallback) => withoutFallback(line, fallback, path),
})
}
export function portableLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
const leaves = inlineLeaves(nodes, container, path, depth)
// The leaves before the spanned trim `portableJudgement` gives them.
export function portableLeaves(nodes: readonly AdfNode[], container: LineContainer, place: ReductionPlace): Result<AdfNode[]> {
const leaves = inlineLeaves(nodes, container, place)
if (!leaves.ok) return leaves
return success(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value, 'spanned')), 'spanned'))
return success(highlightsInsideSharedMarks(trimmedEdges(leaves.value, 'spanned')))
}
export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, place: ReductionPlace): Result<AdfNode[]> {
if (place.depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, place.path)
const leaves: AdfNode[] = []
let joinsNext = false
for (const [index, node] of nodes.entries()) {
const held = nodeLeaves(node, container, [...path, 'content', index], depth)
const held = nodeLeaves(node, container, { ...place, path: [...place.path, 'content', sourceIndex(nodes, index, place.sources)] })
if (!held.ok) return held
if (held.value.length === 0) continue
const block = partsShownText(node.type)
@@ -53,7 +66,7 @@ export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer
return success(leaves)
}
function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
function nodeLeaves(node: AdfNode, container: LineContainer, place: ReductionPlace): Result<AdfNode[]> {
const marks = nodeMarks(node)
if (node.type === 'text') return success(textLeaves(node.text, marks, container))
if (node.type === 'hardBreak') return success([lineBreak(container)])
@@ -63,7 +76,7 @@ function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorP
if (['media', 'mediaInline'].includes(node.type)) return success(mediaLeaves(node, marks, container))
if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(node, marks, container))
const own = textLeaves(node.text ?? shownText(node), marks, container)
const held = inlineLeaves(nodeContent(node), container, path, depth + 1)
const held = inlineLeaves(nodeContent(node), container, { ...place, depth: place.depth + 1 })
if (!held.ok) return held
return success(own.length > 0 && held.value.length > 0 ? [...own, textLeaf(' ', []), ...held.value] : [...own, ...held.value])
}
@@ -173,11 +186,10 @@ export function trimmedEdges(leaves: readonly AdfNode[], grouping: RunGrouping):
// Terminates: a pass that changes a leaf drops whitespace from the leaf's edge or takes a mark off that whitespace, and merging adds no whitespace or mark.
for (let current = leaves; ; ) {
const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsWhenEditorNormal))
const shared = runsSharedWithNext(merged, grouping)
let changed = false
const trimmed: AdfNode[] = []
for (const [index, leaf] of merged.entries()) {
const edges = leafEdges(leaf, beside(merged[index - 1], shared[index - 1]), beside(merged[index + 1], shared[index]))
for (const { after, before, leaf } of placedLeaves(merged, grouping)) {
const edges = leafEdges(leaf, before, after)
if (edges === undefined) {
trimmed.push(leaf)
continue
@@ -198,6 +210,11 @@ function withoutEdgeBreaks(leaves: readonly AdfNode[]): AdfNode[] {
return leaves.slice(first, last + 1)
}
function placedLeaves(leaves: readonly AdfNode[], grouping: RunGrouping): PlacedLeaf[] {
const shared = runsSharedWithNext(leaves, grouping)
return leaves.map((leaf, index) => ({ after: beside(leaves[index + 1], shared[index]), before: beside(leaves[index - 1], shared[index - 1]), leaf }))
}
function beside(neighbour: AdfNode | undefined, sharedRuns = 0): Beside {
return { lineEdge: neighbour === undefined || neighbour.type === 'hardBreak', sharedRuns }
}
@@ -209,50 +226,34 @@ function leafEdges(leaf: AdfNode, before: Beside, after: Beside): AdfNode[] | un
if (text === undefined || marks.some((mark) => mark.type === 'code')) return undefined
const lead = text.slice(0, text.search(/[^ \t]|$/))
const trail = text.slice(text.search(/[ \t]*$/))
const leadDepth = edgeDepth(marks, before, lead)
const trailDepth = edgeDepth(marks, after, trail)
if (leadDepth === marks.length && trailDepth === marks.length) return undefined
if (lead === text) return leadDepth === undefined || trailDepth === undefined ? [] : [textLeaf(text, marks.slice(0, Math.min(leadDepth, trailDepth)))]
const leading = edgeOutcome(marks, before, lead)
const trailing = edgeOutcome(marks, after, trail)
if (leading.kind === 'keep' && trailing.kind === 'keep' && leading.depth === marks.length && trailing.depth === marks.length) return undefined
if (lead === text) return leading.kind === 'drop' || trailing.kind === 'drop' ? [] : [textLeaf(text, marks.slice(0, Math.min(leading.depth, trailing.depth)))]
const edges: AdfNode[] = []
if (lead !== '' && leadDepth !== undefined) edges.push(textLeaf(lead, marks.slice(0, leadDepth)))
if (lead !== '' && leading.kind === 'keep') edges.push(textLeaf(lead, marks.slice(0, leading.depth)))
const core = text.slice(lead.length, text.length - trail.length)
if (core !== '') edges.push(textLeaf(core, marks))
if (trail !== '' && trailDepth !== undefined) edges.push(textLeaf(trail, marks.slice(0, trailDepth)))
if (trail !== '' && trailing.kind === 'keep') edges.push(textLeaf(trail, marks.slice(0, trailing.depth)))
return edges
}
// How many of the leaf's marks the edge whitespace keeps, `undefined` where it drops at a line edge. Text not marked `code` holds every run over it, so the runs it shares are its outermost marks.
function edgeDepth(marks: readonly AdfMark[], { lineEdge, sharedRuns }: Beside, whitespace: string): number | undefined {
if (whitespace === '') return marks.length
// Text not marked `code` holds every run over it, so the runs it shares are its outermost marks.
function edgeOutcome(marks: readonly AdfMark[], { lineEdge, sharedRuns }: Beside, whitespace: string): EdgeOutcome {
if (whitespace === '') return { depth: marks.length, kind: 'keep' }
// A run crossing a hard break holds this whitespace at the edge of a line, where CommonMark strips it.
if (lineEdge && sharedRuns > 0) return undefined
if (lineEdge && sharedRuns > 0) return { kind: 'drop' }
const stripping = marks.findIndex((mark, index) => index >= sharedRuns && edgeStrippingMarks.includes(mark.type))
const kept = stripping === -1 ? marks.length : stripping
return kept === 0 && lineEdge ? undefined : kept
const depth = stripping === -1 ? marks.length : stripping
return depth === 0 && lineEdge ? { kind: 'drop' } : { depth, kind: 'keep' }
}
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<AdfNode[]> {
// Terminates: withoutFallback refuses a pass that takes no mark, and trimmedEdges adds none to any character.
for (let current = leaves, pass = 1; ; pass += 1) {
const judged = judgedLine(current, container, path)
if (!judged.ok) return judged
const { fallback, leaves: line } = judged.value
if (fallback === undefined) return success(line)
const fixed = withoutFallback(line, fallback, path)
if (!fixed.ok) return fixed
current = trimmedEdges(pass === readBackPasses ? withoutEmphasis(fixed.value) : fixed.value, 'spanned')
}
}
// Judged as the writer writes a line: spanned where that reads back, else parted and trimmed by the parted runs, the line a fallback's indexes name.
export function judgedLine(spanned: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<JudgedLine> {
const spannedFallback = portableLineFallback(spanned, container, path, 'spanned')
if (!spannedFallback.ok) return spannedFallback
if (spannedFallback.value === undefined) return success({ fallback: undefined, leaves: [...spanned] })
const parted = trimmedEdges(spanned, 'parted')
const partedFallback = portableLineFallback(parted, container, path, 'parted')
if (!partedFallback.ok) return partedFallback
return success({ fallback: partedFallback.value, leaves: parted })
// Each grouping's line trimmed by its own runs, which a fallback's indexes name.
export function portableJudgement(leaves: readonly AdfNode[], grouping: RunGrouping, container: LineContainer, path: ConvertErrorPath): Result<LineJudgement<readonly AdfNode[], PortableLineFallback, AdfNode[]>> {
const line = trimmedEdges(leaves, grouping)
const fallback = portableLineFallback(line, container, path, grouping)
if (!fallback.ok) return fallback
return success(fallback.value === undefined ? { kind: 'written', written: line } : { fallback: fallback.value, kind: 'fallback', line })
}
export function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallback, path: ConvertErrorPath): Result<AdfNode[]> {
+3 -1
View File
@@ -378,7 +378,9 @@ they are handed, most of them returning `void`, and `parseInlineContent` reads a
output.
Once `Scan`'s pieces have owners, the bracket resolution — `linkedAlt` through `linkTo`,
`holdsLink`, and `closeBracket` through `altText` — can leave `inline-content.ts` (about 690 lines)
for a module of its own.
for a module of its own. That module resolves an image once, into a node holding its alt text and
its link, so `imageAlt`, `resolvedPieces` and `directiveMarkPieces` no longer filter and regroup
`ImageLink` pieces. Found by the architecture pass of the release review of 0.3.0, 2026-11-08.
### 118. Spell a portable link's title through `oneLine`.