This commit is contained in:
@@ -72,6 +72,11 @@ The emitted markdown and HTML are contracts. After 1.0: previously-emitted outpu
|
|||||||
differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR.
|
differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR.
|
||||||
Pre-1.0, normal 0.x rules.
|
Pre-1.0, normal 0.x rules.
|
||||||
|
|
||||||
|
The error surface is a contract too. `ConvertError` is `{ code, message, path }` — the code from a
|
||||||
|
closed list a consumer may switch exhaustively, the message free text, the path the node's position
|
||||||
|
from the document root. Adding, removing or renaming a code is breaking, so a milestone meeting a
|
||||||
|
new failure cause reuses a code where one fits; the list is complete at `0.1.0`.
|
||||||
|
|
||||||
## 9. Release automation
|
## 9. Release automation
|
||||||
|
|
||||||
- `package.json` version on `main` is the source of truth. CI on `main`: tests green and version
|
- `package.json` version on `main` is the source of truth. CI on `main`: tests green and version
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ isAdfDocument(v: unknown): v is AdfDocument
|
|||||||
```
|
```
|
||||||
|
|
||||||
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
|
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
|
||||||
|
`ConvertError` is `{ code, message, path }`: a code from a closed set, and the path of the node it
|
||||||
|
names, from the document root.
|
||||||
|
|
||||||
## The guarantees
|
## The guarantees
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=91 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
|
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=92 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ function markdown(result: Result<string>): string {
|
|||||||
return result.ok ? result.value : `${result.error.code}: ${result.error.message}`
|
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 = { attrs: { localId: 'a' }, 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: 'mention' })))), ['content', 0, 'content', 1])
|
||||||
|
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
|
||||||
|
})
|
||||||
|
|
||||||
test('refuses a value that is not an ADF document', () => {
|
test('refuses a value that is not an ADF document', () => {
|
||||||
assert.equal(code(adfToMarkdown({ type: 'doc', version: Number.NaN })), 'not-an-adf-document')
|
assert.equal(code(adfToMarkdown({ type: 'doc', version: Number.NaN })), 'not-an-adf-document')
|
||||||
})
|
})
|
||||||
|
|||||||
+58
-54
@@ -1,7 +1,7 @@
|
|||||||
import type { AdfDocument, AdfNode } from './adf-document.ts'
|
import type { AdfDocument, AdfNode } from './adf-document.ts'
|
||||||
import type { JsonValue } from './json-value.ts'
|
import type { JsonValue } from './json-value.ts'
|
||||||
import { emitInlineLine } from './markdown-inline.ts'
|
import { emitInlineLine } from './markdown-inline.ts'
|
||||||
import { failure, success, type Result } from './result.ts'
|
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
||||||
import { isAdfDocument } from './adf-document.ts'
|
import { isAdfDocument } from './adf-document.ts'
|
||||||
import { longestBacktickRun } from './backtick-runs.ts'
|
import { longestBacktickRun } from './backtick-runs.ts'
|
||||||
|
|
||||||
@@ -9,24 +9,25 @@ const largestListMarker = 999999999
|
|||||||
const listTypes = ['bulletList', 'orderedList']
|
const listTypes = ['bulletList', 'orderedList']
|
||||||
|
|
||||||
export function adfToMarkdown(document: AdfDocument): Result<string> {
|
export function adfToMarkdown(document: AdfDocument): Result<string> {
|
||||||
if (!isAdfDocument(document)) return failure('not-an-adf-document', 'the value is not an ADF document')
|
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}`)
|
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
||||||
const blocks = emitBlocks(document.content ?? [], false)
|
const blocks = emitBlocks(document.content ?? [], false, [])
|
||||||
if (!blocks.ok) return blocks
|
if (!blocks.ok) return blocks
|
||||||
return success(blocks.value === '' ? '' : `${blocks.value}\n`)
|
return success(blocks.value === '' ? '' : `${blocks.value}\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean): Result<string> {
|
function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean, path: ConvertErrorPath): Result<string> {
|
||||||
let output = ''
|
let output = ''
|
||||||
let previous: AdfNode | undefined
|
let previous: AdfNode | undefined
|
||||||
for (const node of nodes) {
|
for (const [index, node] of nodes.entries()) {
|
||||||
|
const nodePath = [...path, 'content', index]
|
||||||
if (previous !== undefined) {
|
if (previous !== undefined) {
|
||||||
if (listTypes.includes(node.type) && previous.type === node.type) {
|
if (listTypes.includes(node.type) && previous.type === node.type) {
|
||||||
return failure('unspellable-adjacent-lists', `two adjacent ${node.type} nodes read back as one list`)
|
return failure('unspellable-adjacent-lists', `two adjacent ${node.type} nodes read back as one list`, nodePath)
|
||||||
}
|
}
|
||||||
output += inListItem && listTypes.includes(node.type) ? '\n' : '\n\n'
|
output += inListItem && listTypes.includes(node.type) ? '\n' : '\n\n'
|
||||||
}
|
}
|
||||||
const block = emitBlock(node)
|
const block = emitBlock(node, nodePath)
|
||||||
if (!block.ok) return block
|
if (!block.ok) return block
|
||||||
output += block.value
|
output += block.value
|
||||||
previous = node
|
previous = node
|
||||||
@@ -34,23 +35,23 @@ function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean): Result<stri
|
|||||||
return success(output)
|
return success(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitBlock(node: AdfNode): Result<string> {
|
function emitBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||||
if (node.type === 'blockquote') return emitBlockquote(node)
|
if (node.type === 'blockquote') return emitBlockquote(node, path)
|
||||||
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node)
|
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path)
|
||||||
if (node.type === 'codeBlock') return emitCodeBlock(node)
|
if (node.type === 'codeBlock') return emitCodeBlock(node, path)
|
||||||
if (node.type === 'heading') return emitHeading(node)
|
if (node.type === 'heading') return emitHeading(node, path)
|
||||||
if (node.type === 'paragraph') return emitParagraph(node)
|
if (node.type === 'paragraph') return emitParagraph(node, path)
|
||||||
if (node.type === 'rule') return emitRule(node)
|
if (node.type === 'rule') return emitRule(node, path)
|
||||||
if (node.type === 'hardBreak' || node.type === 'listItem' || node.type === 'text') {
|
if (node.type === 'hardBreak' || node.type === 'listItem' || node.type === 'text') {
|
||||||
return failure('unsupported-node-shape', `a ${node.type} node cannot stand where a block belongs`)
|
return failure('unsupported-node-shape', `a ${node.type} node cannot stand where a block belongs`, path)
|
||||||
}
|
}
|
||||||
return failure('unsupported-node-type', `the canonical form spells no block node of type ${node.type}`)
|
return failure('unsupported-node-type', `the canonical form spells no block node of type ${node.type}`, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitBlockquote(node: AdfNode): Result<string> {
|
function emitBlockquote(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||||
const validation = validateBlockNode(node, [])
|
const validation = validateBlockNode(node, [], path)
|
||||||
if (!validation.ok) return validation
|
if (!validation.ok) return validation
|
||||||
const inner = emitBlocks(node.content ?? [], false)
|
const inner = emitBlocks(node.content ?? [], false, path)
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
return success(
|
return success(
|
||||||
inner.value
|
inner.value
|
||||||
@@ -60,15 +61,15 @@ function emitBlockquote(node: AdfNode): Result<string> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitCodeBlock(node: AdfNode): Result<string> {
|
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||||
const validation = validateBlockNode(node, ['language'])
|
const validation = validateBlockNode(node, ['language'], path)
|
||||||
if (!validation.ok) return validation
|
if (!validation.ok) return validation
|
||||||
const info = spellCodeFenceInfo(node.attrs?.['language'])
|
const info = spellCodeFenceInfo(node.attrs?.['language'], path)
|
||||||
if (!info.ok) return info
|
if (!info.ok) return info
|
||||||
let text = ''
|
let text = ''
|
||||||
for (const child of node.content ?? []) {
|
for (const child of node.content ?? []) {
|
||||||
if (child.type !== 'text' || typeof child.text !== 'string' || (child.marks ?? []).length > 0 || Object.keys(child.attrs ?? {}).length > 0) {
|
if (child.type !== 'text' || typeof child.text !== 'string' || (child.marks ?? []).length > 0 || Object.keys(child.attrs ?? {}).length > 0) {
|
||||||
return failure('unsupported-node-shape', 'a codeBlock holds plain text nodes only')
|
return failure('unsupported-node-shape', 'a codeBlock holds plain text nodes only', path)
|
||||||
}
|
}
|
||||||
text += child.text
|
text += child.text
|
||||||
}
|
}
|
||||||
@@ -77,62 +78,65 @@ function emitCodeBlock(node: AdfNode): Result<string> {
|
|||||||
return success(text === '' ? `${opening}\n${fence}` : `${opening}\n${text}\n${fence}`)
|
return success(text === '' ? `${opening}\n${fence}` : `${opening}\n${text}\n${fence}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function spellCodeFenceInfo(language: JsonValue | undefined): Result<string> {
|
function spellCodeFenceInfo(language: JsonValue | undefined, path: ConvertErrorPath): Result<string> {
|
||||||
if (language === undefined) return success('')
|
if (language === undefined) return success('')
|
||||||
if (typeof language !== 'string') return failure('unsupported-node-shape', 'a codeBlock language is no string')
|
if (typeof language !== 'string') return failure('unsupported-node-shape', 'a codeBlock language is no string', path)
|
||||||
if (language === '') return failure('ambiguous-empty-code-block-language', 'an empty codeBlock language and an absent one share one markdown spelling')
|
if (language === '') {
|
||||||
if (language === 'adf') return failure('reserved-adf-language', 'the adf info string is reserved for the opaque carry')
|
return failure('ambiguous-empty-code-block-language', 'an empty codeBlock language and an absent one share one markdown spelling', path)
|
||||||
|
}
|
||||||
|
if (language === 'adf') return failure('reserved-adf-language', 'the adf info string is reserved for the opaque carry', path)
|
||||||
if (/[`\n\r]/.test(language) || language !== language.trim()) {
|
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 failure('unspellable-code-block-language', 'a fence info string holds no backtick and no edge whitespace', path)
|
||||||
}
|
}
|
||||||
return success(language)
|
return success(language)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitHeading(node: AdfNode): Result<string> {
|
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||||
const validation = validateBlockNode(node, ['level'])
|
const validation = validateBlockNode(node, ['level'], path)
|
||||||
if (!validation.ok) return validation
|
if (!validation.ok) return validation
|
||||||
const level = node.attrs?.['level']
|
const level = node.attrs?.['level']
|
||||||
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) {
|
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)}`)
|
return failure('unsupported-heading-level', `no ATX heading spells level ${JSON.stringify(level ?? null)}`, path)
|
||||||
}
|
}
|
||||||
const hashes = '#'.repeat(level)
|
const hashes = '#'.repeat(level)
|
||||||
const content = node.content ?? []
|
const content = node.content ?? []
|
||||||
if (content.length === 0) return success(hashes)
|
if (content.length === 0) return success(hashes)
|
||||||
const line = emitInlineLine(content, 'heading')
|
const line = emitInlineLine(content, 'heading', path)
|
||||||
if (!line.ok) return line
|
if (!line.ok) return line
|
||||||
return success(`${hashes} ${line.value}`)
|
return success(`${hashes} ${line.value}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitList(node: AdfNode): Result<string> {
|
function emitList(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||||
const ordered = node.type === 'orderedList'
|
const ordered = node.type === 'orderedList'
|
||||||
const validation = validateBlockNode(node, ordered ? ['order'] : [])
|
const validation = validateBlockNode(node, ordered ? ['order'] : [], path)
|
||||||
if (!validation.ok) return validation
|
if (!validation.ok) return validation
|
||||||
const items = node.content ?? []
|
const items = node.content ?? []
|
||||||
if (items.length === 0) return failure('unsupported-node-shape', `a ${node.type} holds at least one listItem`)
|
if (items.length === 0) return failure('unsupported-node-shape', `a ${node.type} holds at least one listItem`, path)
|
||||||
const start = ordered ? node.attrs?.['order'] : 0
|
const start = ordered ? node.attrs?.['order'] : 0
|
||||||
if (ordered && (start === undefined || start === 1)) {
|
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')
|
return failure('ambiguous-ordered-list-start', 'an orderedList starting at 1 and one with no order share one markdown spelling', path)
|
||||||
}
|
}
|
||||||
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) {
|
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)}`)
|
return failure('unsupported-node-shape', `no list marker spells the order ${JSON.stringify(start ?? null)}`, path)
|
||||||
}
|
}
|
||||||
if (start + items.length - 1 > largestListMarker) {
|
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 failure('unspellable-list-marker', `no list marker spells the ${items.length} items a list starting at ${start} needs`, path)
|
||||||
}
|
}
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
for (const [offset, item] of items.entries()) {
|
for (const [offset, item] of items.entries()) {
|
||||||
if (item.type !== 'listItem') return failure('unsupported-node-shape', `a ${node.type} holds listItem nodes only`)
|
const itemPath = [...path, 'content', offset]
|
||||||
const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ')
|
if (item.type !== 'listItem') return failure('unsupported-node-shape', `a ${node.type} holds listItem nodes only`, itemPath)
|
||||||
|
const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', itemPath)
|
||||||
if (!emitted.ok) return emitted
|
if (!emitted.ok) return emitted
|
||||||
lines.push(emitted.value)
|
lines.push(emitted.value)
|
||||||
}
|
}
|
||||||
return success(lines.join('\n'))
|
return success(lines.join('\n'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitListItem(item: AdfNode, marker: string): Result<string> {
|
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath): Result<string> {
|
||||||
const validation = validateBlockNode(item, [])
|
const validation = validateBlockNode(item, [], path)
|
||||||
if (!validation.ok) return validation
|
if (!validation.ok) return validation
|
||||||
const inner = emitBlocks(item.content ?? [], true)
|
const inner = emitBlocks(item.content ?? [], true, path)
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
if (inner.value === '') return success(marker.trimEnd())
|
if (inner.value === '') return success(marker.trimEnd())
|
||||||
const indent = ' '.repeat(marker.length)
|
const indent = ' '.repeat(marker.length)
|
||||||
@@ -144,29 +148,29 @@ function emitListItem(item: AdfNode, marker: string): Result<string> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitParagraph(node: AdfNode): Result<string> {
|
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||||
const validation = validateBlockNode(node, [])
|
const validation = validateBlockNode(node, [], path)
|
||||||
if (!validation.ok) return validation
|
if (!validation.ok) return validation
|
||||||
const content = node.content ?? []
|
const content = node.content ?? []
|
||||||
if (content.length === 0) return success('::paragraph')
|
if (content.length === 0) return success('::paragraph')
|
||||||
return emitInlineLine(content, 'paragraph')
|
return emitInlineLine(content, 'paragraph', path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitRule(node: AdfNode): Result<string> {
|
function emitRule(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||||
const validation = validateBlockNode(node, [])
|
const validation = validateBlockNode(node, [], path)
|
||||||
if (!validation.ok) return validation
|
if (!validation.ok) return validation
|
||||||
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a rule holds no content')
|
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a rule holds no content', path)
|
||||||
return success('---')
|
return success('---')
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateBlockNode(node: AdfNode, spelled: readonly string[]): Result<null> {
|
function validateBlockNode(node: AdfNode, spelled: readonly string[], path: ConvertErrorPath): Result<null> {
|
||||||
if ((node.marks ?? []).length > 0) {
|
if ((node.marks ?? []).length > 0) {
|
||||||
return failure('unspelled-block-marks', `the canonical form has no place for the marks a ${node.type} carries`)
|
return failure('unspelled-block-marks', `the canonical form has no place for the marks a ${node.type} carries`, path)
|
||||||
}
|
}
|
||||||
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`)
|
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path)
|
||||||
const unspelled = Object.keys(node.attrs ?? {}).find((key) => !spelled.includes(key))
|
const unspelled = Object.keys(node.attrs ?? {}).find((key) => !spelled.includes(key))
|
||||||
if (unspelled !== undefined) {
|
if (unspelled !== undefined) {
|
||||||
return failure('unspelled-node-attribute', `the ${node.type} attribute ${unspelled} has no canonical markdown spelling`)
|
return failure('unspelled-node-attribute', `the ${node.type} attribute ${unspelled} has no canonical markdown spelling`, path)
|
||||||
}
|
}
|
||||||
return success(null)
|
return success(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
export type LinePosition = 'first' | 'later'
|
export type LinePosition = 'first' | 'later'
|
||||||
|
|
||||||
|
const controlCharacterRange = '\\u0000-\\u001f\\u007f'
|
||||||
|
const autolinkSource = `[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\\s<>${controlCharacterRange}]*`
|
||||||
const entityReferenceSource = '&(?:[A-Za-z][A-Za-z0-9]{1,31}|#\\d{1,7}|#[Xx][A-Fa-f0-9]{1,6});'
|
const entityReferenceSource = '&(?:[A-Za-z][A-Za-z0-9]{1,31}|#\\d{1,7}|#[Xx][A-Fa-f0-9]{1,6});'
|
||||||
|
|
||||||
const anchoredEntityReference = new RegExp(`^(?:${entityReferenceSource})`)
|
const anchoredEntityReference = new RegExp(`^(?:${entityReferenceSource})`)
|
||||||
|
const autolink = new RegExp(`^(?:${autolinkSource})$`)
|
||||||
|
const bracketedAutolink = new RegExp(`^<(?:${autolinkSource})>`)
|
||||||
|
const controlCharacter = new RegExp(`[${controlCharacterRange}]`)
|
||||||
const entityReference = new RegExp(entityReferenceSource)
|
const entityReference = new RegExp(entityReferenceSource)
|
||||||
const firstCharacterOpeners = [/^#{1,6}(?:[ \t]|$)/, /^>/, /^[*+-](?:[ \t]|$)/, /^`{3,}/, /^~{3,}/, /^:{2,}/, /^\|/]
|
const firstCharacterOpeners = [/^#{1,6}(?:[ \t]|$)/, /^>/, /^[*+-](?:[ \t]|$)/, /^`{3,}/, /^~{3,}/, /^:{2,}/, /^\|/]
|
||||||
const orderedListOpener = /^(\d{1,9})[.)](?:[ \t]|$)/
|
const orderedListOpener = /^(\d{1,9})[.)](?:[ \t]|$)/
|
||||||
@@ -22,10 +27,22 @@ export function escapesLineClaim(line: string, offset: number, position: LinePos
|
|||||||
return digits !== undefined && offset === digits.length
|
return digits !== undefined && offset === digits.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function holdsControlCharacter(text: string): boolean {
|
||||||
|
return controlCharacter.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
export function holdsEntityReference(text: string): boolean {
|
export function holdsEntityReference(text: string): boolean {
|
||||||
return entityReference.test(text)
|
return entityReference.test(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAutolink(text: string): boolean {
|
||||||
|
return autolink.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function opensBracketedAutolink(text: string): boolean {
|
||||||
|
return bracketedAutolink.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
export function startsEntityReference(text: string): boolean {
|
export function startsEntityReference(text: string): boolean {
|
||||||
return anchoredEntityReference.test(text)
|
return anchoredEntityReference.test(text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { escapesLineClaim, startsEntityReference, type LinePosition } from './commonmark-grammar.ts'
|
import { escapesLineClaim, opensBracketedAutolink, startsEntityReference, type LinePosition } from './commonmark-grammar.ts'
|
||||||
|
|
||||||
export type InlineSegment = {
|
export type InlineSegment = {
|
||||||
kind: 'emphasis-close' | 'emphasis-open' | 'link-text' | 'literal' | 'syntax'
|
kind: 'emphasis-close' | 'emphasis-open' | 'link-text' | 'literal' | 'syntax'
|
||||||
@@ -8,7 +8,7 @@ export type InlineSegment = {
|
|||||||
export type LineContainer = 'heading' | 'paragraph'
|
export type LineContainer = 'heading' | 'paragraph'
|
||||||
|
|
||||||
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
||||||
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>]*>/, /^<[^\s<>@]+@[^\s<>@]+>/]
|
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
|
||||||
const inlineDirective = /^:[a-z][A-Za-z0-9]*[[{]/
|
const inlineDirective = /^:[a-z][A-Za-z0-9]*[[{]/
|
||||||
const linkOpener = /\](?=[([:])/
|
const linkOpener = /\](?=[([:])/
|
||||||
const unicodePunctuation = /[\p{P}\p{S}]/u
|
const unicodePunctuation = /[\p{P}\p{S}]/u
|
||||||
@@ -88,7 +88,7 @@ function claimsCharacter(scan: string, index: number, inLinkText: boolean, escap
|
|||||||
if (inLinkText && (character === '[' || character === ']')) return true
|
if (inLinkText && (character === '[' || character === ']')) return true
|
||||||
if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1))
|
if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1))
|
||||||
if (character === '&') return startsEntityReference(rest)
|
if (character === '&') return startsEntityReference(rest)
|
||||||
if (character === '<') return htmlConstructs.some((construct) => construct.test(rest))
|
if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest))
|
||||||
if (character === ':') return inlineDirective.test(rest)
|
if (character === ':') return inlineDirective.test(rest)
|
||||||
if (character === '[') return linkOpener.test(rest)
|
if (character === '[') return linkOpener.test(rest)
|
||||||
if (character === '`') return opensCodeSpan(scan, index, escaped)
|
if (character === '`') return opensCodeSpan(scan, index, escaped)
|
||||||
|
|||||||
+62
-53
@@ -1,7 +1,7 @@
|
|||||||
import type { AdfMark, AdfNode } from './adf-document.ts'
|
import type { AdfMark, AdfNode } from './adf-document.ts'
|
||||||
import { assembleInlineLine, type InlineSegment, type LineContainer } from './markdown-escaping.ts'
|
import { assembleInlineLine, type InlineSegment, type LineContainer } from './markdown-escaping.ts'
|
||||||
import { claimsLine, holdsEntityReference } from './commonmark-grammar.ts'
|
import { claimsLine, holdsControlCharacter, holdsEntityReference, isAutolink } from './commonmark-grammar.ts'
|
||||||
import { failure, success, type Result } from './result.ts'
|
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
||||||
import { longestBacktickRun } from './backtick-runs.ts'
|
import { longestBacktickRun } from './backtick-runs.ts'
|
||||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||||
|
|
||||||
@@ -9,97 +9,103 @@ type InlineContext = {
|
|||||||
atBlockEnd: boolean
|
atBlockEnd: boolean
|
||||||
container: LineContainer
|
container: LineContainer
|
||||||
inLinkText: boolean
|
inLinkText: boolean
|
||||||
|
path: ConvertErrorPath
|
||||||
}
|
}
|
||||||
|
|
||||||
type InlineRun = { kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { kind: 'plain'; node: AdfNode }
|
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
|
||||||
|
|
||||||
const autolink = /^[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>\u0000-\u001f\u007f]*$/
|
|
||||||
const controlCharacter = /[\u0000-\u001f\u007f]/
|
|
||||||
const linkAttributes = ['href', 'title']
|
const linkAttributes = ['href', 'title']
|
||||||
|
|
||||||
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer): Result<string> {
|
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
|
||||||
const segments = emitRun(nodes, 0, { atBlockEnd: true, container, inLinkText: false })
|
const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, container, inLinkText: false, path })
|
||||||
if (!segments.ok) return segments
|
if (!segments.ok) return segments
|
||||||
const line = assembleInlineLine(segments.value, container)
|
const line = assembleInlineLine(segments.value, container)
|
||||||
for (const [index, single] of line.split('\n').entries()) {
|
for (const [index, single] of line.split('\n').entries()) {
|
||||||
if (/^[ \t]|[ \t]$/.test(single)) {
|
if (/^[ \t]|[ \t]$/.test(single)) {
|
||||||
return failure('unspellable-whitespace', 'a line begins or ends with whitespace CommonMark strips')
|
return failure('unspellable-whitespace', 'a line begins or ends with whitespace CommonMark strips', path)
|
||||||
}
|
}
|
||||||
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) {
|
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) {
|
||||||
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`)
|
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`, path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return success(line)
|
return success(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitRun(nodes: readonly AdfNode[], depth: number, context: InlineContext): Result<InlineSegment[]> {
|
function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result<InlineSegment[]> {
|
||||||
const runs = inlineRuns(nodes, depth)
|
const runs = inlineRuns(nodes, depth, firstIndex)
|
||||||
const segments: InlineSegment[] = []
|
const segments: InlineSegment[] = []
|
||||||
for (const [index, run] of runs.entries()) {
|
for (const [offset, run] of runs.entries()) {
|
||||||
const runContext = { ...context, atBlockEnd: context.atBlockEnd && index === runs.length - 1 }
|
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
|
||||||
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext) : emitMarkedRun(run.nodes, run.mark, depth, runContext)
|
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.ok) return emitted
|
||||||
segments.push(...emitted.value)
|
segments.push(...emitted.value)
|
||||||
}
|
}
|
||||||
return success(segments)
|
return success(segments)
|
||||||
}
|
}
|
||||||
|
|
||||||
function inlineRuns(nodes: readonly AdfNode[], depth: number): InlineRun[] {
|
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number): InlineRun[] {
|
||||||
const runs: InlineRun[] = []
|
const runs: InlineRun[] = []
|
||||||
for (const node of nodes) {
|
for (const [offset, node] of nodes.entries()) {
|
||||||
|
const index = firstIndex + offset
|
||||||
const mark = (node.marks ?? [])[depth]
|
const mark = (node.marks ?? [])[depth]
|
||||||
if (mark === undefined) {
|
if (mark === undefined) {
|
||||||
runs.push({ kind: 'plain', node })
|
runs.push({ index, kind: 'plain', node })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const previous = runs[runs.length - 1]
|
const previous = runs[runs.length - 1]
|
||||||
if (previous?.kind === 'marked' && sameMark(previous.mark, mark)) previous.nodes.push(node)
|
if (previous?.kind === 'marked' && sameMark(previous.mark, mark)) previous.nodes.push(node)
|
||||||
else runs.push({ kind: 'marked', mark, nodes: [node] })
|
else runs.push({ index, kind: 'marked', mark, nodes: [node] })
|
||||||
}
|
}
|
||||||
return runs
|
return runs
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitLeaf(node: AdfNode, context: InlineContext): Result<InlineSegment[]> {
|
function nodePath(context: InlineContext, index: number): ConvertErrorPath {
|
||||||
|
return [...context.path, 'content', index]
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<InlineSegment[]> {
|
||||||
|
const path = nodePath(context, index)
|
||||||
if (node.type !== 'hardBreak' && node.type !== 'text') {
|
if (node.type !== 'hardBreak' && node.type !== 'text') {
|
||||||
return failure('unsupported-node-type', `the canonical form spells no inline node of type ${node.type}`)
|
return failure('unsupported-node-type', `the canonical form spells no inline node of type ${node.type}`, path)
|
||||||
}
|
}
|
||||||
const unspelled = Object.keys(node.attrs ?? {})[0]
|
const unspelled = Object.keys(node.attrs ?? {})[0]
|
||||||
if (unspelled !== undefined) {
|
if (unspelled !== undefined) {
|
||||||
return failure('unspelled-node-attribute', `the ${node.type} attribute ${unspelled} has no canonical markdown spelling`)
|
return failure('unspelled-node-attribute', `the ${node.type} attribute ${unspelled} has no canonical markdown spelling`, path)
|
||||||
}
|
}
|
||||||
if (node.type === 'hardBreak') {
|
if (node.type === 'hardBreak') {
|
||||||
if (context.container === 'heading' || context.atBlockEnd) return success([{ kind: 'syntax', text: ':hardBreak{}' }])
|
if (context.container === 'heading' || context.atBlockEnd) return success([{ kind: 'syntax', text: ':hardBreak{}' }])
|
||||||
return success([{ kind: 'syntax', text: '\\\n' }])
|
return success([{ kind: 'syntax', text: '\\\n' }])
|
||||||
}
|
}
|
||||||
if (typeof node.text !== 'string') return failure('unsupported-node-shape', 'a text node carries no text')
|
if (typeof node.text !== 'string') return failure('unsupported-node-shape', 'a text node carries no text', path)
|
||||||
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node carries content')
|
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node carries content', path)
|
||||||
if (/[\n\r]/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a newline CommonMark cannot spell')
|
if (/[\n\r]/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a newline CommonMark cannot spell', path)
|
||||||
return success([{ kind: context.inLinkText ? 'link-text' : 'literal', text: node.text }])
|
return success([{ kind: context.inLinkText ? 'link-text' : 'literal', text: node.text }])
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, context: InlineContext): Result<InlineSegment[]> {
|
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<InlineSegment[]> {
|
||||||
if (mark.type === 'code') return emitCodeSpan(nodes, depth)
|
if (mark.type === 'code') return emitCodeSpan(nodes, depth, nodePath(context, index))
|
||||||
if (mark.type === 'link') return emitLink(nodes, mark, depth, context)
|
if (mark.type === 'link') return emitLink(nodes, mark, depth, index, context)
|
||||||
|
const path = nodePath(context, index)
|
||||||
const spelling = mark.type === 'em' ? '_' : mark.type === 'strike' ? '~~' : mark.type === 'strong' ? '**' : undefined
|
const spelling = mark.type === 'em' ? '_' : mark.type === 'strike' ? '~~' : mark.type === 'strong' ? '**' : undefined
|
||||||
if (spelling === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`)
|
if (spelling === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`, path)
|
||||||
if (Object.keys(mark.attrs ?? {}).length > 0) return failure('unspellable-mark', `the ${mark.type} spelling holds no attributes`)
|
if (Object.keys(mark.attrs ?? {}).length > 0) return failure('unspellable-mark', `the ${mark.type} spelling holds no attributes`, path)
|
||||||
const inner = emitRun(nodes, depth + 1, context)
|
const inner = emitRun(nodes, depth + 1, index, context)
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
const text = inner.value.map((segment) => segment.text).join('')
|
const text = inner.value.map((segment) => segment.text).join('')
|
||||||
if (/^[ \t]|[ \t]$/.test(text)) return failure('unspellable-whitespace', `the ${mark.type} spelling cannot open or close beside whitespace`)
|
if (/^[ \t]|[ \t]$/.test(text)) return failure('unspellable-whitespace', `the ${mark.type} spelling cannot open or close beside whitespace`, path)
|
||||||
if (mark.type === 'em') {
|
if (mark.type === 'em') {
|
||||||
return success([{ kind: 'emphasis-open', text: spelling }, ...inner.value, { kind: 'emphasis-close', text: spelling }])
|
return success([{ kind: 'emphasis-open', text: spelling }, ...inner.value, { kind: 'emphasis-close', text: spelling }])
|
||||||
}
|
}
|
||||||
return success([{ kind: 'syntax', text: spelling }, ...inner.value, { kind: 'syntax', text: spelling }])
|
return success([{ kind: 'syntax', text: spelling }, ...inner.value, { kind: 'syntax', text: spelling }])
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number): Result<InlineSegment[]> {
|
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result<InlineSegment[]> {
|
||||||
const node = nodes[0]
|
const node = nodes[0]
|
||||||
if (nodes.length !== 1 || node === undefined || node.type !== 'text' || typeof node.text !== 'string') {
|
if (nodes.length !== 1 || node === undefined || node.type !== 'text' || typeof node.text !== 'string') {
|
||||||
return failure('unspellable-mark', 'a code span holds exactly one text node')
|
return failure('unspellable-mark', 'a code span holds exactly one text node', path)
|
||||||
}
|
}
|
||||||
if ((node.marks ?? []).length !== depth + 1) return failure('unspellable-mark', 'a code span cannot sit inside the marks it carries')
|
if ((node.marks ?? []).length !== depth + 1) return failure('unspellable-mark', 'a code span cannot sit inside the marks it carries', path)
|
||||||
if (/[\n\r]/.test(node.text)) return failure('unspellable-mark', 'a code span holds no newline')
|
if (/[\n\r]/.test(node.text)) return failure('unspellable-mark', 'a code span holds no newline', path)
|
||||||
const fence = '`'.repeat(longestBacktickRun(node.text) + 1)
|
const fence = '`'.repeat(longestBacktickRun(node.text) + 1)
|
||||||
const padded = needsPadding(node.text) ? ` ${node.text} ` : node.text
|
const padded = needsPadding(node.text) ? ` ${node.text} ` : node.text
|
||||||
return success([{ kind: 'syntax', text: `${fence}${padded}${fence}` }])
|
return success([{ kind: 'syntax', text: `${fence}${padded}${fence}` }])
|
||||||
@@ -110,45 +116,48 @@ function needsPadding(text: string): boolean {
|
|||||||
return text.startsWith(' ') && text.endsWith(' ') && text.trim() !== ''
|
return text.startsWith(' ') && text.endsWith(' ') && text.trim() !== ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, context: InlineContext): Result<InlineSegment[]> {
|
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<InlineSegment[]> {
|
||||||
|
const path = nodePath(context, index)
|
||||||
const unspelled = Object.keys(mark.attrs ?? {}).find((key) => !linkAttributes.includes(key))
|
const unspelled = Object.keys(mark.attrs ?? {}).find((key) => !linkAttributes.includes(key))
|
||||||
if (unspelled !== undefined) return failure('unspellable-mark', `the link spelling holds no ${unspelled} attribute`)
|
if (unspelled !== undefined) return failure('unspellable-mark', `the link spelling holds no ${unspelled} attribute`, path)
|
||||||
const href = mark.attrs?.['href']
|
const href = mark.attrs?.['href']
|
||||||
const title = mark.attrs?.['title']
|
const title = mark.attrs?.['title']
|
||||||
if (typeof href !== 'string') return failure('unsupported-node-shape', 'a link mark carries no href')
|
if (typeof href !== 'string') return failure('unsupported-node-shape', 'a link mark carries no href', path)
|
||||||
if (title !== undefined && typeof title !== 'string') return failure('unsupported-node-shape', 'a link title is no string')
|
if (title !== undefined && typeof title !== 'string') return failure('unsupported-node-shape', 'a link title is no string', path)
|
||||||
const node = nodes[0]
|
const node = nodes[0]
|
||||||
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1
|
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1
|
||||||
if (bare && title === undefined && autolink.test(href)) return success([{ kind: 'syntax', text: `<${href}>` }])
|
if (bare && title === undefined && isAutolink(href)) return success([{ kind: 'syntax', text: `<${href}>` }])
|
||||||
const destination = spellDestination(href)
|
const destination = spellDestination(href, path)
|
||||||
if (!destination.ok) return destination
|
if (!destination.ok) return destination
|
||||||
const spelledTitle = title === undefined ? success('') : spellTitle(title)
|
const spelledTitle = title === undefined ? success('') : spellTitle(title, path)
|
||||||
if (!spelledTitle.ok) return spelledTitle
|
if (!spelledTitle.ok) return spelledTitle
|
||||||
const inner = emitRun(nodes, depth + 1, { ...context, inLinkText: true })
|
const inner = emitRun(nodes, depth + 1, index, { ...context, inLinkText: true })
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
return success([{ kind: 'syntax', text: '[' }, ...inner.value, { kind: 'syntax', text: `](${destination.value}${spelledTitle.value})` }])
|
return success([{ kind: 'syntax', text: '[' }, ...inner.value, { kind: 'syntax', text: `](${destination.value}${spelledTitle.value})` }])
|
||||||
}
|
}
|
||||||
|
|
||||||
function spellDestination(href: string): Result<string> {
|
function spellDestination(href: string, path: ConvertErrorPath): Result<string> {
|
||||||
if (controlCharacter.test(href)) return failure('unspellable-link-destination', 'a link destination holds a control character')
|
if (holdsControlCharacter(href)) return failure('unspellable-link-destination', 'a link destination holds a control character', path)
|
||||||
if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination')
|
if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination', path)
|
||||||
if (holdsEntityReference(href)) {
|
if (holdsEntityReference(href)) {
|
||||||
return failure('unspellable-link-destination', 'a link destination shaped like an entity reference decodes on the way back')
|
return failure('unspellable-link-destination', 'a link destination shaped like an entity reference decodes on the way back', path)
|
||||||
}
|
}
|
||||||
if (href.includes(' ')) {
|
if (href.includes(' ')) {
|
||||||
if (/[<>]/.test(href)) {
|
if (/[<>]/.test(href)) {
|
||||||
return failure('unspellable-link-destination', 'no canonical escape spells an angle bracket beside a space in a link destination')
|
return failure('unspellable-link-destination', 'no canonical escape spells an angle bracket beside a space in a link destination', path)
|
||||||
}
|
}
|
||||||
return success(`<${href}>`)
|
return success(`<${href}>`)
|
||||||
}
|
}
|
||||||
if (href.startsWith('<')) return failure('unspellable-link-destination', 'a bare link destination cannot begin with an angle bracket')
|
if (href.startsWith('<')) return failure('unspellable-link-destination', 'a bare link destination cannot begin with an angle bracket', path)
|
||||||
if (!balanced(href)) return failure('unspellable-link-destination', 'no canonical escape spells an unbalanced parenthesis in a link destination')
|
if (!balanced(href)) return failure('unspellable-link-destination', 'no canonical escape spells an unbalanced parenthesis in a link destination', path)
|
||||||
return success(href)
|
return success(href)
|
||||||
}
|
}
|
||||||
|
|
||||||
function spellTitle(title: string): Result<string> {
|
function spellTitle(title: string, path: ConvertErrorPath): Result<string> {
|
||||||
if (/["\n\r\\]/.test(title)) return failure('unspellable-link-title', 'no canonical escape spells a quote, backslash or newline in a link title')
|
if (/["\n\r\\]/.test(title)) {
|
||||||
if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title shaped like an entity reference decodes on the way back')
|
return failure('unspellable-link-title', 'no canonical escape spells a quote, backslash or newline in a link title', path)
|
||||||
|
}
|
||||||
|
if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title shaped like an entity reference decodes on the way back', path)
|
||||||
return success(` "${title}"`)
|
return success(` "${title}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -18,15 +18,18 @@ export type ConvertErrorCode =
|
|||||||
| 'unsupported-node-shape'
|
| 'unsupported-node-shape'
|
||||||
| 'unsupported-node-type'
|
| 'unsupported-node-type'
|
||||||
|
|
||||||
|
export type ConvertErrorPath = readonly (number | string)[]
|
||||||
|
|
||||||
export type ConvertError = {
|
export type ConvertError = {
|
||||||
code: ConvertErrorCode
|
code: ConvertErrorCode
|
||||||
message: string
|
message: string
|
||||||
|
path: ConvertErrorPath
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Result<T> = { error: ConvertError; ok: false } | { ok: true; value: T }
|
export type Result<T> = { error: ConvertError; ok: false } | { ok: true; value: T }
|
||||||
|
|
||||||
export function failure<T>(code: ConvertErrorCode, message: string): Result<T> {
|
export function failure<T>(code: ConvertErrorCode, message: string, path: ConvertErrorPath): Result<T> {
|
||||||
return { error: { code, message }, ok: false }
|
return { error: { code, message, path }, ok: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function success<T>(value: T): Result<T> {
|
export function success<T>(value: T): Result<T> {
|
||||||
|
|||||||
Reference in New Issue
Block a user