Share the CommonMark grammar, close the guard's hole, refuse the lists no marker spells
CI / gate (push) Successful in 4s
CI / gate (push) Successful in 4s
This commit is contained in:
+59
-47
@@ -1,9 +1,11 @@
|
||||
import type { AdfDocument, AdfNode } from './adf-document.ts'
|
||||
import type { JsonValue } from './json-value.ts'
|
||||
import { emitInlineLine } from './markdown-inline.ts'
|
||||
import { failure, success, type Result } from './result.ts'
|
||||
import { isAdfDocument } from './adf-document.ts'
|
||||
import { longestBacktickRun } from './backtick-runs.ts'
|
||||
|
||||
const largestListMarker = 999999999
|
||||
const listTypes = ['bulletList', 'orderedList']
|
||||
|
||||
export function adfToMarkdown(document: AdfDocument): Result<string> {
|
||||
@@ -16,10 +18,8 @@ export function adfToMarkdown(document: AdfDocument): Result<string> {
|
||||
|
||||
function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean): Result<string> {
|
||||
let output = ''
|
||||
for (let index = 0; index < nodes.length; index += 1) {
|
||||
const node = nodes[index]
|
||||
if (node === undefined) return failure('unsupported-node-shape', 'the block content holds a hole')
|
||||
const previous = nodes[index - 1]
|
||||
let previous: AdfNode | undefined
|
||||
for (const node of nodes) {
|
||||
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`)
|
||||
@@ -29,6 +29,7 @@ function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean): Result<stri
|
||||
const block = emitBlock(node)
|
||||
if (!block.ok) return block
|
||||
output += block.value
|
||||
previous = node
|
||||
}
|
||||
return success(output)
|
||||
}
|
||||
@@ -47,8 +48,8 @@ function emitBlock(node: AdfNode): Result<string> {
|
||||
}
|
||||
|
||||
function emitBlockquote(node: AdfNode): Result<string> {
|
||||
const invalid = validateBlockNode(node, [])
|
||||
if (!invalid.ok) return invalid
|
||||
const validation = validateBlockNode(node, [])
|
||||
if (!validation.ok) return validation
|
||||
const inner = emitBlocks(node.content ?? [], false)
|
||||
if (!inner.ok) return inner
|
||||
return success(
|
||||
@@ -60,17 +61,10 @@ function emitBlockquote(node: AdfNode): Result<string> {
|
||||
}
|
||||
|
||||
function emitCodeBlock(node: AdfNode): Result<string> {
|
||||
const invalid = validateBlockNode(node, ['language'])
|
||||
if (!invalid.ok) return invalid
|
||||
const language = node.attrs?.['language']
|
||||
if (language !== undefined) {
|
||||
if (typeof language !== 'string') return failure('unsupported-node-shape', 'a codeBlock language is no string')
|
||||
if (language === '') return failure('ambiguous-empty-code-block-language', 'an empty codeBlock language and an absent one share one markdown spelling')
|
||||
if (language === 'adf') return failure('reserved-adf-language', 'the adf info string is reserved for the opaque carry')
|
||||
if (/[`\n\r]/.test(language) || language !== language.trim()) {
|
||||
return failure('unspellable-code-block-language', 'a fence info string holds no backtick and no edge whitespace')
|
||||
}
|
||||
}
|
||||
const validation = validateBlockNode(node, ['language'])
|
||||
if (!validation.ok) return validation
|
||||
const info = spellCodeFenceInfo(node.attrs?.['language'])
|
||||
if (!info.ok) return info
|
||||
let text = ''
|
||||
for (const child of node.content ?? []) {
|
||||
if (child.type !== 'text' || typeof child.text !== 'string' || (child.marks ?? []).length > 0 || Object.keys(child.attrs ?? {}).length > 0) {
|
||||
@@ -79,13 +73,24 @@ function emitCodeBlock(node: AdfNode): Result<string> {
|
||||
text += child.text
|
||||
}
|
||||
const fence = '`'.repeat(Math.max(3, longestBacktickRun(text) + 1))
|
||||
const opening = `${fence}${language ?? ''}`
|
||||
const opening = `${fence}${info.value}`
|
||||
return success(text === '' ? `${opening}\n${fence}` : `${opening}\n${text}\n${fence}`)
|
||||
}
|
||||
|
||||
function spellCodeFenceInfo(language: JsonValue | undefined): Result<string> {
|
||||
if (language === undefined) return success('')
|
||||
if (typeof language !== 'string') return failure('unsupported-node-shape', 'a codeBlock language is no string')
|
||||
if (language === '') return failure('ambiguous-empty-code-block-language', 'an empty codeBlock language and an absent one share one markdown spelling')
|
||||
if (language === 'adf') return failure('reserved-adf-language', 'the adf info string is reserved for the opaque carry')
|
||||
if (/[`\n\r]/.test(language) || language !== language.trim()) {
|
||||
return failure('unspellable-code-block-language', 'a fence info string holds no backtick and no edge whitespace')
|
||||
}
|
||||
return success(language)
|
||||
}
|
||||
|
||||
function emitHeading(node: AdfNode): Result<string> {
|
||||
const invalid = validateBlockNode(node, ['level'])
|
||||
if (!invalid.ok) return invalid
|
||||
const validation = validateBlockNode(node, ['level'])
|
||||
if (!validation.ok) return validation
|
||||
const level = node.attrs?.['level']
|
||||
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) {
|
||||
return failure('unsupported-heading-level', `no ATX heading spells level ${JSON.stringify(level ?? null)}`)
|
||||
@@ -100,49 +105,56 @@ function emitHeading(node: AdfNode): Result<string> {
|
||||
|
||||
function emitList(node: AdfNode): Result<string> {
|
||||
const ordered = node.type === 'orderedList'
|
||||
const invalid = validateBlockNode(node, ordered ? ['order'] : [])
|
||||
if (!invalid.ok) return invalid
|
||||
const validation = validateBlockNode(node, ordered ? ['order'] : [])
|
||||
if (!validation.ok) return validation
|
||||
const items = node.content ?? []
|
||||
if (items.length === 0) return failure('unsupported-node-shape', `a ${node.type} holds at least one listItem`)
|
||||
const start = ordered ? node.attrs?.['order'] : 0
|
||||
if (ordered && (start === undefined || start === 1)) {
|
||||
return failure('ambiguous-ordered-list-start', 'an orderedList starting at 1 and one with no order share one markdown spelling')
|
||||
}
|
||||
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > 999999999) {
|
||||
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) {
|
||||
return failure('unsupported-node-shape', `no list marker spells the order ${JSON.stringify(start ?? null)}`)
|
||||
}
|
||||
const items: string[] = []
|
||||
for (const [offset, item] of (node.content ?? []).entries()) {
|
||||
if (item.type !== 'listItem') return failure('unsupported-node-shape', `a ${node.type} holds listItem nodes only`)
|
||||
const invalidItem = validateBlockNode(item, [])
|
||||
if (!invalidItem.ok) return invalidItem
|
||||
const inner = emitBlocks(item.content ?? [], true)
|
||||
if (!inner.ok) return inner
|
||||
const marker = ordered ? `${start + offset}. ` : '- '
|
||||
if (inner.value === '') {
|
||||
items.push(marker.trimEnd())
|
||||
continue
|
||||
}
|
||||
const indent = ' '.repeat(marker.length)
|
||||
items.push(
|
||||
inner.value
|
||||
.split('\n')
|
||||
.map((line, lineIndex) => (lineIndex === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
|
||||
.join('\n'),
|
||||
)
|
||||
if (start + items.length - 1 > largestListMarker) {
|
||||
return failure('unspellable-list-marker', `no list marker spells the ${items.length} items an orderedList starting at ${start} needs`)
|
||||
}
|
||||
return success(items.join('\n'))
|
||||
const lines: string[] = []
|
||||
for (const [offset, item] of items.entries()) {
|
||||
if (item.type !== 'listItem') return failure('unsupported-node-shape', `a ${node.type} holds listItem nodes only`)
|
||||
const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ')
|
||||
if (!emitted.ok) return emitted
|
||||
lines.push(emitted.value)
|
||||
}
|
||||
return success(lines.join('\n'))
|
||||
}
|
||||
|
||||
function emitListItem(item: AdfNode, marker: string): Result<string> {
|
||||
const validation = validateBlockNode(item, [])
|
||||
if (!validation.ok) return validation
|
||||
const inner = emitBlocks(item.content ?? [], true)
|
||||
if (!inner.ok) return inner
|
||||
if (inner.value === '') return success(marker.trimEnd())
|
||||
const indent = ' '.repeat(marker.length)
|
||||
return success(
|
||||
inner.value
|
||||
.split('\n')
|
||||
.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
|
||||
.join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
function emitParagraph(node: AdfNode): Result<string> {
|
||||
const invalid = validateBlockNode(node, [])
|
||||
if (!invalid.ok) return invalid
|
||||
const validation = validateBlockNode(node, [])
|
||||
if (!validation.ok) return validation
|
||||
const content = node.content ?? []
|
||||
if (content.length === 0) return success('::paragraph')
|
||||
return emitInlineLine(content, 'paragraph')
|
||||
}
|
||||
|
||||
function emitRule(node: AdfNode): Result<string> {
|
||||
const invalid = validateBlockNode(node, [])
|
||||
if (!invalid.ok) return invalid
|
||||
const validation = validateBlockNode(node, [])
|
||||
if (!validation.ok) return validation
|
||||
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a rule holds no content')
|
||||
return success('---')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user