Compare commits

...

4 Commits

6 changed files with 60 additions and 47 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ export type SpellingMemo = Map<AdfNode, KeptSpelling>
type Walk = { blocks: readonly PlacedBlock[]; headroom: number }
type WalkedItem = { node: AdfNode; walk: Walk }
const largestListMarker = 999999999
export const largestListMarker = 999999999
// Bare because tryList admits no item carrying attributes, marks or text.
const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
+16 -18
View File
@@ -35,7 +35,7 @@ type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } |
type LineFallbacks = { carried: Set<number>; openingLinkAsDirective: boolean }
export type PlainLineFallback = { kind: 'claimed-line'; line: number } | { kind: 'opening-link' } | { kind: 'unspellable-run'; run: MarkRun }
export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; run: MarkRun }
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
const emitted = emitLine(nodes, container, path)
@@ -53,11 +53,8 @@ export function plainLineFallback(nodes: readonly AdfNode[], container: LineCont
const emission = lineSegments(nodes, container, path, { carried: new Set(), openingLinkAsDirective: false })
if (!emission.ok) return emission
if (emission.value.carry !== undefined) return failure('unsupported-node-shape', 'an inline node on a plain line has no spelling but the carry', path)
const assembled = assembleInlineLine(emission.value.segments, container)
if (assembled.openingLinkAsDirective) return success({ kind: 'opening-link' })
if (assembled.unspellableRun !== undefined) return success({ kind: 'unspellable-run', run: assembled.unspellableRun })
const claimed = claimedLine(assembled.line, container)
return success(claimed === undefined ? undefined : { kind: 'claimed-line', line: claimed })
const verdict = lineVerdict(emission.value.segments, container)
return success(verdict.kind === 'line' ? undefined : verdict)
}
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
@@ -118,20 +115,21 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
}
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> {
const assembled = assembleInlineLine(segments, container)
if (assembled.openingLinkAsDirective) return success({ fallback: 'opening-link' })
if (assembled.unspellableRun !== undefined) return success({ fallback: assembled.unspellableRun })
const claimed = claimedLine(assembled.line, container)
if (claimed !== undefined) {
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(assembled.line.split('\n')[claimed])}`, path)
}
return success({ line: assembled.line })
const verdict = lineVerdict(segments, container)
if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' })
if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.run })
if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path)
return success({ line: verdict.text })
}
function claimedLine(line: string, container: LineContainer): number | undefined {
if (container !== 'paragraph') return undefined
const index = line.split('\n').findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later'))
return index === -1 ? undefined : index
// The fallbacks in the order a line takes them, or the line where it takes none.
function lineVerdict(segments: readonly InlineSegment[], container: LineContainer): PlainLineFallback | { kind: 'line'; text: string } {
const assembled = assembleInlineLine(segments, container)
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
if (assembled.unspellableRun !== undefined) return { kind: 'unspellable-run', run: assembled.unspellableRun }
const lines = assembled.line.split('\n')
const claimed = container === 'paragraph' ? lines.findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) : -1
return claimed === -1 ? { kind: 'line', text: assembled.line } : { kind: 'claimed-line', line: claimed, text: lines[claimed] ?? '' }
}
// spec/flavour.md, Inline nodes.
+1 -1
View File
@@ -87,7 +87,7 @@ function mediaLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[],
}
function noteLeaves(name: string): AdfNode[] {
return [textLeaf(`(${name} not included)`, [{ type: 'em' }])]
return [textLeaf(`(${name.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ')} not included)`, [{ type: 'em' }])]
}
function nonEmpty(value: unknown): string | undefined {
+12 -7
View File
@@ -99,7 +99,7 @@ test('refuses nesting past 500 levels wherever the reduction walks', () => {
test('spells a panel as an alert in the GitHub word for its colour', () => {
const panel = (panelType: string | undefined): string =>
plain(node('panel', panelType === undefined ? {} : { localId: 'a', panelType }, said('Check it.')))
plain(node('panel', panelType === undefined ? {} : { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType }, said('Check it.')))
assert.equal(panel('info'), '> [!NOTE]\n>\n> Check it.\n')
assert.equal(panel('note'), '> [!IMPORTANT]\n>\n> Check it.\n')
assert.equal(panel('tip'), '> [!TIP]\n>\n> Check it.\n')
@@ -114,7 +114,7 @@ test('spells a panel as an alert in the GitHub word for its colour', () => {
test('spells an expand and a nested expand as a folded callout titled by the marker line', () => {
const nested = node('nestedExpand', { title: 'Inner' }, said('Deep.'))
assert.equal(
plain(node('expand', { localId: 'a', title: 'Build log' }, said('Line.'), nested)),
plain(node('expand', { localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de', title: 'Build log' }, said('Line.'), nested)),
'> [!NOTE]- Build log\n>\n> Line.\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n',
)
assert.equal(plain(node('expand', {}, said('Line.'))), '> [!NOTE]-\n>\n> Line.\n')
@@ -122,7 +122,7 @@ test('spells an expand and a nested expand as a folded callout titled by the mar
})
test('spells a task list as a bullet list whose items lead with their state', () => {
const task = (state: string, value: string): AdfNode => node('taskItem', { localId: 'a', state }, text(value))
const task = (state: string, value: string): AdfNode => node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state }, text(value))
const nested = node('taskList', {}, task('TODO', 'Review'))
assert.equal(plain(node('taskList', {}, task('DONE', 'Write the spec'), nested, task('TODO', 'Ship it'))), '- [x] Write the spec\n - [ ] Review\n- [ ] Ship it\n')
assert.equal(plain(node('taskList', {}, nested, task('DONE', ''), said('Stray'))), '- - [ ] Review\n- [x]\n- Stray\n')
@@ -154,16 +154,19 @@ test('unwraps the containers plain markdown has no spelling for to their body bl
})
test('keeps the CommonMark blocks in their spelling and drops their attributes and marks', () => {
const localId = { localId: 'a' }
assert.equal(plain(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: 'a' }, text('h'))), 'x\n\n## h\n')
const localId = { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05' }
assert.equal(plain(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de' }, text('h'))), 'x\n\n## h\n')
assert.equal(plain({ attrs: localId, content: [said('q')], marks: [{ type: 'breakout' }], type: 'blockquote' }), '> q\n')
assert.equal(plain(node('codeBlock', { language: 'ts', wrap: true }, text('a\r\nb\u0000'))), '```ts\na\nb\n```\n')
assert.equal(plain(node('codeBlock', { language: 'carry' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n')
assert.equal(plain(node('codeBlock', {})), '```\n```\n')
assert.equal(plain(node('rule', { color: '#000' })), '---\n')
assert.equal(plain(node('orderedList', { localId: 'a', order: 3 }, item(said('c')))), '3. c\n')
assert.equal(plain(node('orderedList', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', order: 3 }, item(said('c')))), '3. c\n')
assert.equal(plain(node('orderedList', {}, item(said('a')))), '1. a\n')
assert.equal(plain(node('orderedList', { order: -1 }, item(said('a')))), '1. a\n')
const code: AdfNode = { content: [text('x')], type: 'codeBlock' }
assert.equal(plain(node('orderedList', { order: 1e10 }, item(said('Alpha')), item(code), item())), '- 10000000000. Alpha\n- 10000000001.\n\n ```\n x\n ```\n- 10000000002.\n')
assert.equal(plain(node('orderedList', { order: 999999999 }, item(said('a'))), node('orderedList', { order: 5 }, item(said('b')))), '- 999999999\\. a\n- 1000000000. b\n')
assert.equal(plain(node('heading', { level: 7 }, text('h'))), 'h\n')
})
@@ -205,6 +208,7 @@ test('keeps an external image wherever it stands and spells a stored file as its
test('spells an extension as its text attribute, else a note naming it, and a placeholder as nothing', () => {
assert.equal(plain(node('extension', { extensionKey: 'toc', text: 'Contents' }), node('extension', { extensionKey: 'jira-issues-table' })), 'Contents\n\n_(jira-issues-table not included)_\n')
assert.equal(plain(node('syncBlock', { resourceId: 'r' })), '_(synced block not included)_\n')
assert.equal(plain(node('extension', { extensionKey: 'jira\r\nissues\u0000' })), '_(jira issues not included)_\n')
assert.equal(plain(paragraph(text('a '), node('inlineExtension', { text: 'macro' }), text(' '), node('inlineExtension', {}), node('placeholder', { text: 'Type here' }))), 'a macro _(extension not included)_\n')
})
@@ -216,7 +220,8 @@ test('spells a node no row names, or one standing where no spelling holds it, as
assert.equal(plain(bulletList(said('stray'), item(said('b')), text('loose'))), '- stray\n- b\n- loose\n')
assert.equal(plain(bulletList()), '')
assert.equal(plain(bulletList(item({ content: [text('a\n \nb')], type: 'codeBlock' }))), '- ```\n a\n\n b\n ```\n')
assert.equal(plain(bulletList(item(node('rule', {}), said('Install')), item(said('Configure')))), '- Install\n- Configure\n')
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {}), said('Install')), item(said('Configure')))), '- Install\n- Configure\n')
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {})), item(said('Configure')))), '-\n- Configure\n')
assert.equal(plain(bulletList(item(bulletList(item(bulletList(item())))))), '- -\n')
assert.equal(plain(node('nestedExpand', {}, node('tableCell', {}, said('c')))), '> [!NOTE]-\n>\n> c\n')
})
+26 -18
View File
@@ -1,7 +1,7 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { commonMarkSpelling, type SpellingMemo } from './adf-to-markdown.ts'
import { commonMarkSpelling, largestListMarker, type SpellingMemo } from './adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineLeaves, isBlockNodeType, reduceInline, writableHref } from './plain-inline.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
@@ -107,26 +107,29 @@ function concatenated(results: readonly Result<AdfNode[]>[]): Result<AdfNode[]>
return success(blocks)
}
// A list or a table still taking the directive form gives way to its blocks.
// A list still taking the directive form gives way to its items' blocks.
function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
let sequence = mergedLists(blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0))
for (let index = 0; index < sequence.length; index += 1) {
const block = sequence[index]
if (block === undefined || !['bulletList', 'orderedList', 'table'].includes(block.type)) continue
const listed = sequence[index]
if (listed === undefined || (listed.type !== 'bulletList' && listed.type !== 'orderedList')) continue
const block = numberedPastMarkers(listed)
sequence[index] = block
if (commonMarkSpelling(block, reduction.path, reduction.depth, reduction.memo)?.ok === true) continue
sequence = mergedLists([...sequence.slice(0, index), ...heldBlocks(block), ...sequence.slice(index + 1)])
index = Math.max(-1, index - 2)
const held = nodeContent(block).flatMap(nodeContent)
const from = Math.max(0, index - 1)
sequence = [...sequence.slice(0, from), ...mergedLists([...sequence.slice(from, index), ...held, ...sequence.slice(index + 1, index + 2)]), ...sequence.slice(index + 2)]
index = from - 1
}
return success(sequence)
}
function heldBlocks(node: AdfNode): AdfNode[] {
const blocks: AdfNode[] = []
for (const child of nodeContent(node)) {
const held = ['listItem', 'tableCell', 'tableHeader', 'tableRow'].includes(child.type) ? heldBlocks(child) : [child]
for (const block of held) blocks.push(block)
}
return blocks
// A numbered list whose markers run past CommonMark's keeps its numbers as text in a bullet list.
function numberedPastMarkers(list: AdfNode): AdfNode {
const order = nodeAttrs(list)['order']
const items = nodeContent(list)
if (list.type !== 'orderedList' || typeof order !== 'number' || order + items.length - 1 <= largestListMarker) return list
return { content: items.map((item, offset) => itemOf(marked(nodeContent(item), `${order + offset}.`))), type: 'bulletList' }
}
function mergedLists(blocks: readonly AdfNode[]): AdfNode[] {
@@ -214,7 +217,8 @@ function listItem(blocks: Result<AdfNode[]>): Result<AdfNode[]> {
// A list item's first line reads as no rule and holds no line of spaces alone: the rule and the spaces give way.
function itemOf(blocks: readonly AdfNode[]): AdfNode {
const content = (blocks[0]?.type === 'rule' ? blocks.slice(1) : blocks).map((block) => (block.type === 'codeBlock' ? { ...block, content: nodeContent(block).map(blankedLines) } : block))
const rules = blocks.findIndex((block) => block.type !== 'rule')
const content = blocks.slice(rules === -1 ? blocks.length : rules).map((block) => (block.type === 'codeBlock' ? { ...block, content: nodeContent(block).map(blankedLines) } : block))
return { content, type: 'listItem' }
}
@@ -241,10 +245,14 @@ function taskBlocks(child: AdfNode, at: Reduction): Result<AdfNode[]> {
}
if (child.type !== 'blockTaskItem') return reduceStanding(child, at)
const blocks = reduceBlocks(nodeContent(child), at)
if (!blocks.ok) return blocks
const [first, ...rest] = blocks.value
if (first?.type === 'paragraph') return success([paragraph([text(`${marker} `), ...nodeContent(first)]), ...rest])
return success([paragraph([text(marker)]), ...blocks.value])
return blocks.ok ? success(marked(blocks.value, marker)) : blocks
}
// The marker leads the first paragraph, or stands as one where the blocks open with another.
function marked(blocks: readonly AdfNode[], marker: string): AdfNode[] {
const [first, ...rest] = blocks
if (first?.type === 'paragraph') return [paragraph([text(`${marker} `), ...nodeContent(first)]), ...rest]
return [paragraph([text(marker)]), ...blocks]
}
function reduceTable(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
+4 -2
View File
@@ -60,7 +60,8 @@ chunk clearing a §11 seam.
the measured one.
- [ ] **33 — A carried mark run costs the line one re-emit (`0.2.0`).** `adfToMarkdown` spends 23 s
on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking cannot spell re-emits the
whole line before riding the carry, quadratic in the runs (§11 Bounds). Make it linear.
whole line before riding the carry, quadratic in the runs (§11 Bounds), and the plain
reduction's `spellableLine` drops one mark per re-emit the same way. Make both linear.
- [x] **24 — The conformance gates have a directory (`0.2.0`).**
- [x] **25 — AGENTS.md §8 and §11 are findable (`0.2.0`).**
- [x] **26 — The two mutable structures say what they guarantee (`0.2.0`).**
@@ -235,7 +236,8 @@ chunk clearing a §11 seam.
rows it covered, padding at most to the table's cell count.
- A list stays a list: where CommonMark cannot hold a block inside an item, what gives way is
what a reader does not see — the spaces of a whitespace-only code line — and a rule opening an
item drops (a panel, 3 of 3, 2026-09-25).
item drops (a panel, 3 of 3, 2026-09-25); an ordered list running past `999999999` is a bullet
list keeping its numbers as text (a panel, 3 of 3, 2026-09-25).
- `code`, `em`, `link`, `strike` and `strong` stay and every other mark drops, keeping its text —
`subsup` too, since `~2~` is a strike on GitHub; a link no CommonMark escape writes has its
`href` percent-encoded until one does, and a mark run CommonMark's flanking or matching cannot