Emitter 2b: the block-node directives, the pipe table and the refusal corpus
CI / gate (push) Successful in 5s
CI / gate (push) Successful in 5s
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and
|
||||
an HTML dialect.
|
||||
|
||||
**Status: pre-release — `adfToMarkdown` emits the CommonMark subset, nothing else is built.**
|
||||
**Status: pre-release — `adfToMarkdown` emits the CommonMark subset and the block nodes, nothing
|
||||
else is built.**
|
||||
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
|
||||
[`spec/flavour.md`](spec/flavour.md).
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ in_node() { docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/app" -
|
||||
in_node npm ci
|
||||
in_node npm run typecheck
|
||||
|
||||
test_output=$(in_node npm test)
|
||||
if ! test_output=$(in_node npm test 2>&1); then
|
||||
printf '%s\n' "$test_output"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$test_output"
|
||||
if printf '%s' "$test_output" | grep -q 'ℹ tests 0'; then
|
||||
echo 'the gate ran zero tests — failing instead of a vacuous green'
|
||||
|
||||
@@ -9,6 +9,9 @@ One directory per contract kind:
|
||||
`markdownToAdf` must build from it. One-way; the markdown is not canonical.
|
||||
- `errors/` — `<name>.md`: markdown input that must not convert. A `<name>.error` beside it
|
||||
pins which error.
|
||||
- `unspellable/` — `<name>.json`: ADF `adfToMarkdown` must refuse, the `ConvertErrorCode` in the
|
||||
`<name>.error` beside it. A maintainer decision (`todo.md`) moves a document from here to
|
||||
`round-trip/`.
|
||||
- `real-payloads/` — `<name>.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No
|
||||
expected markdown.
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
unspelled-node-attribute
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"localId": "01a03d5c-9b21-73f4-8e6a-0c47b1d9e2f8"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Every block node in the schema carries one.",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ambiguous-empty-code-block-language
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"language": ""
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "cargo build --release",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "codeBlock"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
unspelled-block-separation
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"title": "Full build log"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "The build ran for 11 minutes.",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"panelType": "warning"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "Three warnings went unread.",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "panel"
|
||||
}
|
||||
],
|
||||
"type": "expand"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
unspellable-link-destination
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://example.com/a)b"
|
||||
},
|
||||
"type": "link"
|
||||
}
|
||||
],
|
||||
"text": "The torque table",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
unspellable-link-title
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://example.com/torque",
|
||||
"title": "He said \"hi\""
|
||||
},
|
||||
"type": "link"
|
||||
}
|
||||
],
|
||||
"text": "The torque table",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ambiguous-empty-media-alt
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"layout": "center"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"alt": "",
|
||||
"type": "external",
|
||||
"url": "https://example.com/moon.png"
|
||||
},
|
||||
"type": "media"
|
||||
}
|
||||
],
|
||||
"type": "mediaSingle"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
unspellable-line-start
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "Torque the bolts",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"order": 2
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "Then sign off",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "listItem"
|
||||
}
|
||||
],
|
||||
"type": "orderedList"
|
||||
}
|
||||
],
|
||||
"type": "listItem"
|
||||
}
|
||||
],
|
||||
"type": "bulletList"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ambiguous-ordered-list-start
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"order": 1
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "Loosen the clamp",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "listItem"
|
||||
}
|
||||
],
|
||||
"type": "orderedList"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=93 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
|
||||
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=94 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfDocument, AdfMark, AdfNode } from './adf-document.ts'
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf-document.ts'
|
||||
import type { Result } from './result.ts'
|
||||
import { adfToMarkdown } from './index.ts'
|
||||
|
||||
@@ -116,7 +116,8 @@ test('refuses two adjacent lists of the same kind', () => {
|
||||
})
|
||||
|
||||
test('refuses a node type the canonical form does not cover', () => {
|
||||
assert.equal(code(adfToMarkdown(document({ type: 'panel' }))), 'unsupported-node-type')
|
||||
assert.equal(code(adfToMarkdown(document({ type: 'blockCard' }))), 'unsupported-node-type')
|
||||
assert.equal(code(adfToMarkdown(document({ type: 'toString' }))), 'unsupported-node-type')
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ type: 'mention' })))), 'unsupported-node-type')
|
||||
})
|
||||
|
||||
@@ -283,3 +284,92 @@ test('refuses a document nested deeper than the emitter carries', () => {
|
||||
test('emits an empty list item without trailing whitespace', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [{ type: 'listItem' }], type: 'bulletList' }))), '-\n')
|
||||
})
|
||||
|
||||
test('spells a block directive as its node type, arg and attributes', () => {
|
||||
const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' })
|
||||
assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), ':::panel warning\nx\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(panel({}))), ':::panel\nx\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), ':::caption\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '::syncBlock {localId=a}\n')
|
||||
})
|
||||
|
||||
test('refuses a directive attribute no section spells', () => {
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { rounded: true }, type: 'panel' }))), 'unspelled-node-attribute')
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { toString: 'x' }, type: 'panel' }))), 'unspelled-node-attribute')
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { localId: 4 }, type: 'panel' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { width: '50' }, type: 'layoutColumn' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('refuses an arg slot value no bare token spells', () => {
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { panelType: 'extra info' }, type: 'panel' }))), 'unspelled-node-attribute')
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { state: 2 }, type: 'taskItem' }))), 'unspelled-node-attribute')
|
||||
})
|
||||
|
||||
test('carries a block node mark in the reserved attribute', () => {
|
||||
const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' })
|
||||
assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), ':::layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), ':::layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n:::\n')
|
||||
})
|
||||
|
||||
test('refuses the content a directive body has no room for', () => {
|
||||
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'media' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document({ text: 'x', type: 'panel' }))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('separates two directive blocks in a container body by one line, two CommonMark blocks by a blank one', () => {
|
||||
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
|
||||
const panel = (...content: AdfNode[]): AdfDocument => document({ attrs: { panelType: 'info' }, content, type: 'panel' })
|
||||
assert.equal(markdown(adfToMarkdown(panel(text('a'), text('b')))), ':::panel info\na\n\nb\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(panel({ type: 'caption' }, { type: 'caption' }))), '::::panel info\n:::caption\n:::\n:::caption\n:::\n::::\n')
|
||||
assert.equal(code(adfToMarkdown(panel(text('a'), { type: 'caption' }))), 'unspelled-block-separation')
|
||||
assert.equal(code(adfToMarkdown(panel({ type: 'caption' }, text('a')))), 'unspelled-block-separation')
|
||||
assert.equal(code(adfToMarkdown(panel(paragraph(), text('a')))), 'unspelled-block-separation')
|
||||
})
|
||||
|
||||
test('spells the image form for exactly the centered external media shape', () => {
|
||||
const url = 'https://example.com/moon.png'
|
||||
const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument =>
|
||||
document({ attrs: { layout: 'center' }, content: [{ attrs, content, type: 'media' }], type: 'mediaSingle' })
|
||||
assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `\n`)
|
||||
assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `\n`)
|
||||
assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`)
|
||||
assert.equal(code(adfToMarkdown(single({ alt: '', type: 'external', url }))), 'ambiguous-empty-media-alt')
|
||||
assert.equal(code(adfToMarkdown(single({ alt: 'a\nb', type: 'external', url }))), 'unspellable-whitespace')
|
||||
assert.equal(code(adfToMarkdown(single({ alt: 'a\u0000b', type: 'external', url }))), 'unspellable-character')
|
||||
assert.equal(code(adfToMarkdown(single({ type: 'external', url: 'https://example.com/a b>c' }))), 'unspellable-link-destination')
|
||||
assert.equal(code(adfToMarkdown(single({ alt: 4, type: 'external', url }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(single({ type: 'external', url: 4 }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('spells a mediaSingle the image form does not fit as a directive', () => {
|
||||
const media: AdfNode = { attrs: { type: 'external', url: 'https://example.com/moon.png' }, type: 'media' }
|
||||
const single: AdfNode = { attrs: { layout: 'center' }, content: [media], marks: [{ type: 'border' }], type: 'mediaSingle' }
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(single))),
|
||||
':::mediaSingle {layout=center marks="[{\\"type\\":\\"border\\"}]"}\n::media {type=external url="https://example.com/moon.png"}\n:::\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('spells a table as a pipe table only where every row and cell is plain', () => {
|
||||
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
|
||||
const cell = (type: string, ...content: AdfNode[]): AdfNode => ({ content, type })
|
||||
const row = (...cells: AdfNode[]): AdfNode => ({ content: cells, type: 'tableRow' })
|
||||
const table = (...rows: AdfNode[]): AdfDocument => document({ content: rows, type: 'table' })
|
||||
const directive = (result: Result<string>): boolean => markdown(result).startsWith(':')
|
||||
const header = row(cell('tableHeader', text('Part')))
|
||||
assert.equal(markdown(adfToMarkdown(table(header, row(cell('tableCell', text('Bolt M8')))))), '| Part |\n| --- |\n| Bolt M8 |\n')
|
||||
assert.equal(markdown(adfToMarkdown(table(row(cell('tableHeader', text('a|b')))))), '| a\\|b |\n| --- |\n')
|
||||
assert.ok(directive(adfToMarkdown(table())))
|
||||
assert.ok(directive(adfToMarkdown(table(row()))))
|
||||
assert.ok(directive(adfToMarkdown(table({ type: 'tableRow' }))))
|
||||
assert.ok(directive(adfToMarkdown(table(header, row()))))
|
||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableCell', text('Bolt M8')))))))
|
||||
assert.ok(directive(adfToMarkdown(table(cell('tableHeader', text('Part'))))))
|
||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader'))))))
|
||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', text('a'), text('b')))))))
|
||||
assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { attrs: { localId: 'a' }, type: 'paragraph' }))))), 'unspelled-node-attribute')
|
||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', { attrs: { level: 1 }, type: 'heading' }))))))
|
||||
assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: ' a', type: 'text' }], type: 'paragraph' }))))), 'unspellable-whitespace')
|
||||
})
|
||||
|
||||
+123
-34
@@ -1,46 +1,75 @@
|
||||
import type { AdfDocument, AdfNode } from './adf-document.ts'
|
||||
import type { BlockDirective } from './block-directives.ts'
|
||||
import type { JsonValue } from './json-value.ts'
|
||||
import { emitInlineLine } from './markdown-inline.ts'
|
||||
import { blockDirective, spellDirectiveHeader } from './block-directives.ts'
|
||||
import { emitImageLine, emitInlineLine } from './markdown-inline.ts'
|
||||
import { emitPipeTable } from './markdown-tables.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
||||
import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
|
||||
import { isAdfDocument } from './adf-document.ts'
|
||||
import { largestNesting } from './nesting.ts'
|
||||
import { longestBacktickRun } from './backtick-runs.ts'
|
||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||
|
||||
type BlockContainer = 'directive' | 'document' | 'list-item'
|
||||
type EmittedBody = { fenceColons: number; text: string }
|
||||
type EmittedBlock = EmittedBody & { node: AdfNode; path: ConvertErrorPath }
|
||||
|
||||
const centeredMediaSingle = '{"layout":"center"}'
|
||||
const imageAttributes = ['alt', 'type', 'url']
|
||||
const largestListMarker = 999999999
|
||||
const listTypes = ['bulletList', 'orderedList']
|
||||
|
||||
export function adfToMarkdown(document: AdfDocument): Result<string> {
|
||||
if (!isAdfDocument(document)) return failure('not-an-adf-document', 'the value is not an ADF document', [])
|
||||
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
||||
const blocks = emitBlocks(document.content ?? [], false, [], 0)
|
||||
const blocks = emitBlocks(document.content ?? [], 'document', [], 0)
|
||||
if (!blocks.ok) return blocks
|
||||
return success(blocks.value === '' ? '' : `${blocks.value}\n`)
|
||||
return success(blocks.value.text === '' ? '' : `${blocks.value.text}\n`)
|
||||
}
|
||||
|
||||
function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean, path: ConvertErrorPath, depth: number): Result<string> {
|
||||
function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
|
||||
if (depth > largestNesting) return failure('unsupported-node-shape', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
|
||||
let output = ''
|
||||
let previous: AdfNode | undefined
|
||||
const blocks: EmittedBlock[] = []
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const nodePath = [...path, 'content', index]
|
||||
if (previous !== undefined) {
|
||||
if (listTypes.includes(node.type) && previous.type === node.type) {
|
||||
return failure('unspellable-adjacent-lists', `two adjacent ${node.type} nodes read back as one list`, nodePath)
|
||||
}
|
||||
if (inListItem && listTypes.includes(node.type)) {
|
||||
if (!interruptsParagraph(node)) {
|
||||
return failure('unspellable-line-start', `a ${node.type} that cannot interrupt the block above it has no tight spelling`, nodePath)
|
||||
}
|
||||
output += '\n'
|
||||
} else output += '\n\n'
|
||||
}
|
||||
const block = emitBlock(node, nodePath, depth)
|
||||
if (!block.ok) return block
|
||||
output += block.value
|
||||
previous = node
|
||||
blocks.push({ ...block.value, node, path: nodePath })
|
||||
}
|
||||
return success(output)
|
||||
let fenceColons = 0
|
||||
let text = ''
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
const previous = blocks[index - 1]
|
||||
if (previous !== undefined) {
|
||||
const separation = separationBetween(previous, block, container)
|
||||
if (!separation.ok) return separation
|
||||
text += separation.value
|
||||
}
|
||||
fenceColons = Math.max(fenceColons, block.fenceColons)
|
||||
text += block.text
|
||||
}
|
||||
return success({ fenceColons, text })
|
||||
}
|
||||
|
||||
function separationBetween(previous: EmittedBlock, next: EmittedBlock, container: BlockContainer): Result<string> {
|
||||
if (listTypes.includes(next.node.type) && previous.node.type === next.node.type) {
|
||||
return failure('unspellable-adjacent-lists', `two adjacent ${next.node.type} nodes read back as one list`, next.path)
|
||||
}
|
||||
if (container === 'list-item' && listTypes.includes(next.node.type)) {
|
||||
if (!interruptsParagraph(next.node)) {
|
||||
return failure('unspellable-line-start', `a ${next.node.type} that cannot interrupt the block above it has no tight spelling`, next.path)
|
||||
}
|
||||
return success('\n')
|
||||
}
|
||||
if (container !== 'directive') return success('\n\n')
|
||||
if (previous.fenceColons === 0 && next.fenceColons === 0) return success('\n\n')
|
||||
if (previous.fenceColons > 0 && next.fenceColons > 0) return success('\n')
|
||||
return failure(
|
||||
'unspelled-block-separation',
|
||||
`the canonical form leaves the separation between a ${previous.node.type} and a ${next.node.type} in a container body unspelled`,
|
||||
next.path,
|
||||
)
|
||||
}
|
||||
|
||||
function interruptsParagraph(node: AdfNode): boolean {
|
||||
@@ -48,26 +77,86 @@ function interruptsParagraph(node: AdfNode): boolean {
|
||||
return ((node.content ?? [])[0]?.content ?? []).length > 0
|
||||
}
|
||||
|
||||
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
|
||||
if (node.type === 'blockquote') return emitBlockquote(node, path, depth)
|
||||
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth)
|
||||
if (node.type === 'codeBlock') return emitCodeBlock(node, path)
|
||||
if (node.type === 'heading') return emitHeading(node, path)
|
||||
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
|
||||
if (node.type === 'blockquote') return plainBlock(emitBlockquote(node, path, depth))
|
||||
if (node.type === 'bulletList' || node.type === 'orderedList') return plainBlock(emitList(node, path, depth))
|
||||
if (node.type === 'codeBlock') return plainBlock(emitCodeBlock(node, path))
|
||||
if (node.type === 'heading') return plainBlock(emitHeading(node, path))
|
||||
if (node.type === 'paragraph') return emitParagraph(node, path)
|
||||
if (node.type === 'rule') return emitRule(node, path)
|
||||
if (node.type === 'rule') return plainBlock(emitRule(node, path))
|
||||
const directive = blockDirective(node.type)
|
||||
if (directive !== undefined) {
|
||||
if (node.type === 'mediaSingle') return emitMediaSingle(node, directive, path, depth)
|
||||
if (node.type === 'table') return emitTable(node, directive, path, depth)
|
||||
return emitDirectiveBlock(node, directive, path, depth)
|
||||
}
|
||||
if (node.type === 'hardBreak' || node.type === 'listItem' || node.type === 'text') {
|
||||
return failure('unsupported-node-shape', `a ${node.type} node cannot stand where a block belongs`, path)
|
||||
}
|
||||
return failure('unsupported-node-type', `the canonical form spells no block node of type ${node.type}`, path)
|
||||
}
|
||||
|
||||
function plainBlock(text: Result<string>): Result<EmittedBody> {
|
||||
if (!text.ok) return text
|
||||
return success({ fenceColons: 0, text: text.value })
|
||||
}
|
||||
|
||||
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
|
||||
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path)
|
||||
const header = spellDirectiveHeader(node, directive, path)
|
||||
if (!header.ok) return header
|
||||
const content = node.content ?? []
|
||||
if (directive.body === 'none') {
|
||||
if (content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path)
|
||||
return success({ fenceColons: 2, text: `::${header.value}` })
|
||||
}
|
||||
const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1)
|
||||
if (!body.ok) return body
|
||||
const fenceColons = Math.max(3, body.value.fenceColons + 1)
|
||||
const fence = ':'.repeat(fenceColons)
|
||||
const lines = body.value.text === '' ? '' : `${body.value.text}\n`
|
||||
return success({ fenceColons, text: `${fence}${header.value}\n${lines}${fence}` })
|
||||
}
|
||||
|
||||
function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Result<EmittedBody> {
|
||||
if (content.length === 0) return success({ fenceColons: 0, text: '' })
|
||||
return plainBlock(emitInlineLine(content, 'paragraph', path))
|
||||
}
|
||||
|
||||
function emitMediaSingle(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
|
||||
const image = imageShape(node)
|
||||
if (image === undefined) return emitDirectiveBlock(node, directive, path, depth)
|
||||
const mediaPath = [...path, 'content', 0]
|
||||
if (image.alt === '') return failure('ambiguous-empty-media-alt', 'an empty media alt and an absent one share one image spelling', mediaPath)
|
||||
return plainBlock(emitImageLine(image.alt, image.url, mediaPath))
|
||||
}
|
||||
|
||||
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
||||
const content = node.content ?? []
|
||||
const media = content[0]
|
||||
if (serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle || (node.marks ?? []).length > 0) return undefined
|
||||
if (media === undefined || content.length !== 1 || media.type !== 'media' || (media.marks ?? []).length > 0 || (media.content ?? []).length > 0) return undefined
|
||||
const attrs = media.attrs ?? {}
|
||||
const alt = attrs['alt']
|
||||
const url = attrs['url']
|
||||
if (Object.keys(attrs).some((key) => !imageAttributes.includes(key)) || attrs['type'] !== 'external') return undefined
|
||||
if (typeof url !== 'string' || (alt !== undefined && typeof alt !== 'string')) return undefined
|
||||
return { alt, url }
|
||||
}
|
||||
|
||||
function emitTable(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
|
||||
const pipe = emitPipeTable(node, path)
|
||||
if (pipe === undefined) return emitDirectiveBlock(node, directive, path, depth)
|
||||
return plainBlock(pipe)
|
||||
}
|
||||
|
||||
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
|
||||
const validation = validateBlockNode(node, [], path)
|
||||
if (!validation.ok) return validation
|
||||
const inner = emitBlocks(node.content ?? [], false, path, depth + 1)
|
||||
const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1)
|
||||
if (!inner.ok) return inner
|
||||
return success(
|
||||
inner.value
|
||||
inner.value.text
|
||||
.split('\n')
|
||||
.map((line) => (line === '' ? '>' : `> ${line}`))
|
||||
.join('\n'),
|
||||
@@ -152,11 +241,11 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<
|
||||
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<string> {
|
||||
const validation = validateBlockNode(item, [], path)
|
||||
if (!validation.ok) return validation
|
||||
const inner = emitBlocks(item.content ?? [], true, path, depth + 1)
|
||||
const inner = emitBlocks(item.content ?? [], 'list-item', path, depth + 1)
|
||||
if (!inner.ok) return inner
|
||||
if (inner.value === '') return success(marker.trimEnd())
|
||||
if (inner.value.text === '') return success(marker.trimEnd())
|
||||
const indent = ' '.repeat(marker.length)
|
||||
const lines = inner.value.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
|
||||
const lines = inner.value.text.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
|
||||
const first = lines[0] ?? ''
|
||||
if (isThematicBreak(first)) {
|
||||
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(first)}`, path)
|
||||
@@ -164,12 +253,12 @@ function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, dep
|
||||
return success(lines.join('\n'))
|
||||
}
|
||||
|
||||
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBody> {
|
||||
const validation = validateBlockNode(node, [], path)
|
||||
if (!validation.ok) return validation
|
||||
const content = node.content ?? []
|
||||
if (content.length === 0) return success('::paragraph')
|
||||
return emitInlineLine(content, 'paragraph', path)
|
||||
if (content.length === 0) return success({ fenceColons: 2, text: '::paragraph' })
|
||||
return plainBlock(emitInlineLine(content, 'paragraph', path))
|
||||
}
|
||||
|
||||
function emitRule(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { AdfMark, AdfNode } from './adf-document.ts'
|
||||
import type { AttributeKind } from './directive-attributes.ts'
|
||||
import type { JsonValue } from './json-value.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
||||
import { isBareToken, spellAttributeValue, spellAttributes, spellJsonAttribute } from './directive-attributes.ts'
|
||||
|
||||
export type BlockDirective = {
|
||||
argument?: string
|
||||
attributes: Readonly<Record<string, AttributeKind>>
|
||||
body: 'block' | 'inline' | 'none'
|
||||
}
|
||||
|
||||
const cellAttributes: Readonly<Record<string, AttributeKind>> = {
|
||||
background: 'string',
|
||||
colspan: 'number',
|
||||
colwidth: 'json',
|
||||
localId: 'string',
|
||||
rowspan: 'number',
|
||||
valign: 'string',
|
||||
}
|
||||
|
||||
const expandAttributes: Readonly<Record<string, AttributeKind>> = { localId: 'string', title: 'string' }
|
||||
|
||||
const extensionAttributes: Readonly<Record<string, AttributeKind>> = {
|
||||
extensionKey: 'string',
|
||||
extensionType: 'string',
|
||||
layout: 'string',
|
||||
localId: 'string',
|
||||
parameters: 'json',
|
||||
text: 'string',
|
||||
}
|
||||
|
||||
const itemAttributes: Readonly<Record<string, AttributeKind>> = { localId: 'string' }
|
||||
|
||||
const mediaAttributes: Readonly<Record<string, AttributeKind>> = {
|
||||
alt: 'string',
|
||||
collection: 'string',
|
||||
height: 'number',
|
||||
id: 'string',
|
||||
localId: 'string',
|
||||
occurrenceKey: 'string',
|
||||
type: 'string',
|
||||
url: 'string',
|
||||
width: 'number',
|
||||
}
|
||||
|
||||
const syncBlockAttributes: Readonly<Record<string, AttributeKind>> = { localId: 'string', resourceId: 'string' }
|
||||
|
||||
const blockDirectives: Readonly<Record<string, BlockDirective>> = {
|
||||
blockTaskItem: { argument: 'state', attributes: itemAttributes, body: 'block' },
|
||||
bodiedExtension: { attributes: extensionAttributes, body: 'block' },
|
||||
bodiedSyncBlock: { attributes: syncBlockAttributes, body: 'block' },
|
||||
caption: { attributes: itemAttributes, body: 'inline' },
|
||||
decisionItem: { attributes: { localId: 'string', state: 'string' }, body: 'inline' },
|
||||
decisionList: { attributes: itemAttributes, body: 'block' },
|
||||
expand: { attributes: expandAttributes, body: 'block' },
|
||||
extension: { attributes: extensionAttributes, body: 'none' },
|
||||
extensionFrame: { attributes: {}, body: 'block' },
|
||||
layoutColumn: { attributes: { localId: 'string', valign: 'string', width: 'number' }, body: 'block' },
|
||||
layoutSection: { attributes: itemAttributes, body: 'block' },
|
||||
media: { attributes: mediaAttributes, body: 'none' },
|
||||
mediaGroup: { attributes: {}, body: 'block' },
|
||||
mediaSingle: { attributes: { layout: 'string', localId: 'string', width: 'number', widthType: 'string' }, body: 'block' },
|
||||
multiBodiedExtension: { attributes: extensionAttributes, body: 'block' },
|
||||
nestedExpand: { attributes: expandAttributes, body: 'block' },
|
||||
panel: {
|
||||
argument: 'panelType',
|
||||
attributes: { localId: 'string', panelColor: 'string', panelIcon: 'string', panelIconId: 'string', panelIconText: 'string' },
|
||||
body: 'block',
|
||||
},
|
||||
syncBlock: { attributes: syncBlockAttributes, body: 'none' },
|
||||
table: { attributes: { displayMode: 'string', isNumberColumnEnabled: 'boolean', layout: 'string', localId: 'string', width: 'number' }, body: 'block' },
|
||||
tableCell: { attributes: cellAttributes, body: 'block' },
|
||||
tableHeader: { attributes: cellAttributes, body: 'block' },
|
||||
tableRow: { attributes: itemAttributes, body: 'block' },
|
||||
taskItem: { argument: 'state', attributes: itemAttributes, body: 'inline' },
|
||||
taskList: { attributes: itemAttributes, body: 'block' },
|
||||
}
|
||||
|
||||
export function blockDirective(type: string): BlockDirective | undefined {
|
||||
return Object.hasOwn(blockDirectives, type) ? blockDirectives[type] : undefined
|
||||
}
|
||||
|
||||
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<string> {
|
||||
const pairs: [string, string][] = []
|
||||
let argument = ''
|
||||
for (const [key, value] of Object.entries(node.attrs ?? {})) {
|
||||
if (key === directive.argument) {
|
||||
if (typeof value !== 'string' || !isBareToken(value)) {
|
||||
return failure('unspelled-node-attribute', `the ${node.type} attribute ${key} holds no bare token the arg slot spells`, path)
|
||||
}
|
||||
argument = ` ${value}`
|
||||
continue
|
||||
}
|
||||
const kind = attributeKind(directive, key)
|
||||
if (kind === undefined) return failure('unspelled-node-attribute', `the ${node.type} attribute ${key} has no canonical markdown spelling`, path)
|
||||
const spelled = spellAttributeValue(value, kind)
|
||||
if (spelled === undefined) return failure('unsupported-node-shape', `the ${node.type} attribute ${key} holds no ${kind}`, path)
|
||||
pairs.push([key, spelled])
|
||||
}
|
||||
const marks = node.marks ?? []
|
||||
if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))])
|
||||
const attributes = spellAttributes(pairs)
|
||||
return success(`${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`)
|
||||
}
|
||||
|
||||
function attributeKind(directive: BlockDirective, key: string): AttributeKind | undefined {
|
||||
return Object.hasOwn(directive.attributes, key) ? directive.attributes[key] : undefined
|
||||
}
|
||||
|
||||
function markValues(marks: readonly AdfMark[]): JsonValue {
|
||||
return marks.map((mark) => {
|
||||
const attrs = mark.attrs ?? {}
|
||||
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
|
||||
})
|
||||
}
|
||||
+26
-3
@@ -11,9 +11,10 @@ import { serializeCanonicalJson } from './canonical-json.ts'
|
||||
|
||||
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
|
||||
const roundTripRoot = join(corpusRoot, 'round-trip')
|
||||
const unspellableRoot = join(corpusRoot, 'unspellable')
|
||||
|
||||
const emittingDirectories = ['commonmark-subset']
|
||||
const pendingDirectories = ['block-nodes', 'inline-nodes']
|
||||
const emittingDirectories = ['block-nodes', 'commonmark-subset']
|
||||
const pendingDirectories = ['inline-nodes']
|
||||
|
||||
function directoryNames(root: string): string[] {
|
||||
return readdirSync(root, { withFileTypes: true })
|
||||
@@ -23,7 +24,11 @@ function directoryNames(root: string): string[] {
|
||||
}
|
||||
|
||||
function fixtureNames(directory: string, extension: string): string[] {
|
||||
return readdirSync(join(roundTripRoot, directory))
|
||||
return names(join(roundTripRoot, directory), extension)
|
||||
}
|
||||
|
||||
function names(root: string, extension: string): string[] {
|
||||
return readdirSync(root)
|
||||
.filter((name) => name.endsWith(extension))
|
||||
.map((name) => name.slice(0, -extension.length))
|
||||
.sort()
|
||||
@@ -67,6 +72,24 @@ for (const directory of emittingDirectories) {
|
||||
}
|
||||
}
|
||||
|
||||
test('unspellable pairs every .json with an .error', () => {
|
||||
assert.deepEqual(names(unspellableRoot, '.json'), names(unspellableRoot, '.error'))
|
||||
})
|
||||
|
||||
test('unspellable holds fixtures', () => {
|
||||
assert.ok(names(unspellableRoot, '.json').length > 0)
|
||||
})
|
||||
|
||||
for (const name of names(unspellableRoot, '.json')) {
|
||||
test(`unspellable/${name} is refused with the error it names`, () => {
|
||||
const parsed: unknown = JSON.parse(readFileSync(join(unspellableRoot, `${name}.json`), 'utf8'))
|
||||
assert.ok(isAdfDocument(parsed), `${name}.json is not an ADF document`)
|
||||
const result = adfToMarkdown(parsed)
|
||||
assert.ok(!result.ok, result.ok ? `emitted ${JSON.stringify(result.value)}` : '')
|
||||
assert.equal(result.error.code, readFileSync(join(unspellableRoot, `${name}.error`), 'utf8').trimEnd())
|
||||
})
|
||||
}
|
||||
|
||||
test('the corpus holds JSON to gate', () => {
|
||||
assert.ok(corpusJsonPaths().length > 0)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { JsonValue } from './json-value.ts'
|
||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||
|
||||
export type AttributeKind = 'boolean' | 'json' | 'number' | 'string'
|
||||
|
||||
const bareToken = /^[A-Za-z0-9_-]+$/
|
||||
|
||||
export function isBareToken(text: string): boolean {
|
||||
return bareToken.test(text)
|
||||
}
|
||||
|
||||
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
|
||||
if (pairs.length === 0) return ''
|
||||
const spelled = [...pairs].sort(([left], [right]) => (left < right ? -1 : 1)).map(([key, value]) => `${key}=${value}`)
|
||||
return `{${spelled.join(' ')}}`
|
||||
}
|
||||
|
||||
export function spellAttributeValue(value: JsonValue, kind: AttributeKind): string | undefined {
|
||||
if (kind === 'json') return spellJsonAttribute(value)
|
||||
if (kind === 'boolean') return typeof value === 'boolean' ? `${value}` : undefined
|
||||
if (kind === 'number') return typeof value === 'number' ? spell(JSON.stringify(value)) : undefined
|
||||
return typeof value === 'string' ? spell(value) : undefined
|
||||
}
|
||||
|
||||
export function spellJsonAttribute(value: JsonValue): string {
|
||||
return quote(serializeCanonicalJson(value, 'compact'))
|
||||
}
|
||||
|
||||
function spell(text: string): string {
|
||||
return isBareToken(text) ? text : quote(text)
|
||||
}
|
||||
|
||||
function quote(text: string): string {
|
||||
return JSON.stringify(text)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export type InlineSegment =
|
||||
|
||||
export type AssembledLine = { line: string; unspellableMark: string | undefined }
|
||||
|
||||
export type LineContainer = 'heading' | 'paragraph'
|
||||
export type LineContainer = 'heading' | 'paragraph' | 'table-cell'
|
||||
|
||||
type DelimiterRun = { character: string; closeMark: string | undefined; end: number; openMark: string | undefined; start: number }
|
||||
|
||||
@@ -127,8 +127,9 @@ function isSyntax(kind: InlineSegment['kind'] | undefined): boolean {
|
||||
}
|
||||
|
||||
function opensConstruct(scan: string, index: number, inLinkText: boolean, container: LineContainer, escaped: ReadonlySet<number>): boolean {
|
||||
const claimsLine = container === 'heading' ? closesHeading(scan, index) : claimsLineStart(scan, index)
|
||||
return claimsLine || claimsCharacter(scan, index, inLinkText, escaped)
|
||||
if (container === 'heading' && closesHeading(scan, index)) return true
|
||||
if (container === 'paragraph' && claimsLineStart(scan, index)) return true
|
||||
return claimsCharacter(scan, index, inLinkText, container, escaped)
|
||||
}
|
||||
|
||||
function claimsLineStart(scan: string, index: number): boolean {
|
||||
@@ -144,10 +145,11 @@ function closesHeading(scan: string, index: number): boolean {
|
||||
return index === 0 || /[ \t]/.test(scan.charAt(index - 1))
|
||||
}
|
||||
|
||||
function claimsCharacter(scan: string, index: number, inLinkText: boolean, escaped: ReadonlySet<number>): boolean {
|
||||
function claimsCharacter(scan: string, index: number, inLinkText: boolean, container: LineContainer, escaped: ReadonlySet<number>): boolean {
|
||||
const character = scan.charAt(index)
|
||||
const rest = scan.slice(index)
|
||||
if (inLinkText && (character === '[' || character === ']')) return true
|
||||
if (character === '|') return container === 'table-cell'
|
||||
if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1))
|
||||
if (character === '&') return startsEntityReference(rest)
|
||||
if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest))
|
||||
|
||||
+16
-3
@@ -20,7 +20,20 @@ const linkAttributes = ['href', 'title']
|
||||
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
|
||||
const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, container, inLinkText: false, path })
|
||||
if (!segments.ok) return segments
|
||||
const assembled = assembleInlineLine(segments.value, container)
|
||||
return finishLine(segments.value, container, path)
|
||||
}
|
||||
|
||||
export function emitImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): Result<string> {
|
||||
if (alt !== undefined && /[\n\r]/.test(alt)) return failure('unspellable-whitespace', 'a media alt holds a newline no image description spells', path)
|
||||
if (alt !== undefined && holdsNullCharacter(alt)) return failure('unspellable-character', 'a media alt holds a null character CommonMark replaces', path)
|
||||
const destination = spellDestination(href, path)
|
||||
if (!destination.ok) return destination
|
||||
const description: InlineSegment[] = alt === undefined ? [] : [{ kind: 'link-text', text: alt }]
|
||||
return finishLine([{ kind: 'syntax', text: '` }], 'paragraph', path)
|
||||
}
|
||||
|
||||
function finishLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<string> {
|
||||
const assembled = assembleInlineLine(segments, container)
|
||||
if (assembled.unspellableMark !== undefined) {
|
||||
return failure('unspellable-mark', `the ${assembled.unspellableMark} spelling cannot open or close where it sits`, path)
|
||||
}
|
||||
@@ -83,8 +96,8 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
|
||||
const types = (node.marks ?? []).map((mark) => mark.type)
|
||||
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
|
||||
if (node.type === 'hardBreak') {
|
||||
if (context.container === 'heading' || context.atBlockEnd) return success([{ kind: 'syntax', text: ':hardBreak{}' }])
|
||||
return success([{ kind: 'syntax', text: '\\\n' }])
|
||||
if (context.container === 'paragraph' && !context.atBlockEnd) return success([{ kind: 'syntax', text: '\\\n' }])
|
||||
return success([{ kind: 'syntax', text: ':hardBreak{}' }])
|
||||
}
|
||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node carries no text', path)
|
||||
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node carries content', path)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AdfNode } from './adf-document.ts'
|
||||
import { emitInlineLine } from './markdown-inline.ts'
|
||||
import { success, type ConvertErrorPath, type Result } from './result.ts'
|
||||
|
||||
export function emitPipeTable(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
|
||||
const rows = pipeRows(node)
|
||||
if (rows === undefined) return undefined
|
||||
const lines: string[] = []
|
||||
for (const [rowIndex, row] of rows.entries()) {
|
||||
const cells: string[] = []
|
||||
for (const [cellIndex, paragraph] of row.entries()) {
|
||||
const content = paragraph.content ?? []
|
||||
const line = content.length === 0 ? success('') : emitInlineLine(content, 'table-cell', [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
|
||||
if (!line.ok) return line
|
||||
cells.push(line.value)
|
||||
}
|
||||
lines.push(`| ${cells.join(' | ')} |`)
|
||||
if (rowIndex === 0) lines.push(`| ${cells.map(() => '---').join(' | ')} |`)
|
||||
}
|
||||
return success(lines.join('\n'))
|
||||
}
|
||||
|
||||
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
||||
const rows = node.content ?? []
|
||||
const columns = (rows[0]?.content ?? []).length
|
||||
if (!isPlain(node) || columns === 0) return undefined
|
||||
const grid: AdfNode[][] = []
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const cells = row.content ?? []
|
||||
if (row.type !== 'tableRow' || !isPlain(row) || cells.length !== columns) return undefined
|
||||
const wanted = index === 0 ? 'tableHeader' : 'tableCell'
|
||||
const paragraphs: AdfNode[] = []
|
||||
for (const cell of cells) {
|
||||
const paragraph = plainParagraph(cell)
|
||||
if (paragraph === undefined || cell.type !== wanted || !isPlain(cell)) return undefined
|
||||
paragraphs.push(paragraph)
|
||||
}
|
||||
grid.push(paragraphs)
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
function isPlain(node: AdfNode): boolean {
|
||||
return Object.keys(node.attrs ?? {}).length === 0 && (node.marks ?? []).length === 0 && node.text === undefined
|
||||
}
|
||||
|
||||
function plainParagraph(cell: AdfNode): AdfNode | undefined {
|
||||
const content = cell.content ?? []
|
||||
const paragraph = content[0]
|
||||
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !isPlain(paragraph)) return undefined
|
||||
const pipedCode = (paragraph.content ?? []).some((child) => (child.marks ?? []).some((mark) => mark.type === 'code') && (child.text ?? '').includes('|'))
|
||||
return pipedCode ? undefined : paragraph
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export type ConvertErrorCode =
|
||||
| 'ambiguous-empty-code-block-language'
|
||||
| 'ambiguous-empty-media-alt'
|
||||
| 'ambiguous-ordered-list-start'
|
||||
| 'not-an-adf-document'
|
||||
| 'reserved-adf-language'
|
||||
@@ -13,6 +14,7 @@ export type ConvertErrorCode =
|
||||
| 'unspellable-mark'
|
||||
| 'unspellable-whitespace'
|
||||
| 'unspelled-block-marks'
|
||||
| 'unspelled-block-separation'
|
||||
| 'unspelled-node-attribute'
|
||||
| 'unsupported-document-version'
|
||||
| 'unsupported-heading-level'
|
||||
|
||||
@@ -28,12 +28,12 @@ detail is settled at its own milestone.
|
||||
carries a `localId` with no spelling, `codeBlock` also `hideLineNumbers`, `uniqueId` and
|
||||
`wrap`, `blockquote` also marks, and `hardBreak` `text` and `localId` with no section for
|
||||
the carry fallback to reach. Picking one (directive sections for those nodes, or the opaque
|
||||
carry) is a permanent format decision (§8). Three collision sites are held out of the corpus
|
||||
meanwhile, each a choice between the absent attribute and the empty value: a `codeBlock`
|
||||
whose info string is empty and `media` with an empty `alt`, which one "exactly that shape"
|
||||
rule — as the CommonMark image already uses — could settle together, and an `orderedList`
|
||||
starting at 1, independent of the totality answer since `order: 9` keeps the markdown form
|
||||
either way. **Also blocked**: the link rule covers destination spaces only, so two shapes
|
||||
carry) is a permanent format decision (§8). Three collision sites wait in
|
||||
`corpus/unspellable/` meanwhile, each a choice between the absent attribute and the empty
|
||||
value: a `codeBlock` whose info string is empty and `media` with an empty `alt`, which one
|
||||
"exactly that shape" rule — as the CommonMark image already uses — could settle together,
|
||||
and an `orderedList` starting at 1, independent of the totality answer since `order: 9`
|
||||
keeps the markdown form either way. **Also blocked**: the link rule covers destination spaces only, so two shapes
|
||||
break §2 silently — href `https://example.com/a)b` emits `[t](https://example.com/a)b)`,
|
||||
read back as href `…/a` plus literal `b)`; title `He said "hi"` emits
|
||||
`[t](u "He said "hi"")`, which holds no title. Two defensible spellings each — angle
|
||||
@@ -42,7 +42,8 @@ detail is settled at its own milestone.
|
||||
directive block in a container body — an `expand` whose content is `paragraph` "A" then a
|
||||
`panel` (`panelType` `warning`) holding "B" spells `A` and `:::panel warning` either on
|
||||
consecutive lines or with a blank line between. Two defensible spellings, so §8 leaves the
|
||||
pick here.
|
||||
pick here; `unspelled-block-separation` refuses the pair meanwhile, an empty paragraph's
|
||||
`::paragraph` beside a CommonMark block included.
|
||||
- [x] **1d1 — The CommonMark subset**: blockquote, bulletList, codeBlock, heading, orderedList,
|
||||
paragraph, rule, listItem, hardBreak, text, code spans, and the `code`, `em`, `link`,
|
||||
`strike` and `strong` marks — one mark per text node; nesting is 1d3's.
|
||||
|
||||
Reference in New Issue
Block a user