From e9de1214251b1ccdb8c11d8461927a88601856fc Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 8 Nov 2026 20:30:08 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20architecture=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=2011=20=F0=9F=9F=A1=20(0=20misread);=20one=20line?= =?UTF-8?q?=20policy,=20one=20reduction=20run,=20named=20sweep=20stages,?= =?UTF-8?q?=20spelling=20memo,=20block=20text,=20link=20options,=20HTML=20?= =?UTF-8?q?refusals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adf/document.ts | 15 ++- src/html/carry-content.ts | 30 +++-- src/html/data-attributes.ts | 25 +++-- src/html/parse/html-to-adf.ts | 16 ++- src/html/parse/node-element.ts | 5 +- src/markdown/emit/adf-to-markdown.ts | 43 ++++--- src/markdown/emit/block-text.ts | 48 +++++--- src/markdown/emit/inline-line.ts | 67 +++++++---- src/markdown/emit/spelling-questions.ts | 2 +- src/markdown/emit/unspellable-runs.ts | 56 ++++++---- src/markdown/inline-content.ts | 2 +- src/markdown/mark-spellings.ts | 7 +- src/markdown/parse/markdown-to-adf.ts | 4 +- .../portable/adf-to-portable-markdown.test.ts | 1 + .../portable/adf-to-portable-markdown.ts | 53 +++++---- src/markdown/portable/editor-normal.test.ts | 11 +- src/markdown/portable/editor-normal.ts | 26 ++++- .../portable/inline-reduction.test.ts | 24 ++-- src/markdown/portable/inline-reduction.ts | 105 +++++++++--------- todo.md | 4 +- 20 files changed, 329 insertions(+), 215 deletions(-) diff --git a/src/adf/document.ts b/src/adf/document.ts index 161a4d3..446cb41 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -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 } diff --git a/src/html/carry-content.ts b/src/html/carry-content.ts index 1207505..e58cd3e 100644 --- a/src/html/carry-content.ts +++ b/src/html/carry-content.ts @@ -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, '
').map(({ html }) => html).join('
') - return shownBlocks(node, '\n').map(({ code, html }) => (code === undefined ? `

${html}

` : `
${html}
`)).join('') + return shownBlocks(node, '\n').map(({ html, part: { code } }) => (code === undefined ? `

${html}

` : `
${html}
`)).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 ? '
' : 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 ? '
' : 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. diff --git a/src/html/data-attributes.ts b/src/html/data-attributes.ts index 7e4e51d..1d40240 100644 --- a/src/html/data-attributes.ts +++ b/src/html/data-attributes.ts @@ -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 { +// 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 { 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 { + 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 { diff --git a/src/html/parse/html-to-adf.ts b/src/html/parse/html-to-adf.ts index c506b12..0d31451 100644 --- a/src/html/parse/html-to-adf.ts +++ b/src/html/parse/html-to-adf.ts @@ -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 = `` @@ -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 `

`, 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 , or at a line feed before the next block` + // After a line's `

`, 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) diff --git a/src/html/parse/node-element.ts b/src/html/parse/node-element.ts index 8cafaa6..41a5e8b 100644 --- a/src/html/parse/node-element.ts +++ b/src/html/parse/node-element.ts @@ -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 = `` const stop = html.indexOf('<', tag.end) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index a6dc213..2e50a5c 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -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 +type Spell = () => Result | undefined +// ask: spell the node, keeping the spelling unless refused; take: spell the node, reading and dropping what is kept. +export type SpellingMemo = Readonly Result | 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 { 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 { - 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 | 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 | 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() + // 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 | undefined { diff --git a/src/markdown/emit/block-text.ts b/src/markdown/emit/block-text.ts index 26e9e52..6317dfb 100644 --- a/src/markdown/emit/block-text.ts +++ b/src/markdown/emit/block-text.ts @@ -5,7 +5,7 @@ export type BlockText = Readonly 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. diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index dff4b89..e9349af 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -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 = { + giveUpEmphasis: (line: Line) => Line + judge: (line: Line, grouping: RunGrouping) => Result> + // A line whose fallbacks rewrite its nodes judges its spanned line again on every pass. + spansEachPass: boolean + take: (line: Line, fallback: LineFallback) => Result +} + 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 = { fallback: LineFallback; kind: 'fallback'; line: Line } | { kind: 'written'; written: Written } + type LineFallbacks = { carried: Set; 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 { - 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 { - // 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: Line, action: FallbackAction): Result { + // 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> { + 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 { 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 }) diff --git a/src/markdown/emit/spelling-questions.ts b/src/markdown/emit/spelling-questions.ts index 1c5ec37..d876a84 100644 --- a/src/markdown/emit/spelling-questions.ts +++ b/src/markdown/emit/spelling-questions.ts @@ -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' diff --git a/src/markdown/emit/unspellable-runs.ts b/src/markdown/emit/unspellable-runs.ts index 6817539..8326016 100644 --- a/src/markdown/emit/unspellable-runs.ts +++ b/src/markdown/emit/unspellable-runs.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 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): 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, { 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. diff --git a/src/markdown/inline-content.ts b/src/markdown/inline-content.ts index 1a5c852..7e1842f 100644 --- a/src/markdown/inline-content.ts +++ b/src/markdown/inline-content.ts @@ -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 diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index 6718d8b..d0206e9 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -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) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index a65b62b..48c534a 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -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 { 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 } diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index 9d959c3..cdf644a 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -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', () => { diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index e55eee3..630f42b 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -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 @@ -41,7 +40,7 @@ const blockReducers: Readonly> = { 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 { 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 { 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[]): Result { @@ -183,7 +179,7 @@ function listOf(items: readonly AdfNode[], type: string): AdfNode[] { } function paragraphOf(nodes: readonly AdfNode[], reduction: Reduction): Result { - 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 { 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 { - 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 { } function reduceItems(node: AdfNode, reduction: Reduction, itemBlocks: (child: AdfNode, at: Reduction) => Result): Result { - 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 { 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 function reduceTask(task: AdfNode, at: Reduction): Result { 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 { 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 { 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 } diff --git a/src/markdown/portable/editor-normal.test.ts b/src/markdown/portable/editor-normal.test.ts index 37dec43..765e725 100644 --- a/src/markdown/portable/editor-normal.test.ts +++ b/src/markdown/portable/editor-normal.test.ts @@ -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) +}) diff --git a/src/markdown/portable/editor-normal.ts b/src/markdown/portable/editor-normal.ts index aa88f1a..ab65c86 100644 --- a/src/markdown/portable/editor-normal.ts +++ b/src/markdown/portable/editor-normal.ts @@ -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 + // 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() 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 { diff --git a/src/markdown/portable/inline-reduction.test.ts b/src/markdown/portable/inline-reduction.test.ts index e60aca4..b8968d4 100644 --- a/src/markdown/portable/inline-reduction.test.ts +++ b/src/markdown/portable/inline-reduction.test.ts @@ -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 { - 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 } } diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index 7d016f9..5b8ada5 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -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 { - const leaves = portableLeaves(nodes, container, path, depth) +export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, place: ReductionPlace): Result { + 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(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 { - 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 { + 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 { - 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 { + 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 { +function nodeLeaves(node: AdfNode, container: LineContainer, place: ReductionPlace): Result { 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 { - // 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 { - 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> { + 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 { diff --git a/todo.md b/todo.md index 49a74e5..a8a4e94 100644 --- a/todo.md +++ b/todo.md @@ -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`.