From 45b5446f63b7bf13a4302f2e7642e5ad10da4ce7 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 9 Oct 2026 11:55:32 +0200 Subject: [PATCH] Answer architecture round 3: one joinsBreaks flag in place of the parted set and its retry loops, a directive link spells its break, and takesMarks names the table's field --- src/adf/inline-nodes.ts | 16 ++-- src/conformance/adf-schema.test.ts | 2 +- src/markdown/emit/adf-to-markdown.test.ts | 2 +- src/markdown/emit/inline-line.ts | 91 ++++++++++------------- src/markdown/parse/inline-content.ts | 4 +- todo.md | 2 +- 6 files changed, 52 insertions(+), 65 deletions(-) diff --git a/src/adf/inline-nodes.ts b/src/adf/inline-nodes.ts index 7c6b57a..edcec5b 100644 --- a/src/adf/inline-nodes.ts +++ b/src/adf/inline-nodes.ts @@ -2,15 +2,15 @@ import type { AttributeVocabulary } from './attribute-vocabulary.ts' export type InlineNodeModel = { attributes: AttributeVocabulary - marks: boolean + takesMarks: boolean textAttribute?: string } export const inlineNodes: Readonly> = { - date: { attributes: { localId: 'string', timestamp: 'string' }, marks: true }, - emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, marks: true, textAttribute: 'text' }, - hardBreak: { attributes: { localId: 'string', text: 'string' }, marks: false }, - inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' }, marks: true }, + date: { attributes: { localId: 'string', timestamp: 'string' }, takesMarks: true }, + emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, takesMarks: true, textAttribute: 'text' }, + hardBreak: { attributes: { localId: 'string', text: 'string' }, takesMarks: false }, + inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' }, takesMarks: true }, mediaInline: { attributes: { alt: 'string', @@ -23,10 +23,10 @@ export const inlineNodes: Readonly> = { type: 'string', width: 'number', }, - marks: true, + takesMarks: true, }, - mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', text: 'string', userType: 'string' }, marks: true, textAttribute: 'text' }, - status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, marks: true, textAttribute: 'text' }, + mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', text: 'string', userType: 'string' }, takesMarks: true, textAttribute: 'text' }, + status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, takesMarks: true, textAttribute: 'text' }, } export function inlineNodeModel(type: string): InlineNodeModel | undefined { diff --git a/src/conformance/adf-schema.test.ts b/src/conformance/adf-schema.test.ts index 33789b9..e536c6f 100644 --- a/src/conformance/adf-schema.test.ts +++ b/src/conformance/adf-schema.test.ts @@ -78,7 +78,7 @@ test("the ADF JSON Schemas hold no type the tables leave unspelled, the pinned c test('the inline node table gives a type marks exactly where the ADF JSON Schemas give it a marks key', () => { const { marked } = schemaTypes() - for (const [type, model] of Object.entries(inlineNodes)) assert.equal(model.marks, marked.has(type), type) + for (const [type, model] of Object.entries(inlineNodes)) assert.equal(model.takesMarks, marked.has(type), type) }) function spelled(): Spelled[] { diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 820e5f2..64780f0 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -723,7 +723,7 @@ test('spells a mark run across the hard break between two nodes holding the mark assert.equal(readsBack(document(paragraph(marked('a', em, link), hardBreak, marked('b', em)))), '_[a](/u)\\\nb_\n') assert.equal(readsBack(document(paragraph(marked('a', em), hardBreak, hardBreak, marked('b', em)))), '_a\\\n\\\nb_\n') const directive: AdfMark = { attrs: { href: '/u', id: 'x' }, type: 'link' } - assert.equal(readsBack(document(paragraph(marked('a', directive), hardBreak, marked('b', directive)))), '!adf:link[a]{href="/u" id=x}\\\n!adf:link[b]{href="/u" id=x}\n') + assert.equal(readsBack(document(paragraph(marked('a', directive), hardBreak, marked('b', directive)))), '!adf:link[a!adf:hardBreak{}b]{href="/u" id=x}\n') readsBack(document(paragraph(marked(']: /x', link, { type: 'code' }), hardBreak, marked('b', link)))) assert.equal(readsBack(document(paragraph({ text: 'x', type: 'text' }, marked('.', em), hardBreak, marked('b', em)))), 'x!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\".\\",\\"type\\":\\"text\\"}"}\\\n_b_\n') }) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 356c34d..d8e07e5 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -27,19 +27,17 @@ type InlineContext = { bracketed: boolean carried: ReadonlySet flavour: WrittenFlavour + joinsBreaks: boolean openingLinkAsDirective: boolean - parted: ReadonlySet path: ConvertErrorPath spansLines: boolean } -type InlineRun = MarkedRun | { index: number; kind: 'plain'; node: AdfNode } - -type MarkedRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } +type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode } type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | { fallback?: undefined; line: string } -type LineFallbacks = { carried: Set; flavour: WrittenFlavour; openingLinkAsDirective: boolean; parted: Set } +type LineFallbacks = { carried: Set; flavour: WrittenFlavour; joinsBreaks: boolean; openingLinkAsDirective: boolean } export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] } @@ -56,16 +54,12 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve } export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { - const fallbacks: LineFallbacks = { carried: new Set(), flavour: 'portable', openingLinkAsDirective: false, parted: new Set() } - // Terminates because each pass parts a hard break the last one joined. - for (;;) { - const emission = lineSegments(nodes, container, path, fallbacks) - if (!emission.ok) return emission - if (emission.value.carry !== undefined) return failure('unsupported-node-shape', 'an inline node on a portable line has no spelling but the carry', path) - const verdict = lineVerdict(emission.value.segments, container, 'portable') - if (verdict.kind === 'unspellable-run' && partJoinedBreaks(fallbacks, nodes, verdict.runs[0])) continue - return success(verdict.kind === 'line' ? undefined : verdict) - } + // Unjoined: the spelling `emitLine` falls back to where the joined one fails. + const emission = lineSegments(nodes, container, path, { carried: new Set(), flavour: 'portable', joinsBreaks: false, openingLinkAsDirective: false }) + if (!emission.ok) return emission + if (emission.value.carry !== undefined) return failure('unsupported-node-shape', 'an inline node on a portable line has no spelling but the carry', path) + const verdict = lineVerdict(emission.value.segments, container, 'portable') + return success(verdict.kind === 'line' ? undefined : verdict) } export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: WrittenFlavour): string | undefined { @@ -84,13 +78,13 @@ export function tryImageLine(alt: string | undefined, href: string): string | un } function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result { - const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false, parted: new Set() } + const fallbacks: LineFallbacks = { carried: new Set(), flavour, joinsBreaks: nodes.some((node) => node.type === 'hardBreak'), openingLinkAsDirective: false } // Terminates because takeFallback refuses a pass that took no new fallback. for (;;) { const emission = lineSegments(nodes, container, path, fallbacks) if (!emission.ok) return emission if (emission.value.carry !== undefined) { - const taken = takeFallback(fallbacks, emission.value.carry, nodes, path) + const taken = takeFallback(fallbacks, emission.value.carry, path) if (!taken.ok) return taken continue } @@ -98,34 +92,28 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con if (attempt.line !== undefined) { return success({ line: attempt.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments }) } - const taken = takeFallback(fallbacks, attempt.fallback, nodes, path) + const taken = takeFallback(fallbacks, attempt.fallback, path) if (!taken.ok) return taken } } -function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', nodes: readonly AdfNode[], path: ConvertErrorPath): Result { +// A line's first fallback stops runs joining across hard breaks, so no fallback takes a break `joinedBreaks` joined. +function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', path: ConvertErrorPath): Result { + if (fallbacks.joinsBreaks) { + fallbacks.joinsBreaks = false + return success(null) + } if (fallback === 'opening-link') { if (fallbacks.openingLinkAsDirective) return failure('unsupported-node-shape', 'an opening link spelled as a directive still reads as a link definition, so the line has no spelling left', path) fallbacks.openingLinkAsDirective = true return success(null) } - if (partJoinedBreaks(fallbacks, nodes, fallback)) return success(null) const before = fallbacks.carried.size for (let index = fallback.first; index <= fallback.last; index += 1) fallbacks.carried.add(index) if (fallbacks.carried.size === before) return failure('unsupported-node-shape', 'a carry took no inline node the line had not carried, so the line has no spelling left', path) return success(null) } -// An unmarked hard break inside a range is one `joinsRun` joined; parting the run there spares both sides the fallback. -function partJoinedBreaks(fallbacks: LineFallbacks, nodes: readonly AdfNode[], range: NodeRange): boolean { - const before = fallbacks.parted.size - for (let index = range.first; index <= range.last; index += 1) { - const node = nodes[index] - if (node?.type === 'hardBreak' && nodeMarks(node).length === 0 && !fallbacks.carried.has(index)) fallbacks.parted.add(index) - } - return fallbacks.parted.size > before -} - function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, path, spansLines: container === 'paragraph' } const emission = emitRun(nodes, 0, 0, context) @@ -211,13 +199,17 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): InlineRun[] { const runs: InlineRun[] = [] - for (const [offset, node] of nodes.entries()) { + for (let offset = 0; offset < nodes.length; offset += 1) { + const node = nodes[offset] + if (node === undefined) continue const index = firstIndex + offset // `depth` indexes each node's marks, outermost first; a carried node spells its marks inside the carry. const mark = carries(node, context.carried, index) ? undefined : nodeMarks(node)[depth] const previous = runs[runs.length - 1] - if (mark === undefined && previous?.kind === 'marked' && joinsRun(nodes, offset, firstIndex, previous, depth, context)) { - previous.nodes.push(node) + const joined = mark === undefined && previous?.kind === 'marked' && context.joinsBreaks ? joinedBreaks(nodes, offset, firstIndex, previous.mark, depth, context.carried) : 0 + if (previous?.kind === 'marked' && joined > 0) { + for (const held of nodes.slice(offset, offset + joined)) previous.nodes.push(held) + offset += joined - 1 continue } if (mark === undefined) { @@ -230,23 +222,20 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number return runs } -// A directive's [content] holds no line ending, and `!adf:hardBreak{}` there reads worse than closing and reopening it, so only an emphasis or link spelling spans the hard breaks between two nodes it marks. -function joinsRun(nodes: readonly AdfNode[], offset: number, firstIndex: number, run: MarkedRun, depth: number, context: InlineContext): boolean { - const kind = markSpelling(run.mark.type)?.kind - if (kind !== 'emphasis' && kind !== 'link') return false - const joinable = (at: number): boolean => { - const node = nodes[at] - return node?.type === 'hardBreak' && !carries(node, context.carried, firstIndex + at) && !context.parted.has(firstIndex + at) - } - if (!joinable(offset)) return false - // A later break in a sequence joins because the first one found the node closing the run. - if (run.nodes.at(-1)?.type === 'hardBreak') return true - let ahead = offset + 1 - while (joinable(ahead)) ahead += 1 +// The hard breaks from `offset` an emphasis or link run spans because the node after them holds its mark; a directive would spell each `!adf:hardBreak{}`. +function joinedBreaks(nodes: readonly AdfNode[], offset: number, firstIndex: number, mark: AdfMark, depth: number, carried: ReadonlySet): number { + const kind = markSpelling(mark.type)?.kind + if (kind !== 'emphasis' && kind !== 'link') return 0 + let ahead = offset + while (isUncarriedBreak(nodes[ahead], carried, firstIndex + ahead)) ahead += 1 const next = nodes[ahead] - if (next === undefined || carries(next, context.carried, firstIndex + ahead)) return false + if (next === undefined || carries(next, carried, firstIndex + ahead)) return 0 const nextMark = nodeMarks(next)[depth] - return nextMark !== undefined && identicalMark(nextMark, run.mark) + return nextMark !== undefined && identicalMark(nextMark, mark) ? ahead - offset : 0 +} + +function isUncarriedBreak(node: AdfNode | undefined, carried: ReadonlySet, index: number): boolean { + return node?.type === 'hardBreak' && !carries(node, carried, index) } function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet): boolean { @@ -264,7 +253,7 @@ function carries(node: AdfNode, carried: ReadonlySet, index: number): bo if (carried.has(index)) return true if (node.type === 'text') return !isSpellableText(node) || typeof node.text !== 'string' || node.text === '' const model = inlineNodeModel(node.type) - if (model === undefined || (!model.marks && nodeMarks(node).length > 0)) return true + if (model === undefined || (!model.takesMarks && nodeMarks(node).length > 0)) return true const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute] return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined } @@ -324,11 +313,9 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context) const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined if (link !== undefined) return link - const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true }) + const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false }) if (!inner.ok) return inner if (inner.value.carry !== undefined) return inner - // A link `tryLink` declined holds the hard breaks `joinsRun` joined; the carry's fallback parts them. - if (inner.value.segments.some((segment) => segment.text.includes('\n'))) return success({ carry: range }) return success({ segments: [syntax(spellInlineDirectiveOpener(mark.type)), ...inner.value.segments, syntax(`]${attributes}`)] }) } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index aaf26a9..9c02fc5 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -325,7 +325,7 @@ function linkMark({ destination, title }: LinkDefinition): AdfMark { return { attrs: title === undefined ? { href: destination } : { href: destination, title }, type: 'link' } } -// ADF holds no empty text node and a hard break takes no marks, so the destination text carries the link. +// ADF holds no empty text node and a hard break takes no marks, so the destination text carries the link ahead of the text's hard breaks; an empty destination leaves the breaks alone. function linkTo(nodes: readonly Inline[], definition: LinkDefinition): Inline[] { if (!nodes.every(takesNoMarks)) return applyMark(nodes, linkMark(definition)) return definition.destination === '' ? [...nodes] : [{ marks: [linkMark(definition)], text: definition.destination, type: 'text' }, ...nodes] @@ -349,7 +349,7 @@ function partText(items: readonly Inline[], scan: Scan): Result { } function takesNoMarks(item: Inline): boolean { - return isNode(item) && inlineNodeModel(item.type)?.marks === false + return isNode(item) && inlineNodeModel(item.type)?.takesMarks === false } function isNode(item: Inline): item is AdfNode { diff --git a/todo.md b/todo.md index fe27cfc..e668217 100644 --- a/todo.md +++ b/todo.md @@ -137,7 +137,7 @@ three directories. `*!adf:mention[@a]{id=…}*` and `[!adf:status[Done]{…}](/u)` build a node holding `em` or `link`. `full.json` gives these five no `marks`, and `stage-0.json` gives them `annotation` alone, so a site -may reject the document. `InlineNodeModel.marks` (`src/adf/inline-nodes.ts`) afterwards names the mark +may reject the document. `InlineNodeModel.takesMarks` (`src/adf/inline-nodes.ts`) afterwards names the mark types a node takes, and the emitter carries a node holding another. ### 33. Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.