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 Walk = { blocks: readonly PlacedBlock[]; headroom: number }
type WalkedItem = { node: AdfNode; walk: Walk } type WalkedItem = { node: AdfNode; walk: Walk }
const largestListMarker = 999999999 export const largestListMarker = 999999999
// Bare because tryList admits no item carrying attributes, marks or text. // Bare because tryList admits no item carrying attributes, marks or text.
const listItemOpener = spellDirectiveOpener('listItem', undefined, '') 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 } 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> { export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
const emitted = emitLine(nodes, container, path) 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 }) const emission = lineSegments(nodes, container, path, { carried: new Set(), openingLinkAsDirective: false })
if (!emission.ok) return emission 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) 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) const verdict = lineVerdict(emission.value.segments, container)
if (assembled.openingLinkAsDirective) return success({ kind: 'opening-link' }) return success(verdict.kind === 'line' ? undefined : verdict)
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 })
} }
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined { 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> { function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> {
const assembled = assembleInlineLine(segments, container) const verdict = lineVerdict(segments, container)
if (assembled.openingLinkAsDirective) return success({ fallback: 'opening-link' }) if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' })
if (assembled.unspellableRun !== undefined) return success({ fallback: assembled.unspellableRun }) if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.run })
const claimed = claimedLine(assembled.line, container) if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path)
if (claimed !== undefined) { return success({ line: verdict.text })
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 })
} }
function claimedLine(line: string, container: LineContainer): number | undefined { // The fallbacks in the order a line takes them, or the line where it takes none.
if (container !== 'paragraph') return undefined function lineVerdict(segments: readonly InlineSegment[], container: LineContainer): PlainLineFallback | { kind: 'line'; text: string } {
const index = line.split('\n').findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) const assembled = assembleInlineLine(segments, container)
return index === -1 ? undefined : index 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. // 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[] { 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 { 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', () => { test('spells a panel as an alert in the GitHub word for its colour', () => {
const panel = (panelType: string | undefined): string => 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('info'), '> [!NOTE]\n>\n> Check it.\n')
assert.equal(panel('note'), '> [!IMPORTANT]\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') 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', () => { 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.')) const nested = node('nestedExpand', { title: 'Inner' }, said('Deep.'))
assert.equal( 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', '> [!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') 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', () => { 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')) 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', {}, 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') 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', () => { test('keeps the CommonMark blocks in their spelling and drops their attributes and marks', () => {
const localId = { localId: 'a' } const localId = { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05' }
assert.equal(plain(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: 'a' }, text('h'))), 'x\n\n## h\n') 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({ 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: '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', { language: 'carry' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n')
assert.equal(plain(node('codeBlock', {})), '```\n```\n') assert.equal(plain(node('codeBlock', {})), '```\n```\n')
assert.equal(plain(node('rule', { color: '#000' })), '---\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', {}, item(said('a')))), '1. a\n')
assert.equal(plain(node('orderedList', { order: -1 }, 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') 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', () => { 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('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('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') 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(said('stray'), item(said('b')), text('loose'))), '- stray\n- b\n- loose\n')
assert.equal(plain(bulletList()), '') 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({ 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(bulletList(item(bulletList(item(bulletList(item())))))), '- -\n')
assert.equal(plain(node('nestedExpand', {}, node('tableCell', {}, said('c')))), '> [!NOTE]-\n>\n> c\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 type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts' import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { blockNodeModel } from '../../adf/block-nodes.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 { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineLeaves, isBlockNodeType, reduceInline, writableHref } from './plain-inline.ts' import { inlineLeaves, isBlockNodeType, reduceInline, writableHref } from './plain-inline.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts'
@@ -107,26 +107,29 @@ function concatenated(results: readonly Result<AdfNode[]>[]): Result<AdfNode[]>
return success(blocks) 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[]> { function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
let sequence = mergedLists(blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0)) let sequence = mergedLists(blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0))
for (let index = 0; index < sequence.length; index += 1) { for (let index = 0; index < sequence.length; index += 1) {
const block = sequence[index] const listed = sequence[index]
if (block === undefined || !['bulletList', 'orderedList', 'table'].includes(block.type)) continue 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 if (commonMarkSpelling(block, reduction.path, reduction.depth, reduction.memo)?.ok === true) continue
sequence = mergedLists([...sequence.slice(0, index), ...heldBlocks(block), ...sequence.slice(index + 1)]) const held = nodeContent(block).flatMap(nodeContent)
index = Math.max(-1, index - 2) 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) return success(sequence)
} }
function heldBlocks(node: AdfNode): AdfNode[] { // A numbered list whose markers run past CommonMark's keeps its numbers as text in a bullet list.
const blocks: AdfNode[] = [] function numberedPastMarkers(list: AdfNode): AdfNode {
for (const child of nodeContent(node)) { const order = nodeAttrs(list)['order']
const held = ['listItem', 'tableCell', 'tableHeader', 'tableRow'].includes(child.type) ? heldBlocks(child) : [child] const items = nodeContent(list)
for (const block of held) blocks.push(block) 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' }
return blocks
} }
function mergedLists(blocks: readonly AdfNode[]): AdfNode[] { 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. // 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 { 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' } return { content, type: 'listItem' }
} }
@@ -241,10 +245,14 @@ function taskBlocks(child: AdfNode, at: Reduction): Result<AdfNode[]> {
} }
if (child.type !== 'blockTaskItem') return reduceStanding(child, at) if (child.type !== 'blockTaskItem') return reduceStanding(child, at)
const blocks = reduceBlocks(nodeContent(child), at) const blocks = reduceBlocks(nodeContent(child), at)
if (!blocks.ok) return blocks return blocks.ok ? success(marked(blocks.value, marker)) : 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]) // 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[]> { function reduceTable(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
+4 -2
View File
@@ -60,7 +60,8 @@ chunk clearing a §11 seam.
the measured one. the measured one.
- [ ] **33 — A carried mark run costs the line one re-emit (`0.2.0`).** `adfToMarkdown` spends 23 s - [ ] **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 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] **24 — The conformance gates have a directory (`0.2.0`).**
- [x] **25 — AGENTS.md §8 and §11 are findable (`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`).** - [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. 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 - 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 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 — - `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 `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 `href` percent-encoded until one does, and a mark run CommonMark's flanking or matching cannot