Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c0a6526b1 | |||
| 8418150cd3 | |||
| d35637c8fa | |||
| c46b8243ba |
@@ -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, '')
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user