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

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

This commit is contained in:
2026-10-09 11:55:32 +02:00
parent 84db16613a
commit 45b5446f63
6 changed files with 52 additions and 65 deletions
+8 -8
View File
@@ -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<Record<string, InlineNodeModel>> = {
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<Record<string, InlineNodeModel>> = {
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 {
+1 -1
View File
@@ -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[] {
+1 -1
View File
@@ -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')
})
+39 -52
View File
@@ -27,19 +27,17 @@ type InlineContext = {
bracketed: boolean
carried: ReadonlySet<number>
flavour: WrittenFlavour
joinsBreaks: boolean
openingLinkAsDirective: boolean
parted: ReadonlySet<number>
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<number>; flavour: WrittenFlavour; openingLinkAsDirective: boolean; parted: Set<number> }
type LineFallbacks = { carried: Set<number>; 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<PortableLineFallback | undefined> {
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<EmittedLine> {
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<null> {
// 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<null> {
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<Emission> {
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>): 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<number>, index: number): boolean {
return node?.type === 'hardBreak' && !carries(node, carried, index)
}
function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
@@ -264,7 +253,7 @@ function carries(node: AdfNode, carried: ReadonlySet<number>, 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}`)] })
}
+2 -2
View File
@@ -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<AdfNode[]> {
}
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 {
+1 -1
View File
@@ -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`.