diff --git a/AGENTS.md b/AGENTS.md index f5aaaf1..c69e30d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. 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 - `package.json` version on `main` is the source of truth. CI on `main`: tests green and version diff --git a/README.md b/README.md index ba7e164..fc93b5d 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ isAdfDocument(v: unknown): v is AdfDocument ``` `Result` 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 diff --git a/package.json b/package.json index ce73436..7e7a571 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "node": ">=24" }, "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" }, "devDependencies": { diff --git a/src/adf-to-markdown.test.ts b/src/adf-to-markdown.test.ts index f37a886..31ff027 100644 --- a/src/adf-to-markdown.test.ts +++ b/src/adf-to-markdown.test.ts @@ -21,6 +21,18 @@ function markdown(result: Result): string { return result.ok ? result.value : `${result.error.code}: ${result.error.message}` } +function path(result: Result): 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', () => { assert.equal(code(adfToMarkdown({ type: 'doc', version: Number.NaN })), 'not-an-adf-document') }) diff --git a/src/adf-to-markdown.ts b/src/adf-to-markdown.ts index e92abf9..03e7aae 100644 --- a/src/adf-to-markdown.ts +++ b/src/adf-to-markdown.ts @@ -1,7 +1,7 @@ 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 { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { isAdfDocument } from './adf-document.ts' import { longestBacktickRun } from './backtick-runs.ts' @@ -9,24 +9,25 @@ const largestListMarker = 999999999 const listTypes = ['bulletList', 'orderedList'] export function adfToMarkdown(document: AdfDocument): Result { - if (!isAdfDocument(document)) return failure('not-an-adf-document', 'the value is not an ADF document') - if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`) - const blocks = emitBlocks(document.content ?? [], false) + if (!isAdfDocument(document)) return failure('not-an-adf-document', 'the value is not an ADF document', []) + if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) + const blocks = emitBlocks(document.content ?? [], false, []) if (!blocks.ok) return blocks return success(blocks.value === '' ? '' : `${blocks.value}\n`) } -function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean): Result { +function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean, path: ConvertErrorPath): Result { let output = '' 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 (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' } - const block = emitBlock(node) + const block = emitBlock(node, nodePath) if (!block.ok) return block output += block.value previous = node @@ -34,23 +35,23 @@ function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean): Result { - if (node.type === 'blockquote') return emitBlockquote(node) - if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node) - if (node.type === 'codeBlock') return emitCodeBlock(node) - if (node.type === 'heading') return emitHeading(node) - if (node.type === 'paragraph') return emitParagraph(node) - if (node.type === 'rule') return emitRule(node) +function emitBlock(node: AdfNode, path: ConvertErrorPath): Result { + if (node.type === 'blockquote') return emitBlockquote(node, path) + if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path) + if (node.type === 'codeBlock') return emitCodeBlock(node, path) + if (node.type === 'heading') return emitHeading(node, path) + if (node.type === 'paragraph') return emitParagraph(node, path) + if (node.type === 'rule') return emitRule(node, path) 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 { - const validation = validateBlockNode(node, []) +function emitBlockquote(node: AdfNode, path: ConvertErrorPath): Result { + const validation = validateBlockNode(node, [], path) if (!validation.ok) return validation - const inner = emitBlocks(node.content ?? [], false) + const inner = emitBlocks(node.content ?? [], false, path) if (!inner.ok) return inner return success( inner.value @@ -60,15 +61,15 @@ function emitBlockquote(node: AdfNode): Result { ) } -function emitCodeBlock(node: AdfNode): Result { - const validation = validateBlockNode(node, ['language']) +function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result { + const validation = validateBlockNode(node, ['language'], path) if (!validation.ok) return validation - const info = spellCodeFenceInfo(node.attrs?.['language']) + const info = spellCodeFenceInfo(node.attrs?.['language'], path) 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) { - 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 } @@ -77,62 +78,65 @@ function emitCodeBlock(node: AdfNode): Result { return success(text === '' ? `${opening}\n${fence}` : `${opening}\n${text}\n${fence}`) } -function spellCodeFenceInfo(language: JsonValue | undefined): Result { +function spellCodeFenceInfo(language: JsonValue | undefined, path: ConvertErrorPath): Result { 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 (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', 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()) { - 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) } -function emitHeading(node: AdfNode): Result { - const validation = validateBlockNode(node, ['level']) +function emitHeading(node: AdfNode, path: ConvertErrorPath): Result { + const validation = validateBlockNode(node, ['level'], path) 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)}`) + return failure('unsupported-heading-level', `no ATX heading spells level ${JSON.stringify(level ?? null)}`, path) } const hashes = '#'.repeat(level) const content = node.content ?? [] if (content.length === 0) return success(hashes) - const line = emitInlineLine(content, 'heading') + const line = emitInlineLine(content, 'heading', path) if (!line.ok) return line return success(`${hashes} ${line.value}`) } -function emitList(node: AdfNode): Result { +function emitList(node: AdfNode, path: ConvertErrorPath): Result { const ordered = node.type === 'orderedList' - const validation = validateBlockNode(node, ordered ? ['order'] : []) + const validation = validateBlockNode(node, ordered ? ['order'] : [], path) 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`) + 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 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) { - 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) { - 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[] = [] 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}. ` : '- ') + const itemPath = [...path, 'content', 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 lines.push(emitted.value) } return success(lines.join('\n')) } -function emitListItem(item: AdfNode, marker: string): Result { - const validation = validateBlockNode(item, []) +function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath): Result { + const validation = validateBlockNode(item, [], path) 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.value === '') return success(marker.trimEnd()) const indent = ' '.repeat(marker.length) @@ -144,29 +148,29 @@ function emitListItem(item: AdfNode, marker: string): Result { ) } -function emitParagraph(node: AdfNode): Result { - const validation = validateBlockNode(node, []) +function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result { + const validation = validateBlockNode(node, [], path) if (!validation.ok) return validation const content = node.content ?? [] if (content.length === 0) return success('::paragraph') - return emitInlineLine(content, 'paragraph') + return emitInlineLine(content, 'paragraph', path) } -function emitRule(node: AdfNode): Result { - const validation = validateBlockNode(node, []) +function emitRule(node: AdfNode, path: ConvertErrorPath): Result { + const validation = validateBlockNode(node, [], path) 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('---') } -function validateBlockNode(node: AdfNode, spelled: readonly string[]): Result { +function validateBlockNode(node: AdfNode, spelled: readonly string[], path: ConvertErrorPath): Result { 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)) 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) } diff --git a/src/commonmark-grammar.ts b/src/commonmark-grammar.ts index db6dbcf..7578eb4 100644 --- a/src/commonmark-grammar.ts +++ b/src/commonmark-grammar.ts @@ -1,8 +1,13 @@ 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 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 firstCharacterOpeners = [/^#{1,6}(?:[ \t]|$)/, /^>/, /^[*+-](?:[ \t]|$)/, /^`{3,}/, /^~{3,}/, /^:{2,}/, /^\|/] 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 } +export function holdsControlCharacter(text: string): boolean { + return controlCharacter.test(text) +} + export function holdsEntityReference(text: string): boolean { 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 { return anchoredEntityReference.test(text) } diff --git a/src/markdown-escaping.ts b/src/markdown-escaping.ts index 138c142..fad5060 100644 --- a/src/markdown-escaping.ts +++ b/src/markdown-escaping.ts @@ -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 = { kind: 'emphasis-close' | 'emphasis-open' | 'link-text' | 'literal' | 'syntax' @@ -8,7 +8,7 @@ export type InlineSegment = { export type LineContainer = 'heading' | 'paragraph' 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 linkOpener = /\](?=[([:])/ 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 (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1)) 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 linkOpener.test(rest) if (character === '`') return opensCodeSpan(scan, index, escaped) diff --git a/src/markdown-inline.ts b/src/markdown-inline.ts index 777137b..37b77af 100644 --- a/src/markdown-inline.ts +++ b/src/markdown-inline.ts @@ -1,7 +1,7 @@ import type { AdfMark, AdfNode } from './adf-document.ts' import { assembleInlineLine, type InlineSegment, type LineContainer } from './markdown-escaping.ts' -import { claimsLine, holdsEntityReference } from './commonmark-grammar.ts' -import { failure, success, type Result } from './result.ts' +import { claimsLine, holdsControlCharacter, holdsEntityReference, isAutolink } from './commonmark-grammar.ts' +import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { longestBacktickRun } from './backtick-runs.ts' import { serializeCanonicalJson } from './canonical-json.ts' @@ -9,97 +9,103 @@ type InlineContext = { atBlockEnd: boolean container: LineContainer 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'] -export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer): Result { - const segments = emitRun(nodes, 0, { atBlockEnd: true, container, inLinkText: false }) +export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { + const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, container, inLinkText: false, path }) if (!segments.ok) return segments const line = assembleInlineLine(segments.value, container) for (const [index, single] of line.split('\n').entries()) { 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')) { - 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) } -function emitRun(nodes: readonly AdfNode[], depth: number, context: InlineContext): Result { - const runs = inlineRuns(nodes, depth) +function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result { + const runs = inlineRuns(nodes, depth, firstIndex) const segments: InlineSegment[] = [] - for (const [index, run] of runs.entries()) { - const runContext = { ...context, atBlockEnd: context.atBlockEnd && index === runs.length - 1 } - const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext) : emitMarkedRun(run.nodes, run.mark, depth, runContext) + 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 segments.push(...emitted.value) } return success(segments) } -function inlineRuns(nodes: readonly AdfNode[], depth: number): InlineRun[] { +function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number): 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] if (mark === undefined) { - runs.push({ kind: 'plain', node }) + 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({ kind: 'marked', mark, nodes: [node] }) + else runs.push({ index, kind: 'marked', mark, nodes: [node] }) } return runs } -function emitLeaf(node: AdfNode, context: InlineContext): Result { +function nodePath(context: InlineContext, index: number): ConvertErrorPath { + return [...context.path, 'content', index] +} + +function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result { + const path = nodePath(context, index) 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] 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 (context.container === 'heading' || context.atBlockEnd) return success([{ kind: 'syntax', text: ':hardBreak{}' }]) return success([{ kind: 'syntax', text: '\\\n' }]) } - if (typeof node.text !== 'string') return failure('unsupported-node-shape', 'a text node carries no text') - if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node carries content') - if (/[\n\r]/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a newline CommonMark cannot spell') + 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', path) + 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 }]) } -function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, context: InlineContext): Result { - if (mark.type === 'code') return emitCodeSpan(nodes, depth) - if (mark.type === 'link') return emitLink(nodes, mark, depth, context) +function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result { + if (mark.type === 'code') return emitCodeSpan(nodes, depth, nodePath(context, index)) + 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 - if (spelling === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`) - if (Object.keys(mark.attrs ?? {}).length > 0) return failure('unspellable-mark', `the ${mark.type} spelling holds no attributes`) - const inner = emitRun(nodes, depth + 1, context) + 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`, path) + const inner = emitRun(nodes, depth + 1, index, context) if (!inner.ok) return inner 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') { 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 }]) } -function emitCodeSpan(nodes: readonly AdfNode[], depth: number): Result { +function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result { const node = nodes[0] 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 (/[\n\r]/.test(node.text)) return failure('unspellable-mark', 'a code span holds no newline') + 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', path) const fence = '`'.repeat(longestBacktickRun(node.text) + 1) const padded = needsPadding(node.text) ? ` ${node.text} ` : node.text return success([{ kind: 'syntax', text: `${fence}${padded}${fence}` }]) @@ -110,45 +116,48 @@ function needsPadding(text: string): boolean { return text.startsWith(' ') && text.endsWith(' ') && text.trim() !== '' } -function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, context: InlineContext): Result { +function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result { + const path = nodePath(context, index) 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 title = mark.attrs?.['title'] - if (typeof href !== 'string') return failure('unsupported-node-shape', 'a link mark carries no href') - if (title !== undefined && typeof title !== 'string') return failure('unsupported-node-shape', 'a link title is no string') + 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', path) 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 && autolink.test(href)) return success([{ kind: 'syntax', text: `<${href}>` }]) - const destination = spellDestination(href) + if (bare && title === undefined && isAutolink(href)) return success([{ kind: 'syntax', text: `<${href}>` }]) + const destination = spellDestination(href, path) 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 - const inner = emitRun(nodes, depth + 1, { ...context, inLinkText: true }) + const inner = emitRun(nodes, depth + 1, index, { ...context, inLinkText: true }) if (!inner.ok) return inner return success([{ kind: 'syntax', text: '[' }, ...inner.value, { kind: 'syntax', text: `](${destination.value}${spelledTitle.value})` }]) } -function spellDestination(href: string): Result { - if (controlCharacter.test(href)) return failure('unspellable-link-destination', 'a link destination holds a control character') - if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination') +function spellDestination(href: string, path: ConvertErrorPath): Result { + 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', path) 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 (/[<>]/.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}>`) } - if (href.startsWith('<')) return failure('unspellable-link-destination', 'a bare link destination cannot begin with an angle bracket') - if (!balanced(href)) return failure('unspellable-link-destination', 'no canonical escape spells an unbalanced parenthesis in a link destination') + 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', path) return success(href) } -function spellTitle(title: string): Result { - if (/["\n\r\\]/.test(title)) return failure('unspellable-link-title', 'no canonical escape spells a quote, backslash or newline in a link title') - if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title shaped like an entity reference decodes on the way back') +function spellTitle(title: string, path: ConvertErrorPath): Result { + if (/["\n\r\\]/.test(title)) { + 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}"`) } diff --git a/src/result.ts b/src/result.ts index 576c045..c427d73 100644 --- a/src/result.ts +++ b/src/result.ts @@ -18,15 +18,18 @@ export type ConvertErrorCode = | 'unsupported-node-shape' | 'unsupported-node-type' +export type ConvertErrorPath = readonly (number | string)[] + export type ConvertError = { code: ConvertErrorCode message: string + path: ConvertErrorPath } export type Result = { error: ConvertError; ok: false } | { ok: true; value: T } -export function failure(code: ConvertErrorCode, message: string): Result { - return { error: { code, message }, ok: false } +export function failure(code: ConvertErrorCode, message: string, path: ConvertErrorPath): Result { + return { error: { code, message, path }, ok: false } } export function success(value: T): Result {