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:
+10
-5
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <`, '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 <`, '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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user