From 222dcc5aab422268fa95c5aebe5932b90df8de6a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 3 Sep 2026 17:52:31 +0200 Subject: [PATCH] 5b2: name the violation in every error message, and the escape a claimed line takes --- AGENTS.md | 3 + package.json | 2 +- src/adf/document.test.ts | 30 ++++++-- src/adf/document.ts | 37 ++++++++-- src/markdown/directive-syntax.test.ts | 28 +++---- src/markdown/directive-syntax.ts | 14 ++-- src/markdown/emit/adf-to-markdown.test.ts | 58 ++++++++++----- src/markdown/emit/adf-to-markdown.ts | 13 ++-- src/markdown/emit/inline-line.ts | 14 ++-- src/markdown/opaque-carry.ts | 2 +- src/markdown/parse/blocks.test.ts | 2 +- src/markdown/parse/blocks.ts | 14 ++-- src/markdown/parse/directive-attributes.ts | 4 +- src/markdown/parse/directive-nodes.ts | 12 +-- src/markdown/parse/inline-content.ts | 6 +- src/markdown/parse/markdown-to-adf.test.ts | 86 +++++++++++----------- src/markdown/parse/markdown-to-adf.ts | 12 +-- src/markdown/text-directive.ts | 2 +- 18 files changed, 202 insertions(+), 137 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 504f9a1..703e84f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,9 @@ Pre-1.0, normal 0.x rules. The error surface is a contract too. `ConvertError` is `{ code, message, path, position? }` — the code from a closed list a consumer may switch exhaustively, the message free text, the path the node's place from the document root, the position where a parse read the refusal in its input. +A message reads `rule: violation` — a rule alone states a truth the reader must invert before it +reads as a failure — and where the flavour's claim refuses ordinary prose it names the escape that +unclaims the line. 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`. A code names the cause; where one cause recurs across node types or across directions, one code covers them all and diff --git a/package.json b/package.json index 5f62007..75c1579 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "node": ">=18" }, "scripts": { - "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=97.95 --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=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json" }, "devDependencies": { diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 0d1cb91..5da7137 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -1,24 +1,38 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { isAdfDocument } from './document.ts' +import { adfDocumentFault, isAdfDocument } from './document.ts' + +function fault(value: unknown): string { + return adfDocumentFault(value) ?? 'accepted' +} test('accepts an editor-normal document', () => { assert.equal(isAdfDocument({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true) assert.equal(isAdfDocument({ type: 'doc', version: 1 }), true) + assert.equal(fault({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), 'accepted') }) -test('rejects anything that is not a doc node', () => { +test('names the check anything that is not a doc node failed', () => { assert.equal(isAdfDocument(null), false) - assert.equal(isAdfDocument([]), false) - assert.equal(isAdfDocument('doc'), false) - assert.equal(isAdfDocument({ type: 'paragraph', version: 1 }), false) - assert.equal(isAdfDocument({ type: 'doc' }), false) - assert.equal(isAdfDocument({ type: 'doc', version: Number.NaN }), false) - assert.equal(isAdfDocument({ extra: 1, type: 'doc', version: 1 }), false) + assert.equal(fault(null), 'an ADF document is an object: found null') + assert.equal(fault(undefined), 'an ADF document is an object: found undefined') + assert.equal(fault([]), 'an ADF document is an object: found an array') + assert.equal(fault('doc'), 'an ADF document is an object: found "doc"') + assert.equal(fault({ fields: { description: { type: 'doc', version: 1 } } }), 'an ADF document holds content, type and version alone: found the key fields') + assert.equal(fault({ extra: 1, type: 'doc', version: 1 }), 'an ADF document holds content, type and version alone: found the key extra') + assert.equal(fault({ version: 1 }), 'an ADF document holds type "doc": found no type field') + assert.equal(fault({ type: 'document', version: 1 }), 'an ADF document holds type "doc": found "document"') + assert.equal(fault({ type: 'paragraph', version: 1 }), 'an ADF document holds type "doc": found "paragraph"') + assert.equal(fault({ type: 1, version: 1 }), 'an ADF document holds type "doc": found 1') + assert.equal(fault({ type: 'doc' }), 'an ADF document holds a version number: found no version field') + assert.equal(fault({ type: 'doc', version: '1' }), 'an ADF document holds a version number: found "1"') + assert.equal(fault({ type: 'doc', version: Number.NaN }), 'an ADF document holds a version number: found NaN') }) test('rejects a node whose shape ProseMirror JSON cannot hold', () => { + assert.equal(fault({ content: {}, type: 'doc', version: 1 }), "an ADF document's content is an array: found an object") + assert.equal(fault({ content: [{ type: 1 }], type: 'doc', version: 1 }), "an ADF document's content holds ADF nodes: one of them is not") assert.equal(isAdfDocument({ content: [{ type: 1 }], type: 'doc', version: 1 }), false) assert.equal(isAdfDocument({ content: [{ text: 1, type: 'text' }], type: 'doc', version: 1 }), false) assert.equal(isAdfDocument({ content: [{ node: 'x', type: 'paragraph' }], type: 'doc', version: 1 }), false) diff --git a/src/adf/document.ts b/src/adf/document.ts index 7545571..e3a76bd 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -25,16 +25,28 @@ const documentKeys = ['content', 'type', 'version'] const markKeys = ['attrs', 'type'] const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type'] +export function adfDocumentFault(value: unknown): string | undefined { + if (!isRecord(value)) return `an ADF document is an object: found ${describe(value)}` + const extra = extraKey(value, documentKeys) + if (extra !== undefined) return `an ADF document holds content, type and version alone: found the key ${extra}` + if (!('type' in value)) return 'an ADF document holds type "doc": found no type field' + if (value['type'] !== 'doc') return `an ADF document holds type "doc": found ${describe(value['type'])}` + if (!('version' in value)) return 'an ADF document holds a version number: found no version field' + const version = value['version'] + if (typeof version !== 'number' || !Number.isFinite(version)) return `an ADF document holds a version number: found ${describe(version)}` + if (!('content' in value)) return undefined + const content = value['content'] + if (!Array.isArray(content)) return `an ADF document's content is an array: found ${describe(content)}` + return isNodeArray(content) ? undefined : "an ADF document's content holds ADF nodes: one of them is not" +} + export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { if ((node.marks ?? []).length > 0 || node.text !== undefined) return false return holdsOnly(node.attrs ?? {}, attributes) } export function isAdfDocument(value: unknown): value is AdfDocument { - if (!isRecord(value) || !holdsOnly(value, documentKeys)) return false - if (value['type'] !== 'doc') return false - if (typeof value['version'] !== 'number' || !Number.isFinite(value['version'])) return false - return !('content' in value) || isNodeArray(value['content']) + return adfDocumentFault(value) === undefined } export function isAdfNode(value: unknown): value is AdfNode { @@ -47,8 +59,7 @@ export function isAdfMark(value: unknown): value is AdfMark { return !('attrs' in value) || isAttributes(value['attrs']) } -function isNodeArray(value: unknown): value is AdfNode[] { - if (!Array.isArray(value)) return false +function isNodeArray(value: readonly unknown[]): boolean { const pending: unknown[] = [...value] while (pending.length > 0) { const node = pending.pop() @@ -78,6 +89,16 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function holdsOnly(value: Record, keys: readonly string[]): boolean { - return Object.keys(value).every((key) => keys.includes(key)) +function describe(value: unknown): string { + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'object' && value !== null) return Array.isArray(value) ? 'an array' : 'an object' + return String(value) +} + +function extraKey(value: Record, keys: readonly string[]): string | undefined { + return Object.keys(value).find((key) => !keys.includes(key)) +} + +function holdsOnly(value: Record, keys: readonly string[]): boolean { + return extraKey(value, keys) === undefined } diff --git a/src/markdown/directive-syntax.test.ts b/src/markdown/directive-syntax.test.ts index d720229..bdf205c 100644 --- a/src/markdown/directive-syntax.test.ts +++ b/src/markdown/directive-syntax.test.ts @@ -70,7 +70,7 @@ test('names the {attrs} keys read out of the alphabetical order canonical form s }) test('spells an empty {attrs} only where the brace itself claims the directive', () => { - const omitted = 'an empty {attrs} is omitted unless the { itself claims the directive' + const omitted = 'an empty {attrs} is omitted unless the { itself claims the directive: this one spells {}' assert.equal(fault('::rule {}'), omitted) assert.equal(fault(':::panel info {}'), omitted) assert.equal(inline(':underline[a]{}'), omitted) @@ -78,25 +78,25 @@ test('spells an empty {attrs} only where the brace itself claims the directive', }) test('names the directive line no spelling reads', () => { - assert.equal(fault('::Panel'), 'a directive name reads [a-z][A-Za-z0-9]*') - assert.equal(fault('::1panel'), 'a directive name reads [a-z][A-Za-z0-9]*') - assert.equal(fault('::panel info'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') - assert.equal(fault('::panel info extra'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') - assert.equal(fault('::panel{}'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') - assert.equal(fault('::panel info{}'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') - assert.equal(fault('::panel {a=1} x'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') + assert.equal(fault('::Panel'), 'a directive name reads [a-z][A-Za-z0-9]*: this one does not; \\::: keeps the line literal text') + assert.equal(fault('::1panel'), 'a directive name reads [a-z][A-Za-z0-9]*: this one does not; \\::: keeps the line literal text') + assert.equal(fault('::panel info'), 'a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; \\::: keeps the line literal text') + assert.equal(fault('::panel info extra'), 'a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; \\::: keeps the line literal text') + assert.equal(fault('::panel{}'), 'a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; \\::: keeps the line literal text') + assert.equal(fault('::panel info{}'), 'a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; \\::: keeps the line literal text') + assert.equal(fault('::panel {a=1} x'), 'a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; \\::: keeps the line literal text') }) test('names the attributes no spelling reads', () => { assert.equal(fault('::panel {a=1'), 'the {attrs} closing brace is missing') assert.equal(fault('::panel {a="x}'), 'the {attrs} quoted value is unclosed') assert.equal(fault('::panel {a="\\uzzzz"}'), 'the {attrs} quoted value is not a JSON string') - assert.equal(fault('::panel {a}'), 'an attribute reads key=value, the value bare or double-quoted') - assert.equal(fault('::panel {a=}'), 'an attribute reads key=value, the value bare or double-quoted') - assert.equal(fault('::panel {=1}'), 'an attribute reads key=value, the value bare or double-quoted') - assert.equal(fault('::panel {a=1 b=2}'), 'an attribute reads key=value, the value bare or double-quoted') - assert.equal(fault('::panel { a=1}'), 'an attribute reads key=value, the value bare or double-quoted') - assert.equal(fault('::panel {a=1 }'), 'an attribute reads key=value, the value bare or double-quoted') + assert.equal(fault('::panel {a}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not') + assert.equal(fault('::panel {a=}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not') + assert.equal(fault('::panel {=1}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not') + assert.equal(fault('::panel {a=1 b=2}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not') + assert.equal(fault('::panel { a=1}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not') + assert.equal(fault('::panel {a=1 }'), 'an attribute reads key=value, the value bare or double-quoted: this one does not') assert.equal(fault('::panel {a=1 a=2}'), 'the attribute key a is spelled twice') }) diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 7005850..273a0c2 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -36,11 +36,13 @@ const quotedEscapes = new RegExp(reservedSource, 'g') const rawReserved = new RegExp(reservedSource) const noAttributes: DirectiveAttributes = new Map() -const emptyFault = 'an empty {attrs} is omitted unless the { itself claims the directive' -const nameFault = 'a directive name reads [a-z][A-Za-z0-9]*' +export const directiveLineEscape = '\\::: keeps the line literal text' + +const emptyFault = 'an empty {attrs} is omitted unless the { itself claims the directive: this one spells {}' +const nameFault = `a directive name reads [a-z][A-Za-z0-9]*: this one does not; ${directiveLineEscape}` const orderFault = 'the {attrs} keys read in alphabetical order' -const pairFault = 'an attribute reads key=value, the value bare or double-quoted' -const shapeFault = 'a directive line reads a name, one bare argument and {attrs}, one space apart' +const pairFault = 'an attribute reads key=value, the value bare or double-quoted: this one does not' +const shapeFault = `a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; ${directiveLineEscape}` export function attributeValue(text: string, kind: AttributeKind): VocabularyValue | undefined { if (kind === 'string') return { kind, value: text } @@ -78,9 +80,9 @@ export function readInlineDirective(text: string, index: number): Read { - if (span.content !== undefined) return { fault: unsupportedNodeShape(`${span.name} takes no content`) } + if (span.content !== undefined) return { fault: unsupportedNodeShape(`${span.name} takes no content: this one holds some`) } const spelled = span.attributes.get(key) - if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupportedNodeShape(`${span.name} holds one ${key} attribute alone`) } + if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupportedNodeShape(`${span.name} holds one ${key} attribute alone: this one does not`) } const spelling = spellStringAttribute(spelled.decoded) if (spelling !== spelled.spelling) return { fault: unsupportedNodeShape(`${span.name} spells its ${key} attribute as ${key}=${spelling}`) } return { value: spelled.decoded } diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index adcba84..9429bba 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -38,8 +38,8 @@ test('names the node a refusal came from, and no source the emitter never read', assert.equal(position(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined) }) -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 value that is not an ADF document, naming the check it failed', () => { + assert.equal(markdown(adfToMarkdown({ type: 'doc', version: Number.NaN })), 'not-an-adf-document: an ADF document holds a version number: found NaN') }) test('refuses a document version the markdown cannot carry', () => { @@ -186,8 +186,14 @@ test('refuses a carried node nested deeper than the levels its position leaves', }) 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') + assert.equal( + markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), + 'unsupported-node-shape: a codeBlock holds plain text nodes only: this paragraph node is not one', + ) + assert.equal( + markdown(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))), + 'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one', + ) }) test('spells a list its own content shape cannot hold as a directive', () => { @@ -320,7 +326,10 @@ test('refuses marks and attributes nested deeper than the emitter carries', () = assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth') 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') + assert.equal( + markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), + "not-an-adf-document: an ADF document's content holds ADF nodes: one of them is not", + ) }) test('escapes a literal delimiter that would merge with an emitted one', () => { @@ -383,18 +392,26 @@ test('spells a list item whose marker completes a thematic break as a directive' }) test('refuses the characters CommonMark rewrites', () => { - assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' }))), 'unspellable-whitespace') + assert.equal( + markdown(adfToMarkdown(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' }))), + 'unspellable-whitespace: a codeBlock holds no carriage return CommonMark keeps: this text holds one', + ) 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') + assert.equal( + markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), + 'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one', + ) }) 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') + const empty = 'unsupported-node-shape: a text node holds text: this one has none' + const holding = 'unsupported-node-shape: a text node holds no content: this one holds some' + assert.equal(markdown(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), empty) + assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))), empty) + assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, text: 'x', type: 'text' })))), holding) + assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))), holding) }) test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => { @@ -478,8 +495,8 @@ test('carries a block node mark in the reserved attribute', () => { }) 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') + assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'media' }))), 'unsupported-node-shape: a media holds no content: this one holds some') + assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'panel' }))), 'unsupported-node-shape: a panel carries no text: this one holds text') }) test('separates blocks in a container body by a blank line only where the fence is not separation already', () => { @@ -578,13 +595,14 @@ test('carries an inline node attribute no section spells', () => { }) 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') + const refused = (node: AdfNode): string => markdown(adfToMarkdown(document(paragraph(node, { text: 'y', type: 'text' })))) + const neither = (type: string, held: string): string => `unsupported-node-shape: a ${type} node holds neither content nor text: this one holds ${held}` + assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'status' }), neither('status', 'content')) + assert.equal(refused({ text: 'x', type: 'status' }), neither('status', 'text')) + assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), neither('hardBreak', 'content')) + assert.equal(refused({ text: 'x', type: 'hardBreak' }), neither('hardBreak', 'text')) + assert.equal(refused({ attrs: { text: 'a\nb' }, type: 'status' }), 'unspellable-whitespace: the status content slot holds a newline no inline directive spans') + assert.equal(refused({ attrs: { text: 'a\u0000b' }, type: 'status' }), 'unspellable-character: a status content slot holds a null character CommonMark replaces') }) test('spells the directive marks around the longest run they cover', () => { diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index d7cfef3..fee61a6 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -1,8 +1,8 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { BlockDirective } from '../../adf/block-directives.ts' +import { adfDocumentFault, carriesOnly } from '../../adf/document.ts' import { blockDirective } from '../../adf/block-directives.ts' import { carriedBlock } 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' @@ -22,7 +22,8 @@ type PlacedBlock = EmittedBlock & { node: AdfNode; path: ConvertErrorPath } const largestListMarker = 999999999 export function adfToMarkdown(document: AdfDocument): Result { - if (!isAdfDocument(document)) return failure('not-an-adf-document', 'the value is not an ADF document', []) + const fault = adfDocumentFault(document) + if (fault !== undefined) return failure('not-an-adf-document', fault, []) 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 @@ -111,9 +112,9 @@ function commonMarkText(text: string): EmittedBlock { } function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { - if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path) + if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path) const content = node.content ?? [] - if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path) + if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path) if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth) const header = spellDirectiveHeader(node, directive) if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth)) @@ -176,9 +177,9 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result { (child.marks ?? []).length > 0 || Object.keys(child.attrs ?? {}).length > 0 ) { - return failure('unsupported-node-shape', 'a codeBlock holds plain text nodes only', childPath) + return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath) } - if (/\r/.test(child.text)) return failure('unspellable-whitespace', 'a codeBlock holds no carriage return CommonMark keeps', childPath) + if (/\r/.test(child.text)) return failure('unspellable-whitespace', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath) if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath) text += child.text } diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 85b3d43..df0b0d1 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -127,8 +127,10 @@ function syntax(text: string): InlineSegment { } function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result { - if ((node.content ?? []).length > 0 || node.text !== undefined) { - return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text`, path) + const holdsContent = (node.content ?? []).length > 0 + if (holdsContent || node.text !== undefined) { + const held = holdsContent ? 'content' : 'text' + return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text: this one holds ${held}`, path) } return success(null) } @@ -216,8 +218,8 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result { 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 (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) + if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', 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' @@ -259,8 +261,8 @@ function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange 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) + if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) + if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) text += node.text } if (/[\n\r]/.test(text)) return success({ carry: range }) diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 9d5db85..61b1538 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -56,7 +56,7 @@ function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): R const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted' return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}`) } } - if (!isAdfNode(value)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON") } + if (!isAdfNode(value)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON: this JSON is no ADF node") } return { value } } diff --git a/src/markdown/parse/blocks.test.ts b/src/markdown/parse/blocks.test.ts index 08cee34..cd8ada1 100644 --- a/src/markdown/parse/blocks.test.ts +++ b/src/markdown/parse/blocks.test.ts @@ -108,5 +108,5 @@ test('names the directive fence a container does not sit longer than', () => { assert.deepEqual(faults('::::panel info\n:::expand\nPart.\n:::::\n::::\n'), []) assert.deepEqual(faults(':::panel info\nPart.\n'), ['a container fenced with 3 colons is unclosed']) assert.deepEqual(faults('- :::panel info\n\nPart.\n'), ['a container fenced with 3 colons is unclosed']) - assert.deepEqual(faults('Part.\n\n:::\n'), ['a closing fence closes no open container']) + assert.deepEqual(faults('Part.\n\n:::\n'), ['a closing fence closes no open container; \\::: keeps the line literal text']) }) diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index c08cf76..051c0a4 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -15,8 +15,8 @@ import { replaceNullCharacters, setextHeadingLevel, } from '../commonmark-grammar.ts' +import { directiveLineEscape, malformedDirective, readDirectiveLine } from '../directive-syntax.ts' import { isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts' -import { malformedDirective, readDirectiveLine } from '../directive-syntax.ts' import { readLinkDefinitions } from './link-reference-definitions.ts' export type Block = { position: SourcePosition } & ( @@ -244,7 +244,7 @@ function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void { function closeDirective(walk: Walk, colons: number, enclosing: { container: OpenDirective; depth: number } | undefined): void { if (enclosing === undefined) { - pushFault(walk, malformedDirective('a closing fence closes no open container')) + pushFault(walk, malformedDirective(`a closing fence closes no open container; ${directiveLineEscape}`)) return } if (colons < enclosing.container.colons) { @@ -403,16 +403,20 @@ function closeLeaf(walk: Walk): void { function pipeTableBlock(rows: readonly [string[], ...string[][]], position: SourcePosition): Block { const [header, delimiter, ...body] = rows if (delimiter !== undefined && delimiter.some(isPipeAlignment)) { - return faultedBlock('a pipe table carries no column alignment ADF could hold', position) + return faultedBlock('a pipe table carries no column alignment ADF could hold: this delimiter row holds an alignment colon', position) } if (delimiter === undefined || !delimiter.every(isPipeDelimiter)) { - return faultedBlock('a pipe table underlines its header with a row of `-` runs', position) + return faultedBlock('a pipe table underlines its header with a row of `-` runs: this one has none; \\| keeps the line literal text', position) } const ragged = [delimiter, ...body].find((row) => row.length !== header.length) - if (ragged !== undefined) return faultedBlock(`a pipe table row holds ${ragged.length} cells where its header holds ${header.length}`, position) + if (ragged !== undefined) return faultedBlock(`a pipe table row holds ${cellCount(ragged.length)} where its header holds ${cellCount(header.length)}`, position) return { kind: 'table', position, rows: [header, ...body] } } +function cellCount(count: number): string { + return `${count} cell${count === 1 ? '' : 's'}` +} + function faultedBlock(message: string, position: SourcePosition): Block { return { fault: malformedPipeTable(message), kind: 'fault', position } } diff --git a/src/markdown/parse/directive-attributes.ts b/src/markdown/parse/directive-attributes.ts index cfcbdc4..d3d8297 100644 --- a/src/markdown/parse/directive-attributes.ts +++ b/src/markdown/parse/directive-attributes.ts @@ -17,10 +17,10 @@ export function readVocabulary( for (const [key, spelled] of attributes) { if (key === elsewhere?.key) { const place = elsewhere.slot === 'argument' ? 'as the directive argument' : 'in the content slot' - return failure('unsupported-node-shape', `${type} spells its ${key} attribute ${place}`, path) + return failure('unsupported-node-shape', `${type} spells its ${key} attribute ${place}, never in {attrs}`, path) } const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined - if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute`, path) + if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute: this one spells it`, path) const read = attributeValue(spelled.decoded, kind) if (read === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path) const spelling = spellAttributeValue(read) diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index eb51512..33920c7 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -35,7 +35,7 @@ export function readBlockDirectiveNode( const attrs = readVocabulary(name, rest, directive.attributes, elsewhere, path) if (!attrs.ok) return attrs if (argument !== undefined) { - if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument`, path) + if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument: this one spells one`, path) attrs.value[argumentKey] = argument } const spelled = attributes.get(marksAttribute) @@ -53,13 +53,13 @@ export function readInlineDirectiveNode( const directive = inlineDirective(name) if (directive === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name), path) const slot = directive.textAttribute - if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content`, path) + if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path) const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' } const attrs = readVocabulary(name, attributes, directive.attributes, elsewhere, path) if (!attrs.ok) return attrs if (slot !== undefined && content !== undefined) { const text = slotText(content) - if (text === undefined) return failure('unsupported-node-shape', `the ${name} content slot holds one unmarked text node`, path) + if (text === undefined) return failure('unsupported-node-shape', `the ${name} content slot holds one unmarked text node: this one holds something else`, path) const spans = slotLineEndingFault(name, text) if (spans !== undefined) return faulted(spans, path) attrs.value[slot] = text @@ -72,14 +72,14 @@ function inlineSpellingFault(name: string): ConvertFault | undefined { const mark = inlineMarkSpellingFault(name) if (mark !== undefined) return mark if (inlineDirective(name) === undefined && name !== textDirectiveName) return undefined - return { code: 'unsupported-node-shape', message: `${name} takes the inline form, :${name}{…}` } + return { code: 'unsupported-node-shape', message: `${name} takes the inline form, :${name}{…}, never the block form` } } function blockSpellingFault(name: string): ConvertFault | undefined { const directive = blockDirective(name) if (directive === undefined) return undefined const form = directive.contentModel === 'none' ? `::${name}` : `:::${name}` - return { code: 'unsupported-node-shape', message: `${name} takes the block form, ${form}` } + return { code: 'unsupported-node-shape', message: `${name} takes the block form, ${form}, never the inline form` } } // spec/flavour.md, Inline nodes: the slot is plain text, its adjacent nodes already merged. @@ -94,7 +94,7 @@ function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath const read = attributeValue(spelled.decoded, 'json') const marks = read === undefined || spellAttributeValue(read) !== spelled.spelling ? undefined : readMarkValues(read.value) if (marks === undefined) { - return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON`, path) + return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON: this one is not`, path) } return success(marks) } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 6df91e7..286b6ec 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -37,7 +37,7 @@ type Scan = { definitions: LinkDefinitions; path: ConvertErrorPath; pending: str type SlotContent = { carry: boolean; nodes: AdfNode[] } const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included' -const imageAlone = 'an image fits only as a paragraph of its own' +const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content' export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result { return parseInline(source, definitions, path, true) @@ -133,7 +133,7 @@ function readAngle(scan: Scan, index: number): Result { return success(index + autolink.length) } const construct = inlineHtmlConstruct(scan.source, index) - if (construct !== undefined) return failure('unmappable-html', `no ADF node carries ${construct}`, scan.path) + if (construct !== undefined) return failure('unmappable-html', `no raw HTML converts at this version: ${construct}`, scan.path) scan.pending += '<' return success(index + 1) } @@ -179,7 +179,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan): Result { if (mark !== undefined) { if (!mark.ok) return mark if (slot.value === undefined || slot.value.nodes.length === 0) { - return failure('unsupported-node-shape', `the ${span.name} mark wraps the [content] it marks`, scan.path) + return failure('unsupported-node-shape', `the ${span.name} mark wraps the [content] it marks: this one wraps none`, scan.path) } if (slot.value.carry) return failure('unsupported-node-shape', carriedInMark, scan.path) return success({ kind: 'nodes', nodes: applyMark(slot.value.nodes, mark.value) }) diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index db7c926..96bc37b 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -155,7 +155,7 @@ test('names the slot a codeBlock spells its language outside of', () => { assert.equal(content(markdownToAdf(':::codeBlock {language=rust}\n```sql\nx\n```\n:::\n')), slot) assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\n```adf\nx\n```\n:::\n')), slot) assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\n```a\\b\nx\n```\n:::\n')), slot) - assert.equal(content(markdownToAdf('::codeBlock {wrap=true}\n')), 'unsupported-node-shape: codeBlock spells its body in the container form, :::') + assert.equal(content(markdownToAdf('::codeBlock {wrap=true}\n')), 'unsupported-node-shape: codeBlock spells its body in the container form, :::, never the leaf form') }) test('reads a pipe table into the header row and the body rows under it', () => { @@ -182,11 +182,11 @@ test('claims the line a pipe opens and gives the rest back to the block walk', ( }) test('names the pipe table a claimed line does not spell', () => { - assert.equal(content(markdownToAdf('| a | b |\n')), 'malformed-pipe-table: a pipe table underlines its header with a row of `-` runs') - assert.equal(content(markdownToAdf('| a |\n| x |\n')), 'malformed-pipe-table: a pipe table underlines its header with a row of `-` runs') - assert.equal(content(markdownToAdf('| a | b |\n| :--- | ---: |\n')), 'malformed-pipe-table: a pipe table carries no column alignment ADF could hold') - assert.equal(content(markdownToAdf('| a | b |\n| --- |\n')), 'malformed-pipe-table: a pipe table row holds 1 cells where its header holds 2') - assert.equal(content(markdownToAdf('| a |\n| --- |\n| b | c |\n')), 'malformed-pipe-table: a pipe table row holds 2 cells where its header holds 1') + assert.equal(content(markdownToAdf('| a | b |\n')), 'malformed-pipe-table: a pipe table underlines its header with a row of `-` runs: this one has none; \\| keeps the line literal text') + assert.equal(content(markdownToAdf('| a |\n| x |\n')), 'malformed-pipe-table: a pipe table underlines its header with a row of `-` runs: this one has none; \\| keeps the line literal text') + assert.equal(content(markdownToAdf('| a | b |\n| :--- | ---: |\n')), 'malformed-pipe-table: a pipe table carries no column alignment ADF could hold: this delimiter row holds an alignment colon') + assert.equal(content(markdownToAdf('| a | b |\n| --- |\n')), 'malformed-pipe-table: a pipe table row holds 1 cell where its header holds 2 cells') + assert.equal(content(markdownToAdf('| a |\n| --- |\n| b | c |\n')), 'malformed-pipe-table: a pipe table row holds 2 cells where its header holds 1 cell') assert.deepEqual(path(markdownToAdf('Part.\n\n| a |\n')), ['content', 1]) }) @@ -258,10 +258,10 @@ test('names the directive name no node reads back to', () => { test('names the position a directive name the other one spells belongs to', () => { assert.equal(content(markdownToAdf(':::em\na\n:::\n')), 'unsupported-node-shape: em is spelled _x_, never as a block directive') assert.equal(content(markdownToAdf('::underline\n')), 'unsupported-node-shape: underline is spelled :underline[…], never as a block directive') - assert.equal(content(markdownToAdf('::text {text=" "}\n')), 'unsupported-node-shape: text takes the inline form, :text{…}') - assert.equal(content(markdownToAdf('::date {timestamp=1}\n')), 'unsupported-node-shape: date takes the inline form, :date{…}') - assert.equal(content(markdownToAdf(':paragraph[a]\n')), 'unsupported-node-shape: paragraph takes the block form, :::paragraph') - assert.equal(content(markdownToAdf(':rule[a]\n')), 'unsupported-node-shape: rule takes the block form, ::rule') + assert.equal(content(markdownToAdf('::text {text=" "}\n')), 'unsupported-node-shape: text takes the inline form, :text{…}, never the block form') + assert.equal(content(markdownToAdf('::date {timestamp=1}\n')), 'unsupported-node-shape: date takes the inline form, :date{…}, never the block form') + assert.equal(content(markdownToAdf(':paragraph[a]\n')), 'unsupported-node-shape: paragraph takes the block form, :::paragraph, never the inline form') + assert.equal(content(markdownToAdf(':rule[a]\n')), 'unsupported-node-shape: rule takes the block form, ::rule, never the inline form') assert.equal(code(markdownToAdf(':::widget\na\n:::\n')), 'unknown-directive-name') assert.equal(code(markdownToAdf(':widget[a]\n')), 'unknown-directive-name') }) @@ -303,16 +303,16 @@ test('names the canonical spelling a carried JSON reads alone', () => { }) test('names the node JSON an opaque carry restores alone', () => { - const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON" + const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node" assert.equal(content(markdownToAdf('```adf\n[]\n```\n')), node) assert.equal(content(markdownToAdf(':adf{json=null}\n')), node) assert.equal(content(markdownToAdf(':adf{json="{\\"kind\\":\\"x\\"}"}\n')), node) }) test('names the shape the inline carry reads alone', () => { - assert.equal(content(markdownToAdf(':adf[x]{json="{}"}\n')), 'unsupported-node-shape: adf takes no content') - assert.equal(content(markdownToAdf(':adf{}\n')), 'unsupported-node-shape: adf holds one json attribute alone') - assert.equal(content(markdownToAdf(':adf{json="{}" localId=x}\n')), 'unsupported-node-shape: adf holds one json attribute alone') + assert.equal(content(markdownToAdf(':adf[x]{json="{}"}\n')), 'unsupported-node-shape: adf takes no content: this one holds some') + assert.equal(content(markdownToAdf(':adf{}\n')), 'unsupported-node-shape: adf holds one json attribute alone: this one does not') + assert.equal(content(markdownToAdf(':adf{json="{}" localId=x}\n')), 'unsupported-node-shape: adf holds one json attribute alone: this one does not') assert.equal(content(markdownToAdf(':adf{json="null"}\n')), 'unsupported-node-shape: adf spells its json attribute as json=null') }) @@ -372,7 +372,7 @@ test('reads the reserved marks key as the node array it spells', () => { }) test('names the marks key no marks array reads back from', () => { - const named = 'unsupported-node-shape: the marks attribute of rule is its marks array in canonical JSON' + const named = 'unsupported-node-shape: the marks attribute of rule is its marks array in canonical JSON: this one is not' assert.equal(content(markdownToAdf('::rule {marks="[]"}\n')), named) assert.equal(content(markdownToAdf('::rule {marks="[1]"}\n')), named) assert.equal(content(markdownToAdf('::rule {marks="{}"}\n')), named) @@ -381,15 +381,15 @@ test('names the marks key no marks array reads back from', () => { }) test('names the attribute a node holds no reading for', () => { - assert.equal(content(markdownToAdf('::rule {bogus=1}\n')), 'unsupported-node-shape: rule holds no bogus attribute') + assert.equal(content(markdownToAdf('::rule {bogus=1}\n')), 'unsupported-node-shape: rule holds no bogus attribute: this one spells it') assert.equal(content(markdownToAdf('::media {width=wide}\n')), 'unsupported-node-shape: the width attribute of media is no number') assert.equal(content(markdownToAdf(':::table {isNumberColumnEnabled=yes}\n:::\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean') assert.equal(content(markdownToAdf('::media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number') assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340,"}\n:::\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json') const deep = `${'['.repeat(largestNesting + 2)}${']'.repeat(largestNesting + 2)}` assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${deep}"}\n:::\n`)), 'unsupported-node-shape: the colwidth attribute of tableCell is no json') - assert.equal(content(markdownToAdf(':::panel info {panelType=note}\nx\n:::\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument') - assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot') + assert.equal(content(markdownToAdf(':::panel info {panelType=note}\nx\n:::\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument, never in {attrs}') + assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}') }) test('names the attribute value spelled outside the canonical form', () => { @@ -399,15 +399,15 @@ test('names the attribute value spelled outside the canonical form', () => { }) test('names the argument and the body a node takes no reading for', () => { - assert.equal(content(markdownToAdf('::rule x\n')), 'unsupported-node-shape: rule takes no argument') - assert.equal(content(markdownToAdf(':::rule\nPart.\n:::\n')), 'unsupported-node-shape: rule holds no content') - assert.equal(content(markdownToAdf('::bulletList\n')), 'unsupported-node-shape: bulletList spells its body in the container form, :::') - assert.equal(content(markdownToAdf(':::paragraph\n:::\n')), 'unsupported-node-shape: an empty paragraph takes the leaf form, ::') - assert.equal(content(markdownToAdf(':::paragraph\nOne.\n\nTwo.\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body') - assert.equal(content(markdownToAdf(':::paragraph\n---\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body') - assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\nx\n:::\n')), 'unsupported-node-shape: codeBlock takes one code block as its body') + assert.equal(content(markdownToAdf('::rule x\n')), 'unsupported-node-shape: rule takes no argument: this one spells one') + assert.equal(content(markdownToAdf(':::rule\nPart.\n:::\n')), 'unsupported-node-shape: rule holds no content: this one holds some') + assert.equal(content(markdownToAdf('::bulletList\n')), 'unsupported-node-shape: bulletList spells its body in the container form, :::, never the leaf form') + assert.equal(content(markdownToAdf(':::paragraph\n:::\n')), 'unsupported-node-shape: an empty paragraph takes the leaf form, ::, never an empty container') + assert.equal(content(markdownToAdf(':::paragraph\nOne.\n\nTwo.\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one') + assert.equal(content(markdownToAdf(':::paragraph\n---\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one') + assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\nx\n:::\n')), 'unsupported-node-shape: codeBlock takes one code block as its body: this body is not one') assert.equal(content(markdownToAdf(':::paragraph\n![a](/u)\n:::\n')), 'unmappable-image: no ADF node carries an image inside a paragraph') - assert.equal(content(markdownToAdf('Part :date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content') + assert.equal(content(markdownToAdf('Part :date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content: this one holds some') }) test('leaves the colon that opens no directive the text it is', () => { @@ -637,14 +637,14 @@ test('decodes the fenced info string the block walk leaves raw', () => { }) test('refuses the raw inline HTML no element mapping carries, naming it', () => { - assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no ADF node carries ') - assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no ADF node carries
') - assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no ADF node carries an HTML comment') - assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no ADF node carries an HTML processing instruction') - assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no ADF node carries an HTML declaration') - assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no ADF node carries a CDATA section') - assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no ADF node carries an HTML comment') - assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no ADF node carries an HTML comment') + assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: ') + assert.equal(content(markdownToAdf('Part
here.\n')), 'unmappable-html: no raw HTML converts at this version:
') + assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML comment') + assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML processing instruction') + assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML declaration') + assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: a CDATA section') + assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML comment') + assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML comment') assert.equal(code(markdownToAdf('A b\n')), 'unmappable-html') assert.equal(code(markdownToAdf('Part.\n\n')), 'unmappable-html') assert.deepEqual(path(markdownToAdf('Part.\n\nA b.\n')), ['content', 1]) @@ -781,7 +781,7 @@ test('leaves the brackets of an empty link text the text they are', () => { test('refuses the image no ADF node carries where it sits', () => { assert.equal(content(markdownToAdf('![a](/u "t")\n')), 'unmappable-image: no media node carries a link title') - assert.equal(content(markdownToAdf('See ![a](/u).\n')), 'unmappable-image: an image fits only as a paragraph of its own') + assert.equal(content(markdownToAdf('See ![a](/u).\n')), 'unmappable-image: an image fits only as a paragraph of its own: this one sits inside other content') assert.equal(code(markdownToAdf('# ![a](/u)\n')), 'unmappable-image') assert.equal(code(markdownToAdf('*![a](/u)*\n')), 'unmappable-image') assert.equal(code(markdownToAdf('[![a](/u)](/v)\n')), 'unmappable-image') @@ -814,10 +814,10 @@ test('reads the content slot as the text attribute the node spells there', () => }) test('names the content slot no one unmarked text node reads back from', () => { - assert.equal(content(markdownToAdf(':status[**A**]{color=yellow}\n')), 'unsupported-node-shape: the status content slot holds one unmarked text node') + assert.equal(content(markdownToAdf(':status[**A**]{color=yellow}\n')), 'unsupported-node-shape: the status content slot holds one unmarked text node: this one holds something else') assert.equal(code(markdownToAdf(':status[a`b`]{color=yellow}\n')), 'unsupported-node-shape') assert.equal(code(markdownToAdf(':status[:date{timestamp=1}]{color=yellow}\n')), 'unsupported-node-shape') - assert.equal(content(markdownToAdf(':status[![a](/u)]{color=yellow}\n')), 'unmappable-image: an image fits only as a paragraph of its own') + assert.equal(content(markdownToAdf(':status[![a](/u)]{color=yellow}\n')), 'unmappable-image: an image fits only as a paragraph of its own: this one sits inside other content') assert.equal(code(markdownToAdf(':status[
]{color=yellow}\n')), 'unmappable-html') assert.equal(code(markdownToAdf(':date[
]{timestamp=1}\n')), 'unmappable-html') assert.equal(code(markdownToAdf(':widget[
]\n')), 'unmappable-html') @@ -825,7 +825,7 @@ test('names the content slot no one unmarked text node reads back from', () => { assert.equal(content(markdownToAdf(':status[:text{text="\\n"}]{color=yellow}\n')), spans) assert.equal(content(markdownToAdf(':status[a b]{color=yellow}\n')), spans) assert.equal(content(markdownToAdf(':status[a b]{color=yellow}\n')), spans) - assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot') + assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}') }) test('reads the whitespace the reserved text directive carries', () => { @@ -836,13 +836,13 @@ test('reads the whitespace the reserved text directive carries', () => { }) test('names the text directive spelling no whitespace run reads back from', () => { - const named = 'unsupported-node-shape: text spells one run of spaces and tabs, or one run of newlines' + const named = 'unsupported-node-shape: text spells one run of spaces and tabs, or one run of newlines: this one spells neither' assert.equal(content(markdownToAdf(':text{text=hi}\n')), named) assert.equal(content(markdownToAdf(':text{text=" \\n"}\n')), named) assert.equal(content(markdownToAdf(':text{text=""}\n')), named) - assert.equal(content(markdownToAdf(':text{}\n')), 'unsupported-node-shape: text holds one text attribute alone') - assert.equal(content(markdownToAdf(':text{localId=a text=" "}\n')), 'unsupported-node-shape: text holds one text attribute alone') - assert.equal(content(markdownToAdf(':text[a]{text=" "}\n')), 'unsupported-node-shape: text takes no content') + assert.equal(content(markdownToAdf(':text{}\n')), 'unsupported-node-shape: text holds one text attribute alone: this one does not') + assert.equal(content(markdownToAdf(':text{localId=a text=" "}\n')), 'unsupported-node-shape: text holds one text attribute alone: this one does not') + assert.equal(content(markdownToAdf(':text[a]{text=" "}\n')), 'unsupported-node-shape: text takes no content: this one holds some') assert.equal(content(markdownToAdf(':text{text="\\u0020"}\n')), 'unsupported-node-shape: text spells its text attribute as text=" "') }) @@ -873,7 +873,7 @@ test('names the mark markdown spells, never a directive', () => { }) test('names the directive mark left without the content it wraps', () => { - const named = 'unsupported-node-shape: the underline mark wraps the [content] it marks' + const named = 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps none' assert.equal(content(markdownToAdf(':underline[]\n')), named) assert.equal(content(markdownToAdf(':underline{}\n')), named) }) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 2fe0bf5..b57c96e 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -46,7 +46,7 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro case 'heading': return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path) case 'html': - return failure('unmappable-html', `no ADF node carries ${block.construct}`, path) + return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path) case 'orderedList': return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, definitions, path, depth) case 'paragraph': @@ -73,9 +73,9 @@ function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, de const { contentModel, node } = read if (blocks === undefined) { if (contentModel === 'none' || contentModel === 'inline') return success(node) - return failure('unsupported-node-shape', `${node.type} spells its body in the container form, :::`, path) + return failure('unsupported-node-shape', `${node.type} spells its body in the container form, :::, never the leaf form`, path) } - if (contentModel === 'none') return failure('unsupported-node-shape', `${node.type} holds no content`, path) + if (contentModel === 'none') return failure('unsupported-node-shape', `${node.type} holds no content: this one holds some`, path) if (contentModel === 'code') return codeDirectiveNode(node, blocks, path) if (contentModel === 'block') return containerNode(node, blocks, definitions, path, depth) return inlineBodyNode(node, blocks, definitions, path) @@ -83,7 +83,7 @@ function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, de function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result { const only = blocks.length === 1 ? blocks[0] : undefined - if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body`, path) + if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body: this body is not one`, path) const attribute = node.attrs?.['language'] const fromFence = only.language !== '' const slot = languageSlot(fromFence ? only.language : attribute) @@ -110,9 +110,9 @@ function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path } function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result { - if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} takes the leaf form, ::`, path) + if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} takes the leaf form, ::, never an empty container`, path) const only = blocks.length === 1 ? blocks[0] : undefined - if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body`, path) + if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path) return positioned(contentNode(node, only.text, definitions, path), only.position) } diff --git a/src/markdown/text-directive.ts b/src/markdown/text-directive.ts index 616b593..4cdea77 100644 --- a/src/markdown/text-directive.ts +++ b/src/markdown/text-directive.ts @@ -14,6 +14,6 @@ export function readTextDirective(span: DirectiveSpan): Read | undefined if (span.name !== name) return undefined const spelled = readSoleStringAttribute(span, name) if (spelled.fault !== undefined) return spelled - if (!whitespaceRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, or one run of newlines`) } + if (!whitespaceRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, or one run of newlines: this one spells neither`) } return spelled }