This commit is contained in:
@@ -0,0 +1,587 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { Result } from '../../result.ts'
|
||||
import { adfToMarkdown } from '../../index.ts'
|
||||
|
||||
function document(...content: AdfNode[]): AdfDocument {
|
||||
return { content, type: 'doc', version: 1 }
|
||||
}
|
||||
|
||||
function paragraph(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'paragraph' }
|
||||
}
|
||||
|
||||
function code(result: Result<string>): string {
|
||||
return result.ok ? `emitted ${JSON.stringify(result.value)}` : result.error.code
|
||||
}
|
||||
|
||||
function markdown(result: Result<string>): string {
|
||||
return result.ok ? result.value : `${result.error.code}: ${result.error.message}`
|
||||
}
|
||||
|
||||
function path(result: Result<string>): readonly (number | string)[] {
|
||||
return result.ok ? ['emitted'] : result.error.path
|
||||
}
|
||||
|
||||
test('names the node a refusal came from', () => {
|
||||
const unspellable: AdfNode = { text: 'x', type: 'paragraph' }
|
||||
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'text' })))), ['content', 0, 'content', 1])
|
||||
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
|
||||
})
|
||||
|
||||
test('refuses a value that is not an ADF document', () => {
|
||||
assert.equal(code(adfToMarkdown({ type: 'doc', version: Number.NaN })), 'not-an-adf-document')
|
||||
})
|
||||
|
||||
test('refuses a document version the markdown cannot carry', () => {
|
||||
assert.equal(code(adfToMarkdown({ type: 'doc', version: 2 })), 'unsupported-document-version')
|
||||
})
|
||||
|
||||
test('carries a text node attribute no spelling holds', () => {
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, text: 'x', type: 'text' })))),
|
||||
':adf{json="{\\"attrs\\":{\\"localId\\":\\"a\\"},\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('spells a CommonMark block as a directive where its own spelling holds neither attribute nor mark', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'paragraph' }))), '::paragraph {localId=a}\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { wrap: true }, type: 'codeBlock' }))), ':::codeBlock {wrap=true}\n```\n```\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, type: 'hardBreak' }, { text: 'x', type: 'text' })))), ':hardBreak{localId=a}x\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ marks: [{ type: 'border' }], type: 'blockquote' }))), ':::blockquote {marks="[{\\"type\\":\\"border\\"}]"}\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'listItem' }))), ':::listItem\n:::\n')
|
||||
})
|
||||
|
||||
test('spells an ordered list from the order attribute its first marker is', () => {
|
||||
const items: AdfNode[] = [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }]
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: items, type: 'orderedList' }))), '::::orderedList\n:::listItem\nx\n:::\n::::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 1 }, content: items, type: 'orderedList' }))), '1. x\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: items, type: 'orderedList' }))), '2. x\n')
|
||||
})
|
||||
|
||||
test('spells a code block language no info string holds as an attribute', () => {
|
||||
const language = (value: string): string => markdown(adfToMarkdown(document({ attrs: { language: value }, type: 'codeBlock' })))
|
||||
assert.equal(language(''), ':::codeBlock {language=""}\n```\n```\n:::\n')
|
||||
assert.equal(language('a`b'), ':::codeBlock {language="a\\u0060b"}\n```\n```\n:::\n')
|
||||
assert.equal(language(' sql'), ':::codeBlock {language=" sql"}\n```\n```\n:::\n')
|
||||
assert.equal(language('adf'), ':::codeBlock {language="\\u0026#97;df"}\n```\n```\n:::\n')
|
||||
assert.equal(language('foo\\+bar'), ':::codeBlock {language="foo\\\\+bar"}\n```\n```\n:::\n')
|
||||
assert.equal(language('a\u0000b'), ':::codeBlock {language="a\\u0000b"}\n```\n```\n:::\n')
|
||||
assert.equal(language('a\tb'), ':::codeBlock {language="a\\tb"}\n```\n```\n:::\n')
|
||||
})
|
||||
|
||||
test('refuses a link destination CommonMark cannot spell', () => {
|
||||
const link = (href: string): AdfDocument => document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' }))
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/a)b'))), 'unspellable-link-destination')
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/a b>c'))), 'unspellable-link-destination')
|
||||
assert.equal(code(adfToMarkdown(link('<https://example.com/'))), 'unspellable-link-destination')
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/a\\b'))), 'unspellable-link-destination')
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/?a=1&b=2'))), 'unspellable-link-destination')
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/a\nb'))), 'unspellable-link-destination')
|
||||
const entity = 'https://example.com/?a=1&b=2'
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: entity }, type: 'link' }], text: entity, type: 'text' })))), 'unspellable-link-destination')
|
||||
assert.equal(markdown(adfToMarkdown(link('https://en.example.com/a_(b)'))), '[t](https://en.example.com/a_(b))\n')
|
||||
})
|
||||
|
||||
test('refuses a link title CommonMark cannot spell', () => {
|
||||
const titled = (title: string): AdfDocument =>
|
||||
document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', title }, type: 'link' }], text: 't', type: 'text' }))
|
||||
assert.equal(code(adfToMarkdown(titled('He said "hi"'))), 'unspellable-link-title')
|
||||
assert.equal(code(adfToMarkdown(titled('a\nb'))), 'unspellable-link-title')
|
||||
})
|
||||
|
||||
test('carries a link mark the link spelling cannot write', () => {
|
||||
const carried = (mark: AdfMark): string => markdown(adfToMarkdown(document(paragraph({ marks: [mark], text: 't', type: 'text' }))))
|
||||
assert.equal(
|
||||
carried({ attrs: { href: 'x', id: 'y' }, type: 'link' }),
|
||||
':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":\\"x\\",\\"id\\":\\"y\\"},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
assert.equal(carried({ attrs: { href: 4 }, type: 'link' }), ':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":4},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n')
|
||||
assert.equal(carried({ type: 'link' }), ':adf{json="{\\"marks\\":[{\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n')
|
||||
})
|
||||
|
||||
test('carries a mark the canonical spellings cannot nest', () => {
|
||||
const carried = (...marks: AdfMark[]): string => markdown(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' }))))
|
||||
assert.equal(carried({ type: 'annotation' }), ':adf{json="{\\"marks\\":[{\\"type\\":\\"annotation\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n')
|
||||
assert.equal(
|
||||
carried({ type: 'code' }, { type: 'strong' }),
|
||||
':adf{json="{\\"marks\\":[{\\"type\\":\\"code\\"},{\\"type\\":\\"strong\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
assert.equal(
|
||||
carried({ attrs: { colour: 'red' }, type: 'em' }),
|
||||
':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"colour\\":\\"red\\"},\\"type\\":\\"em\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
assert.equal(
|
||||
carried({ attrs: { localId: 'x' }, type: 'code' }),
|
||||
':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"localId\\":\\"x\\"},\\"type\\":\\"code\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('refuses whitespace CommonMark cannot hold', () => {
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: '\fa', type: 'text' })))), 'unspellable-whitespace')
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\rb', type: 'text' })))), 'unspellable-whitespace')
|
||||
})
|
||||
|
||||
test('refuses a line whose start block parsing would claim', () => {
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '```', type: 'text' })))), 'unspellable-line-start')
|
||||
})
|
||||
|
||||
test('refuses two adjacent lists of the same kind', () => {
|
||||
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }], type: 'bulletList' }
|
||||
assert.equal(code(adfToMarkdown(document(list, list))), 'unspellable-adjacent-lists')
|
||||
})
|
||||
|
||||
test('carries a node type no section spells', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf\n{\n "type": "blockCard"\n}\n```\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf\n{\n "type": "toString"\n}\n```\n')
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), ':adf{json="{\\"type\\":\\"blockCard\\"}"}\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```adf\n{\n "text": "x",\n "type": "text"\n}\n```\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```adf\n{\n "type": "hardBreak"\n}\n```\n')
|
||||
})
|
||||
|
||||
test('spells the code block whose language is the reserved info string', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), ':::codeBlock {language=adf}\n```\n```\n:::\n')
|
||||
})
|
||||
|
||||
test('breaks a mark run at the node it carries', () => {
|
||||
const strong: AdfMark = { type: 'strong' }
|
||||
const carried: AdfNode = { marks: [strong], type: 'placeholder' }
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [strong], text: 'a', type: 'text' }, carried, { marks: [strong], text: 'b', type: 'text' })))),
|
||||
'**a**:adf{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"type\\":\\"placeholder\\"}"}**b**\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('refuses a carried node nested deeper than the emitter carries', () => {
|
||||
let node: AdfNode = { type: 'blockCard' }
|
||||
for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockCard' }
|
||||
assert.equal(code(adfToMarkdown(document(node))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document(paragraph(node)))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('refuses a node whose content model the canonical form cannot emit', () => {
|
||||
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('spells a list its own content shape cannot hold as a directive', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), ':::bulletList\n::paragraph\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), ':::bulletList\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), ':::orderedList {order=2}\n:::\n')
|
||||
})
|
||||
|
||||
test('spells an ordered list no marker fits as a directive', () => {
|
||||
const item: AdfNode = { content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }
|
||||
const list = (order: number, items: number): AdfDocument =>
|
||||
document({ attrs: { order }, content: Array.from({ length: items }, () => item), type: 'orderedList' })
|
||||
assert.equal(markdown(adfToMarkdown(list(1.5, 1))), '::::orderedList {order="1.5"}\n:::listItem\nx\n:::\n::::\n')
|
||||
assert.equal(markdown(adfToMarkdown(list(999999999, 1))), '999999999. x\n')
|
||||
assert.equal(markdown(adfToMarkdown(list(999999999, 2))), '::::orderedList {order=999999999}\n:::listItem\nx\n:::\n:::listItem\nx\n:::\n::::\n')
|
||||
})
|
||||
|
||||
test('carries a code mark over anything but text', () => {
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], type: 'hardBreak' })))),
|
||||
':adf{json="{\\"marks\\":[{\\"type\\":\\"code\\"}],\\"type\\":\\"hardBreak\\"}"}\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('spells a heading level no ATX heading fits as a directive', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { level: 7 }, content: [{ text: 'x', type: 'text' }], type: 'heading' }))), ':::heading {level=7}\nx\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'heading' }))), ':::heading\nx\n:::\n')
|
||||
})
|
||||
|
||||
test('escapes only text that would otherwise open a construct', () => {
|
||||
const emitted = (text: string): string => markdown(adfToMarkdown(document(paragraph({ text, type: 'text' }))))
|
||||
assert.equal(emitted('<div>'), '\\<div>\n')
|
||||
assert.equal(emitted('a < b'), 'a < b\n')
|
||||
assert.equal(emitted('& & x'), '\\& & x\n')
|
||||
assert.equal(emitted('| a | b |'), '\\| a | b |\n')
|
||||
assert.equal(emitted(':mention[@x]{id=1}'), '\\:mention[@x]{id=1}\n')
|
||||
assert.equal(emitted(':::panel info'), '\\:::panel info\n')
|
||||
assert.equal(emitted('10:30 tomorrow'), '10:30 tomorrow\n')
|
||||
assert.equal(emitted('[a](b)'), '\\[a](b)\n')
|
||||
assert.equal(emitted('**bold**'), '\\*\\*bold\\*\\*\n')
|
||||
assert.equal(emitted('a `x` b'), 'a \\`x` b\n')
|
||||
assert.equal(emitted('~~struck~~'), '\\~~struck\\~~\n')
|
||||
assert.equal(emitted('a \\* b'), 'a \\\\\\* b\n')
|
||||
assert.equal(emitted('1. not a list'), '1\\. not a list\n')
|
||||
assert.equal(emitted('*"quoted"*'), '\\*"quoted"\\*\n')
|
||||
assert.equal(emitted('x"_y"'), 'x"\\_y"\n')
|
||||
})
|
||||
|
||||
test('escapes a heading closing sequence', () => {
|
||||
const heading = (text: string): string => markdown(adfToMarkdown(document({ attrs: { level: 2 }, content: [{ text, type: 'text' }], type: 'heading' })))
|
||||
assert.equal(heading('done #'), '## done \\#\n')
|
||||
assert.equal(heading('#tag first'), '## #tag first\n')
|
||||
})
|
||||
|
||||
test('wraps adjacent nodes carrying one mark once, and a differing mark twice', () => {
|
||||
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||
assert.equal(emitted(marked('a', { type: 'strong' }), marked('b', { type: 'strong' }, { type: 'em' })), '**a*b***\n')
|
||||
assert.equal(emitted(marked('a', { type: 'strong' }), marked('b', { type: 'em' })), '**a**_b_\n')
|
||||
const link = (href: string): AdfMark => ({ attrs: { href }, type: 'link' })
|
||||
assert.equal(emitted(marked('a', link('http://x')), marked('b', link('http://y'))), '[a](http://x)[b](http://y)\n')
|
||||
})
|
||||
|
||||
test('carries a mark spelling that cannot open or close where it sits', () => {
|
||||
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||
const strong: AdfMark = { type: 'strong' }
|
||||
assert.equal(
|
||||
emitted({ text: 'un', type: 'text' }, marked('-real', strong), { text: 'istic', type: 'text' }),
|
||||
'un:adf{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"text\\":\\"-real\\",\\"type\\":\\"text\\"}"}istic\n',
|
||||
)
|
||||
assert.equal(
|
||||
emitted(marked('C++', { type: 'em' }), { text: 'ish', type: 'text' }),
|
||||
':adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"C++\\",\\"type\\":\\"text\\"}"}ish\n',
|
||||
)
|
||||
assert.equal(
|
||||
emitted({ text: 'x', type: 'text' }, marked('.a', strong)),
|
||||
'x:adf{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"text\\":\\".a\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
assert.equal(emitted({ text: 'un ', type: 'text' }, marked('-real', strong), { text: ' istic', type: 'text' }), 'un **-real** istic\n')
|
||||
assert.equal(emitted(marked('a.', strong)), '**a.**\n')
|
||||
assert.equal(emitted({ text: 'x', type: 'text' }, marked('a', strong), { text: 'y', type: 'text' }), 'x**a**y\n')
|
||||
const em: AdfMark = { type: 'em' }
|
||||
assert.equal(
|
||||
emitted({ text: 'x', type: 'text' }, marked('a.', em), marked('b', strong)),
|
||||
'x:adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"a.\\",\\"type\\":\\"text\\"}"}**b**\n',
|
||||
)
|
||||
assert.equal(emitted({ text: 'x', type: 'text' }, marked('ab', em, strong), { text: 'y', type: 'text' }), 'x***ab***y\n')
|
||||
})
|
||||
|
||||
test('refuses a node carrying one mark type twice', () => {
|
||||
const em: AdfMark = { type: 'em' }
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [em, em], text: 'x', type: 'text' })))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('refuses a nested list the tight spelling would swallow', () => {
|
||||
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' })
|
||||
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
|
||||
const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
|
||||
const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' }
|
||||
assert.equal(code(adfToMarkdown(outer(text('a'), ordered))), 'unspellable-line-start')
|
||||
assert.equal(code(adfToMarkdown(outer(text('a'), { content: [item()], type: 'bulletList' }))), 'unspellable-line-start')
|
||||
assert.equal(markdown(adfToMarkdown(outer(text('a'), { content: [item(text('b'))], type: 'bulletList' }))), '- a\n - b\n')
|
||||
})
|
||||
|
||||
test('refuses marks and attributes nested deeper than the emitter carries', () => {
|
||||
const marks: AdfMark[] = Array.from({ length: 600 }, (_, index) => ({ type: index % 2 === 0 ? 'em' : 'strong' }))
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-node-shape')
|
||||
let attrs: AdfMark['attrs'] = { depth: 'x' }
|
||||
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), 'not-an-adf-document')
|
||||
})
|
||||
|
||||
test('escapes a literal delimiter that would merge with an emitted one', () => {
|
||||
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||
assert.equal(emitted(marked('a_', { type: 'em' })), '_a\\__\n')
|
||||
assert.equal(emitted(marked('_a', { type: 'em' })), '_\\_a_\n')
|
||||
assert.equal(emitted(marked('a*', { type: 'strong' })), '**a\\***\n')
|
||||
assert.equal(emitted(marked('~a', { type: 'strike' })), '~~\\~a~~\n')
|
||||
assert.equal(
|
||||
emitted({ text: 'x', type: 'text' }, marked('~a', { type: 'strike' })),
|
||||
'x:adf{json="{\\"marks\\":[{\\"type\\":\\"strike\\"}],\\"text\\":\\"~a\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
assert.equal(emitted({ text: '`', type: 'text' }, marked('x', { type: 'code' })), '\\``x`\n')
|
||||
assert.equal(emitted(marked('x', { type: 'code' }), { text: '`', type: 'text' }), '`x`\\`\n')
|
||||
assert.equal(emitted({ text: '`a', type: 'text' }, marked('b', { type: 'code' })), '\\`a`b`\n')
|
||||
assert.equal(
|
||||
emitted({ text: '<https://example.com/', type: 'text' }, { attrs: { url: 'x>' }, type: 'inlineCard' }),
|
||||
'\\<https://example.com/:inlineCard{url="x>"}\n',
|
||||
)
|
||||
assert.equal(emitted({ text: '!', type: 'text' }, marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' })), '\\\n')
|
||||
assert.equal(emitted({ text: '[a', type: 'text' }, marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' })), '[a[x](https://example.com/)\n')
|
||||
assert.equal(emitted({ text: '[a](b)', type: 'text' }), '\\[a](b)\n')
|
||||
assert.equal(emitted(marked('x', { type: 'underline' }), { text: '{}', type: 'text' }), ':underline[x]\\{}\n')
|
||||
assert.equal(emitted({ attrs: { text: '' }, type: 'status' }, { text: '{color=red}', type: 'text' }), ':status[]\\{color=red}\n')
|
||||
assert.equal(emitted(marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' }), { text: '{}', type: 'text' }), '[x](https://example.com/){}\n')
|
||||
})
|
||||
|
||||
test('escapes a literal delimiter run that only closes', () => {
|
||||
const emitted = (text: string): string => markdown(adfToMarkdown(document(paragraph({ text, type: 'text' }))))
|
||||
assert.equal(emitted('a* b'), 'a\\* b\n')
|
||||
assert.equal(emitted('2 * 3'), '2 * 3\n')
|
||||
})
|
||||
|
||||
test("spells a mark run CommonMark's matching pairs as written", () => {
|
||||
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||
const em: AdfMark = { type: 'em' }
|
||||
const strong: AdfMark = { type: 'strong' }
|
||||
assert.equal(emitted({ text: 'un', type: 'text' }, marked('a', em, strong), { text: 'istic', type: 'text' }), 'un***a***istic\n')
|
||||
assert.equal(emitted({ text: 're', type: 'text' }, marked('structure', strong), { text: ' the code', type: 'text' }), 're**structure** the code\n')
|
||||
assert.equal(
|
||||
emitted({ text: 'un', type: 'text' }, marked('a', em), marked('b', em, strong), marked('c', em), { text: 'istic', type: 'text' }),
|
||||
'un*a**b**c*istic\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('escapes a hyphen underline a hard break would expose', () => {
|
||||
const line = (text: string): string => markdown(adfToMarkdown(document(paragraph({ text: 'foo', type: 'text' }, { type: 'hardBreak' }, { text, type: 'text' }))))
|
||||
assert.equal(line('--'), 'foo\\\n\\--\n')
|
||||
assert.equal(line('=='), 'foo\\\n\\==\n')
|
||||
})
|
||||
|
||||
test('spells a list item whose marker completes a thematic break as a directive', () => {
|
||||
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' })
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '::::bulletList\n:::listItem\n---\n:::\n::::\n')
|
||||
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
|
||||
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [item(nested)], type: 'bulletList' }))), '::::bulletList\n:::listItem\n- -\n:::\n::::\n')
|
||||
})
|
||||
|
||||
test('refuses the characters CommonMark rewrites', () => {
|
||||
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' }))), 'unspellable-whitespace')
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character')
|
||||
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character')
|
||||
assert.equal(code(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('refuses a text node the spelling would empty out', () => {
|
||||
const nested: AdfNode[] = [{ text: 'lost', type: 'text' }]
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ content: nested, text: 'x', type: 'text' })))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => {
|
||||
const em = { type: 'em' }
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [em], text: 'a', type: 'text' }, { marks: [em], type: 'hardBreak' }, { text: 'b', type: 'text' })))),
|
||||
':adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"a\\",\\"type\\":\\"text\\"}"}:adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"type\\":\\"hardBreak\\"}"}b\n',
|
||||
)
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [em], text: '\u00a0a', type: 'text' })))),
|
||||
':adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"\u00a0a\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('pads a code span whose edges CommonMark would strip', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n')
|
||||
})
|
||||
|
||||
test('spells one code span over a run of code-marked nodes', () => {
|
||||
const code_ = { type: 'code' }
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))),
|
||||
'`ab`\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('refuses a document nested deeper than the emitter carries', () => {
|
||||
let node: AdfNode = paragraph({ text: 'x', type: 'text' })
|
||||
for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockquote' }
|
||||
assert.equal(code(adfToMarkdown(document(node))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
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({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), ':::caption\nx\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '::caption\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '::syncBlock {localId=a}\n')
|
||||
})
|
||||
|
||||
test('carries a directive attribute no section spells', () => {
|
||||
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
|
||||
assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```adf\n{\n "attrs": {\n "rounded": true\n },\n "type": "panel"\n}\n```\n')
|
||||
assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```adf\n{\n "attrs": {\n "toString": "x"\n },\n "type": "panel"\n}\n```\n')
|
||||
assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```adf\n{\n "attrs": {\n "localId": 4\n },\n "type": "panel"\n}\n```\n')
|
||||
assert.equal(
|
||||
carried({ attrs: { width: '50' }, type: 'layoutColumn' }),
|
||||
'```adf\n{\n "attrs": {\n "width": "50"\n },\n "type": "layoutColumn"\n}\n```\n',
|
||||
)
|
||||
assert.equal(
|
||||
carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }),
|
||||
'```adf\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n },\n "type": "table"\n}\n```\n',
|
||||
)
|
||||
assert.equal(
|
||||
carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }),
|
||||
':::mediaGroup\n```adf\n{\n "attrs": {\n "alt": 4\n },\n "type": "media"\n}\n```\n:::\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('carries an arg slot value no bare token spells', () => {
|
||||
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
|
||||
assert.equal(
|
||||
carried({ attrs: { panelType: 'extra info' }, type: 'panel' }),
|
||||
'```adf\n{\n "attrs": {\n "panelType": "extra info"\n },\n "type": "panel"\n}\n```\n',
|
||||
)
|
||||
assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```adf\n{\n "attrs": {\n "state": 2\n },\n "type": "taskItem"\n}\n```\n')
|
||||
})
|
||||
|
||||
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')
|
||||
const caption: AdfNode = { content: [{ text: 'c', type: 'text' }], type: 'caption' }
|
||||
assert.equal(markdown(adfToMarkdown(panel(caption, caption))), '::::panel info\n:::caption\nc\n:::\n:::caption\nc\n:::\n::::\n')
|
||||
assert.equal(code(adfToMarkdown(panel(text('a'), caption))), 'unspelled-block-separation')
|
||||
assert.equal(code(adfToMarkdown(panel(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`)
|
||||
const fallback = (attrs: AdfAttributes): boolean => markdown(adfToMarkdown(single(attrs))).startsWith(':::mediaSingle {layout=center}')
|
||||
assert.ok(fallback({ alt: '', type: 'external', url }))
|
||||
assert.ok(fallback({ alt: 'a\nb', type: 'external', url }))
|
||||
assert.ok(fallback({ alt: 'a\u0000b', type: 'external', url }))
|
||||
assert.ok(fallback({ type: 'external', url: 'https://example.com/a b>c' }))
|
||||
assert.ok(fallback({ alt: ' moon ', type: 'external', url }))
|
||||
assert.ok(fallback({ alt: 4, type: 'external', url }))
|
||||
assert.ok(fallback({ type: 'external', url: 4 }))
|
||||
assert.equal(code(adfToMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape')
|
||||
const media: AdfNode = { attrs: { type: 'external', url }, type: 'media' }
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { layout: 'center' }, content: [media], text: 'x', type: 'mediaSingle' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { layout: 'center' }, content: [{ ...media, text: 'x' }], type: 'mediaSingle' }))), '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.ok(directive(adfToMarkdown(table(row(cell('tableHeader', { attrs: { localId: 'a' }, type: 'paragraph' }))))))
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(table(row(cell('tableHeader', { content: [{ attrs: { url: 'a|b' }, type: 'blockCard' }], type: 'paragraph' }))))),
|
||||
'| :adf{json="{\\"attrs\\":{\\"url\\":\\"a\\u007cb\\"},\\"type\\":\\"blockCard\\"}"} |\n| --- |\n',
|
||||
)
|
||||
assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: '\fa', type: 'text' }], type: 'paragraph' }))))), 'unspellable-whitespace')
|
||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', { attrs: { level: 1 }, type: 'heading' }))))))
|
||||
assert.equal(markdown(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: ' a', type: 'text' }], type: 'paragraph' }))))), '| :text{text=" "}a |\n| --- |\n')
|
||||
const marked = (mark: AdfMark): AdfDocument => table(row(cell('tableHeader', { content: [{ marks: [mark], text: 'l', type: 'text' }], type: 'paragraph' })))
|
||||
assert.ok(directive(adfToMarkdown(marked({ attrs: { href: 'https://example.com/?x|y' }, type: 'link' }))))
|
||||
assert.ok(directive(adfToMarkdown(marked({ attrs: { href: 'https://example.com/', title: 'a|b' }, type: 'link' }))))
|
||||
const codeSpan: AdfNode = { marks: [{ type: 'code' }], text: 'a|b', type: 'text' }
|
||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', { content: [codeSpan], type: 'paragraph' }))))))
|
||||
assert.equal(markdown(adfToMarkdown(marked({ attrs: { href: 'https://example.com/x' }, type: 'link' }))), '| [l](https://example.com/x) |\n| --- |\n')
|
||||
})
|
||||
|
||||
test('spells an inline node as a directive with its content slot and attributes', () => {
|
||||
const emitted = (node: AdfNode): string => markdown(adfToMarkdown(document(paragraph(node))))
|
||||
assert.equal(emitted({ attrs: { timestamp: '1756080000000' }, type: 'date' }), ':date{timestamp=1756080000000}\n')
|
||||
assert.equal(emitted({ type: 'mention' }), ':mention{}\n')
|
||||
assert.equal(emitted({ attrs: { text: '' }, type: 'status' }), ':status[]\n')
|
||||
assert.equal(emitted({ attrs: { color: 'yellow', text: 'In review' }, type: 'status' }), ':status[In review]{color=yellow}\n')
|
||||
assert.equal(emitted({ attrs: { id: '1f389', text: 'a]b' }, type: 'emoji' }), ':emoji[a\\]b]{id=1f389}\n')
|
||||
assert.equal(emitted({ attrs: { data: { url: 'https://example.com/' } }, type: 'inlineCard' }), ':inlineCard{data="{\\"url\\":\\"https://example.com/\\"}"}\n')
|
||||
assert.equal(emitted({ attrs: { height: 24 }, type: 'mediaInline' }), ':mediaInline{height=24}\n')
|
||||
assert.equal(emitted({ attrs: { url: 'a`b&c<d|e' }, type: 'inlineCard' }), ':inlineCard{url="a\\u0060b\\u0026c\\u003cd\\u007ce"}\n')
|
||||
})
|
||||
|
||||
test('carries an inline node attribute no section spells', () => {
|
||||
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(paragraph(node))))
|
||||
assert.equal(carried({ attrs: { rounded: true }, type: 'status' }), ':adf{json="{\\"attrs\\":{\\"rounded\\":true},\\"type\\":\\"status\\"}"}\n')
|
||||
assert.equal(carried({ attrs: { toString: 'x' }, type: 'status' }), ':adf{json="{\\"attrs\\":{\\"toString\\":\\"x\\"},\\"type\\":\\"status\\"}"}\n')
|
||||
assert.equal(carried({ attrs: { color: 4 }, type: 'status' }), ':adf{json="{\\"attrs\\":{\\"color\\":4},\\"type\\":\\"status\\"}"}\n')
|
||||
assert.equal(carried({ attrs: { width: '2' }, type: 'mediaInline' }), ':adf{json="{\\"attrs\\":{\\"width\\":\\"2\\"},\\"type\\":\\"mediaInline\\"}"}\n')
|
||||
assert.equal(carried({ attrs: { text: 4 }, type: 'status' }), ':adf{json="{\\"attrs\\":{\\"text\\":4},\\"type\\":\\"status\\"}"}\n')
|
||||
})
|
||||
|
||||
test('refuses the content and slot an inline directive has no room for', () => {
|
||||
const refused = (node: AdfNode): string => code(adfToMarkdown(document(paragraph(node, { text: 'y', type: 'text' }))))
|
||||
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'status' }), 'unsupported-node-shape')
|
||||
assert.equal(refused({ text: 'x', type: 'status' }), 'unsupported-node-shape')
|
||||
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), 'unsupported-node-shape')
|
||||
assert.equal(refused({ text: 'x', type: 'hardBreak' }), 'unsupported-node-shape')
|
||||
assert.equal(refused({ attrs: { text: 'a\nb' }, type: 'status' }), 'unspellable-whitespace')
|
||||
assert.equal(refused({ attrs: { text: 'a\u0000b' }, type: 'status' }), 'unspellable-character')
|
||||
})
|
||||
|
||||
test('spells the directive marks around the longest run they cover', () => {
|
||||
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||
const underline: AdfMark = { type: 'underline' }
|
||||
assert.equal(emitted(marked('x', underline)), ':underline[x]\n')
|
||||
assert.equal(emitted(marked('a', underline), marked('b', underline)), ':underline[ab]\n')
|
||||
assert.equal(emitted(marked('x', { attrs: { type: 'sub' }, type: 'subsup' })), ':subsup[x]{type=sub}\n')
|
||||
assert.equal(emitted(marked('x', { attrs: { color: '#ae2e24' }, type: 'textColor' })), ':textColor[x]{color="#ae2e24"}\n')
|
||||
assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), ':border[x]{color="#091e42" size=2}\n')
|
||||
assert.equal(emitted(marked('x', { type: 'em' }, underline)), '_:underline[x]_\n')
|
||||
assert.equal(emitted(marked('x', underline, { type: 'em' })), ':underline[_x_]\n')
|
||||
assert.equal(emitted(marked('a', underline), { marks: [underline], type: 'hardBreak' }, marked('b', underline)), ':underline[a:hardBreak{}b]\n')
|
||||
const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' }
|
||||
assert.equal(
|
||||
emitted(marked('a', underline, link), { marks: [underline, link], type: 'hardBreak' }, marked('b', underline, link)),
|
||||
':underline[[a:hardBreak{}b](https://example.com/)]\n',
|
||||
)
|
||||
assert.equal(
|
||||
emitted(marked('a', link), { marks: [link], type: 'hardBreak' }, marked('b', link)),
|
||||
'[a\\\nb](https://example.com/)\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('carries a mark directive attribute no spelling holds', () => {
|
||||
const carried = (mark: AdfMark): string => markdown(adfToMarkdown(document(paragraph({ marks: [mark], text: 'x', type: 'text' }))))
|
||||
assert.equal(
|
||||
carried({ attrs: { width: 2 }, type: 'border' }),
|
||||
':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"width\\":2},\\"type\\":\\"border\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
assert.equal(
|
||||
carried({ attrs: { size: '2' }, type: 'border' }),
|
||||
':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"size\\":\\"2\\"},\\"type\\":\\"border\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('carries whitespace CommonMark strips in the reserved text directive', () => {
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||
assert.equal(emitted({ text: ' lead', type: 'text' }), ':text{text=" "}lead\n')
|
||||
assert.equal(emitted({ text: 'trail ', type: 'text' }), 'trail:text{text=" "}\n')
|
||||
assert.equal(emitted({ text: 'a\nb', type: 'text' }), 'a:text{text="\\n"}b\n')
|
||||
assert.equal(emitted({ text: '\t', type: 'text' }), ':text{text="\\t"}\n')
|
||||
assert.equal(emitted({ text: 'a ', type: 'text' }, { type: 'hardBreak' }, { text: ' b', type: 'text' }), 'a:text{text=" "}\\\n:text{text=" "}b\n')
|
||||
assert.equal(emitted({ marks: [{ type: 'em' }], text: ' a ', type: 'text' }), '_:text{text=" "}a:text{text=" "}_\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { level: 1 }, content: [{ text: 'x ', type: 'text' }], type: 'heading' }))), '# x:text{text=" "}\n')
|
||||
})
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { BlockDirective } from '../../adf/block-directives.ts'
|
||||
import type { JsonValue } from '../../json-value.ts'
|
||||
import { blockDirective } from '../../adf/block-directives.ts'
|
||||
import { carriedBlock, carryName } from '../opaque-carry.ts'
|
||||
import { carriesOnly, isAdfDocument } from '../../adf/document.ts'
|
||||
import { emitInlineLine } from './inline-line.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { fencedCodeBlock } from '../backtick-runs.ts'
|
||||
import { holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isThematicBreak } from '../commonmark-grammar.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { spellDirectiveHeader } from '../block-directive-spelling.ts'
|
||||
import { tryImage } from './image.ts'
|
||||
import { tryPipeTable } from './pipe-table.ts'
|
||||
|
||||
type BlockContainer = 'directive' | 'document' | 'list-item'
|
||||
type BlockSpelling = 'commonmark' | 'directive'
|
||||
type EmittedBody = { fenceColons: number; text: string }
|
||||
type EmittedBlock = EmittedBody & { spelling: BlockSpelling }
|
||||
type PlacedBlock = EmittedBlock & { node: AdfNode; path: ConvertErrorPath }
|
||||
|
||||
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 ?? [], 'document', [], 0)
|
||||
if (!blocks.ok) return blocks
|
||||
return success(blocks.value.text === '' ? '' : `${blocks.value.text}\n`)
|
||||
}
|
||||
|
||||
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)
|
||||
const blocks: PlacedBlock[] = []
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const nodePath = [...path, 'content', index]
|
||||
const block = emitBlock(node, nodePath, depth)
|
||||
if (!block.ok) return block
|
||||
blocks.push({ ...block.value, node, path: nodePath })
|
||||
}
|
||||
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: PlacedBlock, next: PlacedBlock, container: BlockContainer): Result<string> {
|
||||
const plainPair = previous.spelling === 'commonmark' && next.spelling === 'commonmark'
|
||||
if (plainPair && listTypes.includes(next.node.type)) {
|
||||
if (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') {
|
||||
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' || plainPair) return success('\n\n')
|
||||
if (previous.spelling === 'directive' && next.spelling === 'directive') return success('\n')
|
||||
return failure(
|
||||
'unspelled-block-separation',
|
||||
`the canonical form leaves the separation between a ${previous.spelling} and a ${next.spelling} block in a container body unspelled`,
|
||||
next.path,
|
||||
)
|
||||
}
|
||||
|
||||
function interruptsParagraph(node: AdfNode): boolean {
|
||||
if (node.type === 'orderedList') return false
|
||||
return ((node.content ?? [])[0]?.content ?? []).length > 0
|
||||
}
|
||||
|
||||
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||
const directive = blockDirective(node.type)
|
||||
if (directive === undefined) return commonMarkLine(carriedBlock(node, path))
|
||||
const readable = readableBlock(node, path, depth)
|
||||
if (readable !== undefined) return readable
|
||||
return emitDirectiveBlock(node, directive, path, depth)
|
||||
}
|
||||
|
||||
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
|
||||
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)
|
||||
if (node.type === 'mediaSingle') return readableText(tryImage(node, path))
|
||||
if (node.type === 'paragraph') return emitParagraph(node, path)
|
||||
if (node.type === 'rule') return emitRule(node)
|
||||
if (node.type === 'table') return readableText(tryPipeTable(node, path))
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readableText(text: string | undefined): Result<EmittedBlock> | undefined {
|
||||
return text === undefined ? undefined : success(commonMarkText(text))
|
||||
}
|
||||
|
||||
function commonMarkLine(text: Result<string>): Result<EmittedBlock> {
|
||||
if (!text.ok) return text
|
||||
return success(commonMarkText(text.value))
|
||||
}
|
||||
|
||||
function commonMarkText(text: string): EmittedBlock {
|
||||
return { fenceColons: 0, spelling: 'commonmark', text }
|
||||
}
|
||||
|
||||
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path)
|
||||
const content = node.content ?? []
|
||||
if (directive.body === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path)
|
||||
if (directive.body === 'code') return emitCodeDirective(node, directive, path)
|
||||
const header = spellDirectiveHeader(node, directive)
|
||||
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
|
||||
if (directive.body === 'none' || (directive.body === 'inline' && content.length === 0)) {
|
||||
return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` })
|
||||
}
|
||||
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, spelling: 'directive', text: `${fence}${header}\n${lines}${fence}` })
|
||||
}
|
||||
|
||||
function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Result<EmittedBody> {
|
||||
const line = emitInlineLine(content, 'paragraph', path)
|
||||
if (!line.ok) return line
|
||||
return success({ fenceColons: 0, text: line.value })
|
||||
}
|
||||
|
||||
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
|
||||
if (!carriesOnly(node, [])) return undefined
|
||||
const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1)
|
||||
if (!inner.ok) return inner
|
||||
const text = inner.value.text
|
||||
.split('\n')
|
||||
.map((line) => (line === '' ? '>' : `> ${line}`))
|
||||
.join('\n')
|
||||
return success({ fenceColons: inner.value.fenceColons, spelling: 'commonmark', text })
|
||||
}
|
||||
|
||||
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
if (!carriesOnly(node, ['language'])) return undefined
|
||||
const info = fenceInfo(node.attrs?.['language'])
|
||||
if (info === undefined) return undefined
|
||||
const text = codeBlockText(node, path)
|
||||
if (!text.ok) return text
|
||||
return success(commonMarkText(fencedCodeBlock(info, text.value)))
|
||||
}
|
||||
|
||||
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<EmittedBlock> {
|
||||
const info = fenceInfo(node.attrs?.['language'])
|
||||
const header = spellDirectiveHeader(node, directive, info === undefined ? [] : ['language'])
|
||||
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
|
||||
const text = codeBlockText(node, path)
|
||||
if (!text.ok) return text
|
||||
return success({ fenceColons: 3, spelling: 'directive', text: `:::${header}\n${fencedCodeBlock(info ?? '', text.value)}\n:::` })
|
||||
}
|
||||
|
||||
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
let text = ''
|
||||
for (const [index, child] of (node.content ?? []).entries()) {
|
||||
const childPath = [...path, 'content', index]
|
||||
if (
|
||||
child.type !== 'text' ||
|
||||
typeof child.text !== 'string' ||
|
||||
child.text === '' ||
|
||||
(child.content ?? []).length > 0 ||
|
||||
(child.marks ?? []).length > 0 ||
|
||||
Object.keys(child.attrs ?? {}).length > 0
|
||||
) {
|
||||
return failure('unsupported-node-shape', 'a codeBlock holds plain text nodes only', childPath)
|
||||
}
|
||||
if (/\r/.test(child.text)) return failure('unspellable-whitespace', 'a codeBlock holds no carriage return CommonMark keeps', childPath)
|
||||
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
|
||||
text += child.text
|
||||
}
|
||||
return success(text)
|
||||
}
|
||||
|
||||
// spec/flavour.md, The CommonMark blocks.
|
||||
function fenceInfo(language: JsonValue | undefined): string | undefined {
|
||||
if (language === undefined) return ''
|
||||
if (typeof language !== 'string' || language === '' || language === carryName) return undefined
|
||||
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return undefined
|
||||
return language
|
||||
}
|
||||
|
||||
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
if (!carriesOnly(node, ['level'])) return undefined
|
||||
const level = node.attrs?.['level']
|
||||
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
|
||||
const hashes = '#'.repeat(level)
|
||||
const content = node.content ?? []
|
||||
if (content.length === 0) return success(commonMarkText(hashes))
|
||||
const line = emitInlineLine(content, 'heading', path)
|
||||
if (!line.ok) return line
|
||||
return success(commonMarkText(`${hashes} ${line.value}`))
|
||||
}
|
||||
|
||||
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
|
||||
const ordered = node.type === 'orderedList'
|
||||
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
|
||||
const items = node.content ?? []
|
||||
const start = listStart(node, items.length)
|
||||
if (start === undefined || items.length === 0) return undefined
|
||||
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
|
||||
const lines: string[] = []
|
||||
let fenceColons = 0
|
||||
for (const [offset, item] of items.entries()) {
|
||||
const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', [...path, 'content', offset], depth)
|
||||
if (emitted === undefined) return undefined
|
||||
if (!emitted.ok) return emitted
|
||||
fenceColons = Math.max(fenceColons, emitted.value.fenceColons)
|
||||
lines.push(emitted.value.text)
|
||||
}
|
||||
return success({ fenceColons, spelling: 'commonmark', text: lines.join('\n') })
|
||||
}
|
||||
|
||||
function listStart(node: AdfNode, items: number): number | undefined {
|
||||
if (node.type !== 'orderedList') return 0
|
||||
const start = node.attrs?.['order']
|
||||
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined
|
||||
return start + items - 1 > largestListMarker ? undefined : start
|
||||
}
|
||||
|
||||
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> | undefined {
|
||||
const inner = emitBlocks(item.content ?? [], 'list-item', path, depth + 1)
|
||||
if (!inner.ok) return inner
|
||||
if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() })
|
||||
const indent = ' '.repeat(marker.length)
|
||||
const lines = inner.value.text.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
|
||||
if (isThematicBreak(lines[0] ?? '')) return undefined
|
||||
return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') })
|
||||
}
|
||||
|
||||
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
const content = node.content ?? []
|
||||
if (content.length === 0 || !carriesOnly(node, [])) return undefined
|
||||
const line = emitInlineLine(content, 'paragraph', path)
|
||||
if (!line.ok) return line
|
||||
return success(commonMarkText(line.value))
|
||||
}
|
||||
|
||||
function emitRule(node: AdfNode): Result<EmittedBlock> | undefined {
|
||||
if (!carriesOnly(node, []) || (node.content ?? []).length > 0) return undefined
|
||||
return success(commonMarkText('---'))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import { carriesOnly } from '../../adf/document.ts'
|
||||
import type { ConvertErrorPath } from '../../result.ts'
|
||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||
import { tryImageLine } from './inline-line.ts'
|
||||
|
||||
const centeredMediaSingle = '{"layout":"center"}'
|
||||
const imageAttributes = ['alt', 'type', 'url']
|
||||
|
||||
export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefined {
|
||||
const image = imageShape(node)
|
||||
if (image === undefined) return undefined
|
||||
return tryImageLine(image.alt, image.url, [...path, 'content', 0])
|
||||
}
|
||||
|
||||
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
||||
const content = node.content ?? []
|
||||
const media = content[0]
|
||||
if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle) return undefined
|
||||
if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || (media.content ?? []).length > 0) return undefined
|
||||
const attrs = media.attrs ?? {}
|
||||
const alt = attrs['alt']
|
||||
const url = attrs['url']
|
||||
if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined
|
||||
if (alt !== undefined && (typeof alt !== 'string' || alt === '')) return undefined
|
||||
return { alt, url }
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { InlineDirective } from '../../adf/inline-directives.ts'
|
||||
import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
|
||||
import { carriedInline } from '../opaque-carry.ts'
|
||||
import { claimsLine, holdsEntityReference, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { inlineDirective } from '../../adf/inline-directives.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { longestBacktickRun } from '../backtick-runs.ts'
|
||||
import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||
import { spellAttributes, spellStringAttribute } from '../directive-attributes.ts'
|
||||
import { spellDestination, spellTitle } from '../destination-spelling.ts'
|
||||
import { spellInlineNodeAttributes } from '../inline-directive-spelling.ts'
|
||||
|
||||
type EmittedLine = { line: string; segments: InlineSegment[] }
|
||||
|
||||
type Emission = { carry: NodeRange; segments?: undefined } | { carry?: undefined; segments: InlineSegment[] }
|
||||
|
||||
type InlineContext = {
|
||||
atBlockEnd: boolean
|
||||
bracketed: boolean
|
||||
carried: ReadonlySet<number>
|
||||
path: ConvertErrorPath
|
||||
spansLines: boolean
|
||||
}
|
||||
|
||||
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
|
||||
|
||||
type LineAttempt = { carry: NodeRange; line?: undefined } | { carry?: undefined; line: string }
|
||||
|
||||
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
|
||||
const emitted = emitLine(nodes, container, path)
|
||||
if (!emitted.ok) return emitted
|
||||
return success(emitted.value.line)
|
||||
}
|
||||
|
||||
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
|
||||
const emitted = emitLine(nodes, 'table-cell', path)
|
||||
if (!emitted.ok) return undefined
|
||||
if (emitted.value.segments.some((segment) => segment.escaping === 'none' && segment.text.includes('|'))) return undefined
|
||||
return emitted.value.line
|
||||
}
|
||||
|
||||
export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined {
|
||||
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
|
||||
const destination = spellDestination(href, path)
|
||||
if (!destination.ok) return undefined
|
||||
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
|
||||
const attempt = attemptLine([syntax('`)], 'paragraph', path)
|
||||
return attempt.ok ? attempt.value.line : undefined
|
||||
}
|
||||
|
||||
// A carried node joins no run, so every pass carries at least one more node.
|
||||
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> {
|
||||
const carried = new Set<number>()
|
||||
for (;;) {
|
||||
const emission = lineSegments(nodes, container, path, carried)
|
||||
if (!emission.ok) return emission
|
||||
if (emission.value.carry !== undefined) {
|
||||
carryRange(carried, emission.value.carry)
|
||||
continue
|
||||
}
|
||||
const attempt = attemptLine(emission.value.segments, container, path)
|
||||
if (!attempt.ok) return attempt
|
||||
if (attempt.value.carry === undefined) return success({ line: attempt.value.line, segments: emission.value.segments })
|
||||
carryRange(carried, attempt.value.carry)
|
||||
}
|
||||
}
|
||||
|
||||
function carryRange(carried: Set<number>, range: NodeRange): void {
|
||||
for (let index = range.first; index <= range.last; index += 1) carried.add(index)
|
||||
}
|
||||
|
||||
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, carried: ReadonlySet<number>): Result<Emission> {
|
||||
const emission = emitRun(nodes, 0, 0, { atBlockEnd: true, bracketed: false, carried, path, spansLines: container === 'paragraph' })
|
||||
if (!emission.ok) return emission
|
||||
if (emission.value.carry !== undefined) return emission
|
||||
return success({ segments: carryStrippedWhitespace(emission.value.segments) })
|
||||
}
|
||||
|
||||
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> {
|
||||
const assembled = assembleInlineLine(segments, container)
|
||||
if (assembled.unspellableRun !== undefined) return success({ carry: assembled.unspellableRun })
|
||||
for (const [index, single] of assembled.line.split('\n').entries()) {
|
||||
if (/^[ \t\v\f]|[ \t\v\f]$/.test(single)) {
|
||||
return failure('unspellable-whitespace', 'a line begins or ends with whitespace CommonMark strips', path)
|
||||
}
|
||||
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) {
|
||||
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`, path)
|
||||
}
|
||||
}
|
||||
return success({ line: assembled.line })
|
||||
}
|
||||
|
||||
// spec/flavour.md, Inline nodes.
|
||||
function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
|
||||
const carried: InlineSegment[] = []
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
const previous = segments[index - 1]
|
||||
const next = segments[index + 1]
|
||||
const leading = previous === undefined || previous.text.includes('\n')
|
||||
const trailing = next === undefined || next.text.includes('\n')
|
||||
carried.push(...carryEdges(segment, leading, trailing))
|
||||
}
|
||||
return carried
|
||||
}
|
||||
|
||||
function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean): InlineSegment[] {
|
||||
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
|
||||
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
|
||||
const body = segment.text.slice(head.length)
|
||||
const tail = trailing ? (/[ \t]+$/.exec(body)?.[0] ?? '') : ''
|
||||
const middle = body.slice(0, body.length - tail.length)
|
||||
const edges: InlineSegment[] = []
|
||||
if (head !== '') edges.push(carriedText(head))
|
||||
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
|
||||
if (tail !== '') edges.push(carriedText(tail))
|
||||
return edges
|
||||
}
|
||||
|
||||
function carriedText(text: string): InlineSegment {
|
||||
return syntax(spellLeafDirective('text', spellAttributes([['text', spellStringAttribute(text)]])))
|
||||
}
|
||||
|
||||
function spellLeafDirective(name: string, attributes: string): string {
|
||||
return `:${name}${attributes === '' ? '{}' : attributes}`
|
||||
}
|
||||
|
||||
function syntax(text: string): InlineSegment {
|
||||
return { escaping: 'none', text }
|
||||
}
|
||||
|
||||
function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result<null> {
|
||||
if ((node.content ?? []).length > 0 || node.text !== undefined) {
|
||||
return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text`, path)
|
||||
}
|
||||
return success(null)
|
||||
}
|
||||
|
||||
function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result<Emission> {
|
||||
if (depth > largestNesting) {
|
||||
return failure('unsupported-node-shape', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path)
|
||||
}
|
||||
const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
|
||||
const segments: InlineSegment[] = []
|
||||
for (const [offset, run] of runs.entries()) {
|
||||
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
|
||||
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
|
||||
if (!emitted.ok) return emitted
|
||||
if (emitted.value.carry !== undefined) return emitted
|
||||
segments.push(...emitted.value.segments)
|
||||
}
|
||||
return success({ segments })
|
||||
}
|
||||
|
||||
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet<number>): InlineRun[] {
|
||||
const runs: InlineRun[] = []
|
||||
for (const [offset, node] of nodes.entries()) {
|
||||
const index = firstIndex + offset
|
||||
// spec/flavour.md, Marks.
|
||||
const mark = carries(node, carried, index) ? undefined : (node.marks ?? [])[depth]
|
||||
if (mark === undefined) {
|
||||
runs.push({ index, kind: 'plain', node })
|
||||
continue
|
||||
}
|
||||
const previous = runs[runs.length - 1]
|
||||
if (previous?.kind === 'marked' && sameMark(previous.mark, mark)) previous.nodes.push(node)
|
||||
else runs.push({ index, kind: 'marked', mark, nodes: [node] })
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
function nodePath(context: InlineContext, index: number): ConvertErrorPath {
|
||||
return [...context.path, 'content', index]
|
||||
}
|
||||
|
||||
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
|
||||
if (carried.has(index)) return true
|
||||
return node.type !== 'text' && inlineDirective(node.type) === undefined
|
||||
}
|
||||
|
||||
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> {
|
||||
const path = nodePath(context, index)
|
||||
if (carries(node, context.carried, index)) {
|
||||
const carried = carriedInline(node, path)
|
||||
if (!carried.ok) return carried
|
||||
return success({ segments: [syntax(carried.value)] })
|
||||
}
|
||||
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)
|
||||
const directive = inlineDirective(node.type)
|
||||
if (directive === undefined) return emitText(node, context, index, path)
|
||||
if (node.type === 'hardBreak') return emitHardBreak(node, directive, context, index, path)
|
||||
return emitInlineDirective(node, directive, index, path)
|
||||
}
|
||||
|
||||
function emitHardBreak(node: AdfNode, directive: InlineDirective, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||
const empty = refuseContentAndText(node, path)
|
||||
if (!empty.ok) return empty
|
||||
const attributes = spellInlineNodeAttributes(node, directive)
|
||||
if (attributes === undefined) return success({ carry: { first: index, last: index } })
|
||||
if (attributes === '' && context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] })
|
||||
return success({ segments: [syntax(spellLeafDirective('hardBreak', attributes))] })
|
||||
}
|
||||
|
||||
function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||
const empty = refuseContentAndText(node, path)
|
||||
if (!empty.ok) return empty
|
||||
const attributes = spellInlineNodeAttributes(node, directive)
|
||||
if (attributes === undefined) return success({ carry: { first: index, last: index } })
|
||||
const slot = directive.slot === undefined ? undefined : node.attrs?.[directive.slot]
|
||||
if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] })
|
||||
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
|
||||
if (/[\n\r]/.test(slot)) return failure('unspellable-whitespace', `a ${node.type} content slot holds a newline no inline directive spans`, path)
|
||||
if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path)
|
||||
const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
|
||||
return success({ segments: [syntax(`:${node.type}[`), ...content, syntax(`]${attributes}`)] })
|
||||
}
|
||||
|
||||
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||
if (Object.keys(node.attrs ?? {}).length > 0) return success({ carry: { first: index, last: index } })
|
||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text', path)
|
||||
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content', path)
|
||||
if (/\r/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a carriage return CommonMark rewrites', path)
|
||||
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
||||
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
||||
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
|
||||
return success({ segments: parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part })) })
|
||||
}
|
||||
|
||||
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
|
||||
const path = nodePath(context, index)
|
||||
const range: NodeRange = { first: index, last: index + nodes.length - 1 }
|
||||
const spelling = markSpelling(mark.type)
|
||||
if (spelling === undefined) return success({ carry: range })
|
||||
const attributes = spellMarkAttributes(mark, spelling.attributes)
|
||||
if (attributes === undefined) return success({ carry: range })
|
||||
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
|
||||
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
|
||||
if (spelling.kind === 'link') return emitLink(nodes, mark, depth, range, context, path)
|
||||
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
|
||||
if (!inner.ok) return inner
|
||||
if (inner.value.carry !== undefined) return inner
|
||||
return success({ segments: [syntax(`:${mark.type}[`), ...inner.value.segments, syntax(`]${attributes}`)] })
|
||||
}
|
||||
|
||||
function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
|
||||
const inner = emitRun(nodes, depth + 1, range.first, context)
|
||||
if (!inner.ok) return inner
|
||||
if (inner.value.carry !== undefined) return inner
|
||||
const carried = carryStrippedWhitespace(inner.value.segments)
|
||||
return success({
|
||||
segments: [
|
||||
{ emphasis: 'open', escaping: 'none', nodes: range, text: spelling },
|
||||
...carried,
|
||||
{ emphasis: 'close', escaping: 'none', nodes: range, text: spelling },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
||||
let text = ''
|
||||
for (const node of nodes) {
|
||||
if (node.type !== 'text' || (node.marks ?? []).length !== depth + 1) return success({ carry: range })
|
||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text', path)
|
||||
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content', path)
|
||||
text += node.text
|
||||
}
|
||||
if (/[\n\r]/.test(text)) return success({ carry: range })
|
||||
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
|
||||
const fence = '`'.repeat(longestBacktickRun(text) + 1)
|
||||
const padded = needsPadding(text) ? ` ${text} ` : text
|
||||
return success({ segments: [syntax(`${fence}${padded}${fence}`)] })
|
||||
}
|
||||
|
||||
function needsPadding(text: string): boolean {
|
||||
if (text.startsWith('`') || text.endsWith('`')) return true
|
||||
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
|
||||
}
|
||||
|
||||
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result<Emission> {
|
||||
const href = mark.attrs?.['href']
|
||||
const title = mark.attrs?.['title']
|
||||
if (typeof href !== 'string') return success({ carry: range })
|
||||
const node = nodes[0]
|
||||
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1
|
||||
if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] })
|
||||
const destination = spellDestination(href, path)
|
||||
if (!destination.ok) return destination
|
||||
const spelledTitle = typeof title === 'string' ? spellTitle(title, path) : success('')
|
||||
if (!spelledTitle.ok) return spelledTitle
|
||||
const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true })
|
||||
if (!inner.ok) return inner
|
||||
if (inner.value.carry !== undefined) return inner
|
||||
return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${destination.value}${spelledTitle.value})`)] })
|
||||
}
|
||||
|
||||
function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
|
||||
if (candidate.type !== mark.type) return false
|
||||
return serializeCanonicalJson(candidate.attrs ?? {}, 'compact') === serializeCanonicalJson(mark.attrs ?? {}, 'compact')
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { escapesLineClaim, opensBracketedAutolink, startsEntityReference, type LinePosition } from '../commonmark-grammar.ts'
|
||||
import { delimiterFlags, isWordCharacter, matchEmphasis } from '../emphasis-matching.ts'
|
||||
|
||||
export type EmphasisRole = 'close' | 'open'
|
||||
|
||||
export type InlineEscaping = 'backslash' | 'bracketed' | 'none'
|
||||
|
||||
export type NodeRange = { first: number; last: number }
|
||||
|
||||
export type InlineSegment =
|
||||
| { emphasis: EmphasisRole; escaping: 'none'; nodes: NodeRange; text: string }
|
||||
| { emphasis?: undefined; escaping: InlineEscaping; text: string }
|
||||
|
||||
export type AssembledLine = { line: string; unspellableRun: NodeRange | undefined }
|
||||
|
||||
export type LineContainer = 'heading' | 'paragraph' | 'table-cell'
|
||||
|
||||
type EmittedDelimiter = { closes: boolean; offset: number; pair: number; width: number }
|
||||
|
||||
type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; delimiters: EmittedDelimiter[]; length: number; start: number }
|
||||
|
||||
const delimiters = ['*', '_', '`', '~']
|
||||
|
||||
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
||||
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
|
||||
const inlineDirectiveOpener = /^:[a-z][A-Za-z0-9]*[[{]/
|
||||
const followsLinkText = /[([:]/
|
||||
|
||||
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
|
||||
return escape(resolveEmphasis(segments), container)
|
||||
}
|
||||
|
||||
function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
|
||||
const resolved = segments.map((segment) => ({ ...segment }))
|
||||
// Offsets index the pre-swap text: every emphasis spelling this swaps between is one character wide.
|
||||
const scan = resolved.map((segment) => segment.text).join('')
|
||||
const offsets: number[] = []
|
||||
let offset = 0
|
||||
for (const segment of resolved) {
|
||||
offsets.push(offset)
|
||||
offset += segment.text.length
|
||||
}
|
||||
const open: number[] = []
|
||||
for (let index = 0; index < resolved.length; index += 1) {
|
||||
const segment = resolved[index]
|
||||
if (segment === undefined) continue
|
||||
if (segment.emphasis === 'open') open.push(index)
|
||||
if (segment.emphasis !== 'close') continue
|
||||
const openerIndex = open.pop()
|
||||
const opener = openerIndex === undefined ? undefined : resolved[openerIndex]
|
||||
if (openerIndex === undefined || opener === undefined) continue
|
||||
const openOffset = offsets[openerIndex] ?? 0
|
||||
const closeOffset = offsets[index] ?? 0
|
||||
if (opener.text !== '_') continue
|
||||
if (!isWordCharacter(charAt(scan, openOffset - 1)) && !isWordCharacter(charAt(scan, closeOffset + 1))) continue
|
||||
opener.text = '*'
|
||||
segment.text = '*'
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function escape(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
|
||||
const scan = segments.map((segment) => segment.text).join('')
|
||||
const escapings: InlineEscaping[] = []
|
||||
for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping)
|
||||
const escaped = new Set<number>()
|
||||
const placements: number[] = []
|
||||
let output = ''
|
||||
for (let index = 0; index < scan.length; index += 1) {
|
||||
const escaping = escapings[index]
|
||||
const escapable = escaping === 'backslash' || escaping === 'bracketed'
|
||||
if (escapable && (mergesWithSyntax(scan, escapings, index) || opensConstruct(scan, escapings, index, escaping === 'bracketed', container, escaped))) {
|
||||
output += '\\'
|
||||
escaped.add(index)
|
||||
}
|
||||
placements.push(output.length)
|
||||
output += scan.charAt(index)
|
||||
}
|
||||
return { line: output, unspellableRun: unspellableRun(segments, output, placements) }
|
||||
}
|
||||
|
||||
function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): NodeRange | undefined {
|
||||
const { nodes, runs } = emittedRuns(segments, placements, output)
|
||||
const pair = misflanked(runs) ?? unpaired(runs)
|
||||
return pair === undefined ? undefined : nodes[pair]
|
||||
}
|
||||
|
||||
function misflanked(runs: readonly EmittedRun[]): number | undefined {
|
||||
for (const run of runs) {
|
||||
for (const delimiter of run.delimiters) {
|
||||
if (!(delimiter.closes ? run.canClose : run.canOpen)) return delimiter.pair
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function unpaired(runs: readonly EmittedRun[]): number | undefined {
|
||||
const matched = new Set<number>()
|
||||
for (const pairing of matchEmphasis(runs)) {
|
||||
const opened = delimiterAt(pairing.opener, false, pairing.openerOffset, pairing.used)
|
||||
const closed = delimiterAt(pairing.closer, true, pairing.closerOffset, pairing.used)
|
||||
if (opened !== undefined && closed !== undefined && opened.pair === closed.pair) matched.add(opened.pair)
|
||||
}
|
||||
// The last opener left unpaired is the innermost: the smallest carry that changes the line.
|
||||
let innermost: number | undefined
|
||||
for (const run of runs) {
|
||||
for (const delimiter of run.delimiters) {
|
||||
if (!delimiter.closes && !matched.has(delimiter.pair)) innermost = delimiter.pair
|
||||
}
|
||||
}
|
||||
return innermost
|
||||
}
|
||||
|
||||
function delimiterAt(run: EmittedRun, closes: boolean, offset: number, width: number): EmittedDelimiter | undefined {
|
||||
return run.delimiters.find((delimiter) => delimiter.closes === closes && delimiter.offset === offset && delimiter.width === width)
|
||||
}
|
||||
|
||||
function emittedRuns(segments: readonly InlineSegment[], placements: readonly number[], output: string): { nodes: NodeRange[]; runs: EmittedRun[] } {
|
||||
const runs: EmittedRun[] = []
|
||||
const nodes: NodeRange[] = []
|
||||
const open: number[] = []
|
||||
let cursor = 0
|
||||
for (const segment of segments) {
|
||||
const start = placements[cursor] ?? 0
|
||||
cursor += segment.text.length
|
||||
if (segment.emphasis === undefined) continue
|
||||
const closes = segment.emphasis === 'close'
|
||||
const pair = closes ? (open.pop() ?? nodes.length) : nodes.length
|
||||
if (!closes) {
|
||||
nodes.push(segment.nodes)
|
||||
open.push(pair)
|
||||
}
|
||||
const width = segment.text.length
|
||||
const previous = runs[runs.length - 1]
|
||||
if (previous !== undefined && previous.start + previous.length === start && previous.character === segment.text.charAt(0)) {
|
||||
previous.delimiters.push({ closes, offset: start - previous.start, pair, width })
|
||||
previous.length += width
|
||||
continue
|
||||
}
|
||||
runs.push({
|
||||
canClose: false,
|
||||
canOpen: false,
|
||||
character: segment.text.charAt(0),
|
||||
delimiters: [{ closes, offset: 0, pair, width }],
|
||||
length: width,
|
||||
start,
|
||||
})
|
||||
}
|
||||
for (const run of runs) {
|
||||
const flags = delimiterFlags(run.character, charAt(output, run.start - 1), output.charAt(run.start + run.length))
|
||||
run.canClose = flags.canClose
|
||||
run.canOpen = flags.canOpen
|
||||
}
|
||||
return { nodes, runs }
|
||||
}
|
||||
|
||||
function mergesWithSyntax(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean {
|
||||
const character = scan.charAt(index)
|
||||
if (character === '!') return scan.charAt(index + 1) === '[' && isSyntax(escapings[index + 1])
|
||||
if (character === '{') return scan.charAt(index - 1) === ']' && isSyntax(escapings[index - 1])
|
||||
if (!delimiters.includes(character)) return false
|
||||
return touchesSyntax(scan, escapings, index, -1) || touchesSyntax(scan, escapings, index, 1)
|
||||
}
|
||||
|
||||
function touchesSyntax(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number, step: number): boolean {
|
||||
const character = scan.charAt(index)
|
||||
let cursor = index + step
|
||||
while (scan.charAt(cursor) === character && !isSyntax(escapings[cursor])) cursor += step
|
||||
return scan.charAt(cursor) === character && isSyntax(escapings[cursor])
|
||||
}
|
||||
|
||||
function isSyntax(escaping: InlineEscaping | undefined): boolean {
|
||||
return escaping === 'none'
|
||||
}
|
||||
|
||||
function opensConstruct(
|
||||
scan: string,
|
||||
escapings: readonly (InlineEscaping | undefined)[],
|
||||
index: number,
|
||||
inBrackets: boolean,
|
||||
container: LineContainer,
|
||||
escaped: ReadonlySet<number>,
|
||||
): boolean {
|
||||
if (container === 'heading' && closesHeading(scan, index)) return true
|
||||
if (container === 'paragraph' && claimsLineStart(scan, index)) return true
|
||||
return claimsCharacter(scan, escapings, index, inBrackets, container, escaped)
|
||||
}
|
||||
|
||||
function claimsLineStart(scan: string, index: number): boolean {
|
||||
const start = scan.lastIndexOf('\n', index - 1) + 1
|
||||
const end = scan.indexOf('\n', index)
|
||||
const line = scan.slice(start, end === -1 ? undefined : end)
|
||||
const position: LinePosition = start === 0 ? 'first' : 'later'
|
||||
return escapesLineClaim(line, index - start, position)
|
||||
}
|
||||
|
||||
function closesHeading(scan: string, index: number): boolean {
|
||||
if (scan.charAt(index) !== '#' || !/^#+$/.test(scan.slice(index))) return false
|
||||
return index === 0 || /[ \t]/.test(scan.charAt(index - 1))
|
||||
}
|
||||
|
||||
function claimsCharacter(
|
||||
scan: string,
|
||||
escapings: readonly (InlineEscaping | undefined)[],
|
||||
index: number,
|
||||
inBrackets: boolean,
|
||||
container: LineContainer,
|
||||
escaped: ReadonlySet<number>,
|
||||
): boolean {
|
||||
const character = scan.charAt(index)
|
||||
const rest = scan.slice(index)
|
||||
if (inBrackets && (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))
|
||||
if (character === ':') return inlineDirectiveOpener.test(rest)
|
||||
if (character === '[') return opensLink(scan, escapings, index)
|
||||
if (character === '`') return opensCodeSpan(scan, index, escaped)
|
||||
if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped)
|
||||
return false
|
||||
}
|
||||
|
||||
// A `]` the emitter spelled sits inside a construct that binds before link text does.
|
||||
function opensLink(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean {
|
||||
for (let cursor = index + 1; cursor < scan.length; cursor += 1) {
|
||||
if (scan.charAt(cursor) !== ']' || isSyntax(escapings[cursor])) continue
|
||||
if (followsLinkText.test(scan.charAt(cursor + 1))) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
|
||||
if (!startsRun(scan, index, escaped)) return false
|
||||
const length = runLength(scan, index)
|
||||
return new RegExp('(?<!`)`{' + length + '}(?!`)').test(scan.slice(index + length))
|
||||
}
|
||||
|
||||
function claimsEmphasis(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
|
||||
if (!startsRun(scan, index, escaped)) return false
|
||||
const character = scan.charAt(index)
|
||||
const length = runLength(scan, index)
|
||||
if (character === '~' && length !== 2) return false
|
||||
const flags = delimiterFlags(character, index === 0 ? '' : scan.charAt(index - 1), scan.charAt(index + length))
|
||||
return flags.canClose || flags.canOpen
|
||||
}
|
||||
|
||||
function startsRun(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
|
||||
if (index === 0 || escaped.has(index - 1)) return true
|
||||
return scan.charAt(index - 1) !== scan.charAt(index)
|
||||
}
|
||||
|
||||
function runLength(scan: string, index: number): number {
|
||||
const character = scan.charAt(index)
|
||||
let length = 0
|
||||
while (scan.charAt(index + length) === character) length += 1
|
||||
return length
|
||||
}
|
||||
|
||||
function charAt(text: string, index: number): string {
|
||||
return index < 0 ? '' : text.charAt(index)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import { carriesOnly } from '../../adf/document.ts'
|
||||
import { tryPipeCell } from './inline-line.ts'
|
||||
import type { ConvertErrorPath } from '../../result.ts'
|
||||
|
||||
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): 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 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
|
||||
if (line === undefined) return undefined
|
||||
cells.push(line)
|
||||
}
|
||||
lines.push(`| ${cells.join(' | ')} |`)
|
||||
if (rowIndex === 0) lines.push(`| ${cells.map(() => '---').join(' | ')} |`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
||||
const rows = node.content ?? []
|
||||
const columns = (rows[0]?.content ?? []).length
|
||||
if (!carriesOnly(node, []) || columns === 0) return undefined
|
||||
const grid: AdfNode[][] = []
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const cells = row.content ?? []
|
||||
if (row.type !== 'tableRow' || !carriesOnly(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 || !carriesOnly(cell, [])) return undefined
|
||||
paragraphs.push(paragraph)
|
||||
}
|
||||
grid.push(paragraphs)
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
function plainParagraph(cell: AdfNode): AdfNode | undefined {
|
||||
const content = cell.content ?? []
|
||||
const paragraph = content[0]
|
||||
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined
|
||||
return paragraph
|
||||
}
|
||||
Reference in New Issue
Block a user