From da561bcf70aa836729e35e94bafdd3eaa24fd25d Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 10 Nov 2026 22:51:37 +0100 Subject: [PATCH] Assert the facts a refusal's message must carry, never its whole sentence --- docs/reading-cost.md | 17 + src/adf/document.test.ts | 173 +++---- src/html/parse/html-to-adf.test.ts | 110 ++-- src/markdown/parse/markdown-to-adf.test.ts | 569 ++++++++++----------- todo.md | 12 - 5 files changed, 422 insertions(+), 459 deletions(-) diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 0d80800..0355dd4 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -1014,3 +1014,20 @@ Read most (opens and searches): - `CHANGELOG.md` 2 - `src/markdown/emit/escaping.test.ts` 2 - `src/markdown/pipe-table-syntax.ts` 2 + +## Reading cost, chunk 15a08a64ac67, finished 2026-11-10T21:40:51Z + +80495 new input tokens before the first commit. + +Read most (opens and searches): + +- `todo.md` 6 +- `ci.sh` 5 +- `src/adf/document.test.ts` 5 +- `src/adf/document.ts` 5 +- `docker-runner.sh` 3 +- `CHANGELOG.md` 2 +- `README.md` 2 +- `src/result.ts` 2 +- `MIGRATION.md` 1 +- `src/adf/plain-copy.ts` 1 diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index db5ed2b..66bc1ec 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -3,34 +3,28 @@ import test from 'node:test' import vm from 'node:vm' import type { AdfDocument, AdfNode } from './document.ts' +import type { ConvertErrorCode, Result } from '../result.ts' import type { JsonValue } from '../json-value.ts' import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown } from '../index.ts' import { readAdfDocument, writableContent } from './document.ts' import { largestNesting } from '../nesting.ts' -function written(value: unknown): { code: string; message: string } { - const read = readAdfDocument(value) - const checked = read.ok ? writableContent(read.value) : read - return checked.ok ? { code: 'accepted', message: 'accepted' } : checked.error +// A message is free text: a refusal pins its code and only the facts the message must name. +function refused(result: Result, errorCode: ConvertErrorCode, ...facts: string[]): void { + assert.equal(result.ok ? 'accepted' : result.error.code, errorCode) + const message = result.ok ? '' : result.error.message + for (const fact of facts) assert.ok(message.includes(fact), `${JSON.stringify(message)} names ${fact}`) } -function fault(value: unknown): string { - return written(value).message +function written(value: unknown): Result { + const read = readAdfDocument(value) + return read.ok ? writableContent(read.value) : read } function readable(value: unknown): boolean { return readAdfDocument(value).ok } -function readFault(value: unknown): string { - const result = readAdfDocument(value) - return result.ok ? 'accepted' : `${result.error.code}: ${result.error.message}` -} - -function faultCode(value: unknown): string { - return written(value).code -} - function nested(levels: number): JsonValue { let value: JsonValue = 1 for (let level = 0; level < levels; level += 1) value = [value] @@ -44,26 +38,26 @@ function withAttribute(value: JsonValue): unknown { test('accepts an editor-normal document', () => { assert.equal(readable({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true) assert.equal(readable({ type: 'doc', version: 1 }), true) - assert.equal(fault({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), 'accepted') + assert.equal(written({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }).ok, true) }) test('names the check anything that is not a doc node failed', () => { assert.equal(readable(null), 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('x'.repeat(200000)), `an ADF document is an object: found "${'x'.repeat(40)}…"`) - assert.equal(fault(function named(first: number, second: number) { return first + second }), 'an ADF document is an object: found a function') - 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') + refused(written(null), 'not-an-adf-document', 'an object', 'found null') + refused(written(undefined), 'not-an-adf-document', 'an object', 'found undefined') + refused(written([]), 'not-an-adf-document', 'an object', 'found an array') + refused(written('doc'), 'not-an-adf-document', 'an object', 'found "doc"') + refused(written('x'.repeat(200000)), 'not-an-adf-document', 'an object', `found "${'x'.repeat(40)}…"`) + refused(written(function named(first: number, second: number) { return first + second }), 'not-an-adf-document', 'an object', 'found a function') + refused(written({ fields: { description: { type: 'doc', version: 1 } } }), 'not-an-adf-document', 'content, type and version', 'the key fields') + refused(written({ extra: 1, type: 'doc', version: 1 }), 'not-an-adf-document', 'content, type and version', 'the key extra') + refused(written({ version: 1 }), 'not-an-adf-document', 'type "doc"', 'no type field') + refused(written({ type: 'document', version: 1 }), 'not-an-adf-document', 'type "doc"', 'found "document"') + refused(written({ type: 'paragraph', version: 1 }), 'not-an-adf-document', 'type "doc"', 'found "paragraph"') + refused(written({ type: 1, version: 1 }), 'not-an-adf-document', 'type "doc"', 'found 1') + refused(written({ type: 'doc' }), 'not-an-adf-document', 'version number', 'no version field') + refused(written({ type: 'doc', version: '1' }), 'not-an-adf-document', 'version number', 'found "1"') + refused(written({ type: 'doc', version: Number.NaN }), 'not-an-adf-document', 'version number', 'found NaN') }) function refusedNode(content: unknown[]): { message: string; path: unknown } { @@ -99,7 +93,7 @@ test('names the first unreadable node in document order', () => { }) 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") + refused(written({ content: {}, type: 'doc', version: 1 }), 'not-an-adf-document', 'content is an array', 'found an object') assert.equal(readable({ content: [{ type: 1 }], type: 'doc', version: 1 }), false) assert.equal(readable({ content: [{ text: 1, type: 'text' }], type: 'doc', version: 1 }), false) assert.equal(readable({ content: [{ node: 'x', type: 'paragraph' }], type: 'doc', version: 1 }), false) @@ -110,14 +104,13 @@ test('rejects a node whose shape ProseMirror JSON cannot hold', () => { }) test('names the attribute nesting past the levels the parser reads one at, and still calls the value a document', () => { - const deeper = (key: string, type: string): string => `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` - assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted') - assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph')) - assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth') + const deeper = (key: string, type: string): string[] => [`the ${key} attribute of ${type}`, `${largestNesting} levels`] + assert.equal(written(withAttribute(nested(largestNesting))).ok, true) + refused(written(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth', ...deeper('a', 'paragraph')) assert.equal(readable(withAttribute(nested(largestNesting + 1))), true) const marked = (levels: number): unknown => ({ content: [{ marks: [{ attrs: { a: nested(levels) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }) - assert.equal(fault(marked(largestNesting)), 'accepted') - assert.equal(fault(marked(largestNesting + 1)), deeper('a', 'link')) + assert.equal(written(marked(largestNesting)).ok, true) + refused(written(marked(largestNesting + 1)), 'unsupported-nesting-depth', ...deeper('a', 'link')) assert.equal(readable(marked(largestNesting + 1)), true) }) @@ -140,27 +133,27 @@ test('refuses an attribute value that is an object JSON does not hold', () => { test("reads a node's siblings as a walk rather than as one call's arguments", () => { const wide = { content: [{ content: Array.from({ length: 200000 }, () => ({ type: 'rule' })), type: 'blockquote' }], type: 'doc', version: 1 } assert.equal(readable(wide), true) - assert.equal(fault(wide), 'accepted') + assert.equal(written(wide).ok, true) }) test('refuses an object holding itself, and accepts one shared', () => { - const holdsItself = 'not-an-adf-document: an ADF document is a tree: an object in it holds itself' + const holdsItself = ['a tree', 'holds itself'] const paragraph: { content: unknown[]; type: string } = { content: [], type: 'paragraph' } paragraph.content.push(paragraph) - assert.equal(readFault({ content: [paragraph], type: 'doc', version: 1 }), holdsItself) + refused(readAdfDocument({ content: [paragraph], type: 'doc', version: 1 }), 'not-an-adf-document', ...holdsItself) const document: { content: unknown[]; type: string; version: number } = { content: [], type: 'doc', version: 1 } document.content.push(document) - assert.equal(readFault(document), holdsItself) + refused(readAdfDocument(document), 'not-an-adf-document', ...holdsItself) const attrs: Record = {} attrs['a'] = [attrs] - assert.equal(readFault({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), holdsItself) - assert.equal(readFault({ content: [{ marks: [{ attrs, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), holdsItself) + refused(readAdfDocument({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), 'not-an-adf-document', ...holdsItself) + refused(readAdfDocument({ content: [{ marks: [{ attrs, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), 'not-an-adf-document', ...holdsItself) assert.equal(readable({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), false) const bold = { type: 'strong' } const text = { marks: [bold], text: 'x', type: 'text' } const shared = { content: [text, text], type: 'paragraph' } const values = { b: 1 } - assert.equal(fault({ content: [shared, shared, { attrs: { a: [values, values] }, type: 'paragraph' }], type: 'doc', version: 1 }), 'accepted') + assert.equal(written({ content: [shared, shared, { attrs: { a: [values, values] }, type: 'paragraph' }], type: 'doc', version: 1 }).ok, true) }) test('opens every writer with the same checks: a document, version 1, and no more text than the longest output holds', () => { @@ -189,24 +182,23 @@ test('refuses an inline card a link marks, wherever the document holds it and wh const writers = [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown] for (const { holder, path } of holders) { for (const write of writers) { - const refused = write({ content: [holder], type: 'doc', version: 1 }) - assert.equal(!refused.ok && refused.error.code, 'unsupported-node-shape') - assert.deepEqual(!refused.ok && refused.error.path, path) - assert.match(!refused.ok ? refused.error.message : '', /inlineCard.*link/) + const result = write({ content: [holder], type: 'doc', version: 1 }) + refused(result, 'unsupported-node-shape', 'inlineCard', 'link') + assert.deepEqual(!result.ok && result.error.path, path) } } }) const shapeWriters = [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown] -const schemaRefusals: readonly { message: string; node: AdfNode }[] = [ - { message: 'a node of type panel holds no text key: this one holds one', node: { text: 'x', type: 'panel' } }, - { message: 'a node of type paragraph holds no text key: this one holds one', node: { text: 'x', type: 'paragraph' } }, - { message: 'a node of type media holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'media' } }, - { message: 'a node of type rule holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'rule' } }, - { message: 'a node of type status holds neither content nor text: this one holds content', node: { content: [{ text: 'x', type: 'text' }], type: 'status' } }, - { message: 'a node of type status holds neither content nor text: this one holds text', node: { text: 'x', type: 'status' } }, - { message: 'a node of type hardBreak holds neither content nor text: this one holds text', node: { text: 'x', type: 'hardBreak' } }, +const schemaRefusals: readonly { facts: string[]; node: AdfNode }[] = [ + { facts: ['panel', 'no text key'], node: { text: 'x', type: 'panel' } }, + { facts: ['paragraph', 'no text key'], node: { text: 'x', type: 'paragraph' } }, + { facts: ['media', 'no content'], node: { content: [{ type: 'paragraph' }], type: 'media' } }, + { facts: ['rule', 'no content'], node: { content: [{ type: 'paragraph' }], type: 'rule' } }, + { facts: ['status', 'neither content nor text', 'holds content'], node: { content: [{ text: 'x', type: 'text' }], type: 'status' } }, + { facts: ['status', 'neither content nor text', 'holds text'], node: { text: 'x', type: 'status' } }, + { facts: ['hardBreak', 'neither content nor text', 'holds text'], node: { text: 'x', type: 'hardBreak' } }, ] const schemaRefusalHolders: readonly { hold: (node: AdfNode) => AdfNode[]; path: (number | string)[]; where: string }[] = [ @@ -215,12 +207,13 @@ const schemaRefusalHolders: readonly { hold: (node: AdfNode) => AdfNode[]; path: { hold: (node) => [{ content: [node], type: 'futureBlock' }], path: ['content', 0, 'content', 0], where: 'in a carried node' }, ] -for (const { message, node } of schemaRefusals) { +for (const { facts, node } of schemaRefusals) { for (const { hold, path, where } of schemaRefusalHolders) { for (const write of shapeWriters) { - test(`${write.name} refuses what Atlassian's schema forbids ${where}: ${message}`, () => { - const refused = write({ content: hold(node), type: 'doc', version: 1 }) - assert.deepEqual(refused.ok ? 'written' : refused.error, { code: 'unsupported-node-shape', message, path }) + test(`${write.name} refuses what Atlassian's schema forbids ${where}: ${facts.join(', ')}`, () => { + const result = write({ content: hold(node), type: 'doc', version: 1 }) + refused(result, 'unsupported-node-shape', ...facts) + assert.deepEqual(result.ok ? 'written' : result.error.path, path) }) } } @@ -243,8 +236,8 @@ for (const node of carriedShapes) { test('names the first link-marked card in document order', () => { const card = (url: string): AdfNode => ({ attrs: { url }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' }) const content = [{ content: [card('https://a.example/'), card('https://b.example/')], type: 'paragraph' }, { content: [card('https://c.example/')], type: 'paragraph' }] - const refused = writableContent({ content, type: 'doc', version: 1 }) - assert.deepEqual(!refused.ok && refused.error.path, ['content', 0, 'content', 0]) + const result = writableContent({ content, type: 'doc', version: 1 }) + assert.deepEqual(!result.ok && result.error.path, ['content', 0, 'content', 0]) }) test('writes a card holding no link mark, and a node type it does not know holding one', () => { @@ -258,9 +251,9 @@ test('names a link-marked card at any depth by its path alone, with no step quad const depth = 100_000 let node: AdfNode = { attrs: { url: 'https://example.com/' }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' } for (let level = 0; level < depth; level += 1) node = { content: [node], type: 'panel' } - const refused = writableContent({ content: [node], type: 'doc', version: 1 }) - assert.equal(!refused.ok && refused.error.path.length, 2 * (depth + 1)) - assert.deepEqual(!refused.ok && refused.error.path.slice(0, 4), ['content', 0, 'content', 0]) + const result = writableContent({ content: [node], type: 'doc', version: 1 }) + assert.equal(!result.ok && result.error.path.length, 2 * (depth + 1)) + assert.deepEqual(!result.ok && result.error.path.slice(0, 4), ['content', 0, 'content', 0]) }) const writers = [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown] @@ -280,37 +273,39 @@ test('reads a document once into a copy that shares nothing with the original', }) test('refuses a value holding an accessor property, whichever object or array holds it', () => { - const refused = 'not-an-adf-document: an ADF document is plain data: one of its objects holds a getter or a setter' - assert.equal(readFault({ get content() { return [] }, type: 'doc', version: 1 }), refused) - assert.equal(readFault({ content: [{ get type() { return 'rule' } }], type: 'doc', version: 1 }), refused) - assert.equal(readFault(documentOf({ set a(value: number) { void value } })), refused) - assert.equal(readFault(documentOf({ a: Object.defineProperty([], 0, { enumerable: true, get: () => 1 }) })), refused) + const accessor = ['plain data', 'a getter or a setter'] + refused(readAdfDocument({ get content() { return [] }, type: 'doc', version: 1 }), 'not-an-adf-document', ...accessor) + refused(readAdfDocument({ content: [{ get type() { return 'rule' } }], type: 'doc', version: 1 }), 'not-an-adf-document', ...accessor) + refused(readAdfDocument(documentOf({ set a(value: number) { void value } })), 'not-an-adf-document', ...accessor) + refused(readAdfDocument(documentOf({ a: Object.defineProperty([], 0, { enumerable: true, get: () => 1 }) })), 'not-an-adf-document', ...accessor) }) test('refuses a getter minting a fresh child per access without walking it', () => { const endless = (): unknown => ({ get next() { return endless() } }) - assert.equal(readFault(documentOf(endless())), 'not-an-adf-document: an ADF document is plain data: one of its objects holds a getter or a setter') + refused(readAdfDocument(documentOf(endless())), 'not-an-adf-document', 'plain data', 'a getter or a setter') }) -const unreadable = 'not-an-adf-document: an ADF document is plain data: reading one of its objects threw' +const unreadable = ['plain data', 'threw'] for (const trap of ['getOwnPropertyDescriptor', 'getPrototypeOf', 'ownKeys']) { test(`refuses a Proxy whose ${trap} trap throws, as the document and inside it`, () => { const throwing = (target: object): object => new Proxy(target, { [trap]: () => { throw new Error('trap') } }) - assert.equal(readFault(throwing({ type: 'doc', version: 1 })), unreadable) - assert.equal(readFault(documentOf(throwing({ b: 1 }))), unreadable) + refused(readAdfDocument(throwing({ type: 'doc', version: 1 })), 'not-an-adf-document', ...unreadable) + refused(readAdfDocument(documentOf(throwing({ b: 1 }))), 'not-an-adf-document', ...unreadable) }) } test('refuses a Proxy that throws on an array, and a revoked one, wherever they stand', () => { const throwing = (target: unknown[], trap: string): object => new Proxy(target, { [trap]: () => { throw new Error('trap') } }) - assert.equal(readFault({ content: throwing([1], 'getOwnPropertyDescriptor'), type: 'doc', version: 1 }), unreadable) - assert.equal(readFault({ content: throwing([], 'get'), type: 'doc', version: 1 }), unreadable) + refused(readAdfDocument({ content: throwing([1], 'getOwnPropertyDescriptor'), type: 'doc', version: 1 }), 'not-an-adf-document', ...unreadable) + refused(readAdfDocument({ content: throwing([], 'get'), type: 'doc', version: 1 }), 'not-an-adf-document', ...unreadable) const revoked = Proxy.revocable({}, {}) revoked.revoke() - assert.equal(readFault(revoked.proxy), unreadable) - assert.equal(readFault(documentOf(revoked.proxy)), unreadable) - assert.deepEqual(readAdfDocument(revoked.proxy), { error: { code: 'not-an-adf-document', message: unreadable.slice('not-an-adf-document: '.length), path: [] }, ok: false }) + refused(readAdfDocument(revoked.proxy), 'not-an-adf-document', ...unreadable) + refused(readAdfDocument(documentOf(revoked.proxy)), 'not-an-adf-document', ...unreadable) + const read = readAdfDocument(revoked.proxy) + refused(read, 'not-an-adf-document', ...unreadable) + assert.deepEqual(read.ok ? 'accepted' : read.error.path, []) }) test('copies own enumerable string keys alone, as JSON.stringify does', () => { @@ -350,27 +345,27 @@ test('reads an array by index, so no iterator runs and a hole is no JSON value', assert.equal(readable(documentOf({ a: [1, , 3] })), false) }) -const claimed = 'unsupported-node-shape: an ADF document holds at most 10000000 JSON values: this one holds more' +const claimed = ['at most 10000000 JSON values'] const claiming = (length: unknown): object => new Proxy([], { get: (target, key) => (key === 'length' ? length : Reflect.get(target, key)), getOwnPropertyDescriptor: () => { throw new Error('trap') }, }) test('refuses an array claiming more elements than the values left, before it reads any', () => { - assert.equal(readFault({ content: claiming(2 ** 32 - 1), type: 'doc', version: 1 }), claimed) - assert.equal(readFault({ content: new Array(10_000_000 - 1), type: 'doc', version: 1 }), claimed) + refused(readAdfDocument({ content: claiming(2 ** 32 - 1), type: 'doc', version: 1 }), 'unsupported-node-shape', ...claimed) + refused(readAdfDocument({ content: new Array(10_000_000 - 1), type: 'doc', version: 1 }), 'unsupported-node-shape', ...claimed) }) test('reads an array of the most values the document may hold, and refuses one value more', () => { - assert.equal(readFault({ content: claiming(10_000_000 - 4), type: 'doc', version: 1 }), unreadable) - assert.equal(readFault({ content: claiming(10_000_000 - 3), type: 'doc', version: 1 }), claimed) + refused(readAdfDocument({ content: claiming(10_000_000 - 4), type: 'doc', version: 1 }), 'not-an-adf-document', ...unreadable) + refused(readAdfDocument({ content: claiming(10_000_000 - 3), type: 'doc', version: 1 }), 'unsupported-node-shape', ...claimed) }) test('refuses an array whose length is no count', () => { - const badLength = "not-an-adf-document: an ADF document is plain data: an array's length is not a count" - assert.equal(readFault({ content: claiming('many'), type: 'doc', version: 1 }), badLength) - assert.equal(readFault({ content: claiming(-1), type: 'doc', version: 1 }), badLength) - assert.equal(readFault({ content: claiming(1.5), type: 'doc', version: 1 }), badLength) + const badLength = ['plain data', "an array's length is not a count"] + refused(readAdfDocument({ content: claiming('many'), type: 'doc', version: 1 }), 'not-an-adf-document', ...badLength) + refused(readAdfDocument({ content: claiming(-1), type: 'doc', version: 1 }), 'not-an-adf-document', ...badLength) + refused(readAdfDocument({ content: claiming(1.5), type: 'doc', version: 1 }), 'not-an-adf-document', ...badLength) }) const hostile: [string, AdfDocument][] = [ diff --git a/src/html/parse/html-to-adf.test.ts b/src/html/parse/html-to-adf.test.ts index 1128f19..580b69a 100644 --- a/src/html/parse/html-to-adf.test.ts +++ b/src/html/parse/html-to-adf.test.ts @@ -41,8 +41,10 @@ function refusal(result: Result): string { return result.ok ? 'read' : `${result.error.code} at /${result.error.path.join('/')} line ${result.error.position.line} offset ${result.error.position.offset}` } -function message(result: Result): string { - return result.ok ? 'read' : result.error.message +// A message is free text: a refusal pins only the facts the message must name. +function names(result: Result, ...facts: readonly string[]): void { + const message = result.ok ? '' : result.error.message + for (const fact of facts) assert.ok(message.includes(fact), `${JSON.stringify(message)} names ${fact}`) } const documentCarry = carry('div', { type: 'doc', version: 1 }, '') @@ -183,19 +185,19 @@ test('reads an image standing alone in a paragraph, a link around it where one m test('refuses a carry element holding an inline card a link marks, at any depth in the node it carries', () => { const card = { attrs: { url: 'https://e.com/r' }, marks: [{ attrs: { href: 'https://e.com/' }, type: 'link' }], type: 'inlineCard' } for (const html of [`

${carry('span', card, '')}

\n`, `${carry('div', { content: [{ content: [card], type: 'futureNode' }], type: 'futureBlock' }, '')}\n`]) { - assert.match(message(htmlToAdf(html)), /^a carry element's data-adf holds a node every writer refuses: an inlineCard takes no link mark/) + names(htmlToAdf(html), 'data-adf', 'every writer refuses', 'inlineCard', 'link mark') } }) test('refuses a carry element holding a shape every writer refuses, at any depth in the node it carries', () => { const shapes = [ - { message: 'a node of type panel holds no text key: this one holds one', node: { text: 'x', type: 'panel' } }, - { message: 'a node of type rule holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'rule' } }, - { message: 'a node of type status holds neither content nor text: this one holds text', node: { text: 'x', type: 'status' } }, + { facts: ['panel', 'no text key'], node: { text: 'x', type: 'panel' } }, + { facts: ['rule', 'no content'], node: { content: [{ type: 'paragraph' }], type: 'rule' } }, + { facts: ['status', 'neither content nor text', 'holds text'], node: { text: 'x', type: 'status' } }, ] - for (const { message: refusal, node } of shapes) { + for (const { facts, node } of shapes) { for (const carried of [node, { content: [node], type: 'futureBlock' }]) { - assert.equal(message(htmlToAdf(`${carry('div', carried, '')}\n`)), `a carry element's data-adf holds a node every writer refuses: ${refusal}`) + names(htmlToAdf(`${carry('div', carried, '')}\n`), 'data-adf', 'every writer refuses', ...facts) } } }) @@ -487,59 +489,59 @@ test('refuses a tag of three million attributes, past what one pattern over them assert.equal(refusal(htmlToAdf(`x

\n`)), 'unmappable-html at /content/0 line 1 offset 0') }) -const messages: readonly (readonly [string, string])[] = [ - ['

ab

\n', ' as written here is no inline element of the dialect: found "b

\\n"'], - ['

a > b

\n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found "> b

\\n"'], - ['

a

\n', ' holds its attributes as name="value", each value escaping & as & and " as ", then >: found "b\\">a

\\n"'], - ['

&b

\n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found "&b

\\n"'], - ['
a&b
\n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found "&b\\n"'], - ['

a 

\n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found " 

\\n"'], - ['
a
: found "x\n', ' as written here is no code element of the dialect, which writes or : found "\n
  • a\n', 'the line ends at
  • , or at a line feed before the next block, and its text spells < as <: found "\\n"'], - ['
      \n
    • ab
    • \n
    \n', ' as written here is no inline element of the dialect: found "b\\n\\n"'], - ['
      \n
    • if a < b
    • \n
    \n', 'the line ends at , or at a line feed before the next block, and its text spells < as <: found "< b\\n\\n"'], - ['
      \n
    • see x
    • \n
    \n', ' holds its attributes as name="value", each value escaping & as & and " as ", then >: found "&b=2\\">x\\n\\n"'], - ['
      \n
    • a

      x
    • \n
    \n', 'the line ends at , or at a line feed before the next block: found "x\\n"…'], - ['
      \n
    • Bolt
        \n
      • a
      • \n
    • \n
    \n', 'the line ends at , or at a line feed before the next block: found "
      \\n
    • a
    • \\n
    \n
  • a
  • b
  • \n\n', 'the line ends at , or at a line feed before the next block: found "
  • b
  • \\n\\n"'], - ['
      \n
    • a\n
    • b
    • \n
    \n', 'the line ends at , or at a line feed before the next block: found "
  • b
  • \\n\\n"'], - ['

    x

    \n', ' as written here is no mark element of the dialect, and a mark element holds text, or one other mark element around text: found "x

    \\n"'], - ['


    x

    \n', '
    as written here is no mark element of the dialect, and a mark element holds text, or one other mark element around text: found "
    x

    \\n"'], - ['

    \n', 'a mark element holds text, or one other mark element around text: found "

    \\n"'], - ['

    x

    \n', ' holds its attributes as name="value", each value escaping & as & and " as ", then >: found "&b=2\\">x

    "…'], - [`

    a

    \n${carry('div', panel, '

    Check the collation.

    ')}\n`, 'a carry element shows what adfToHtml writes for its node, as it or a browser spells it, then ; to change the node, edit its ADF and write the HTML again: found "collation.

    a

    \n', 'a of the dialect is a carry element, holding class="adf-carry" and then data-adf alone, or opens with class="adf-emoji", "adf-mention" or "adf-status": found "a

    \\n"'], - ['
    \n', 'a carry element spells its node\'s JSON canonically: compact, keys sorted, each character HTML cannot hold spelled as a JSON escape: found "{ "type":""…'], - [`${documentCarry}\n
    \n`, 'a carry element holding the document stands alone, as the whole document: found "\\n
    \\n"'], - [`
    \n${documentCarry}\n`, 'a carry element holding the document stands alone, as the whole document: found "
    \n
  • Bolt

  • \n\n', 'an item\'s or cell\'s first paragraph holding content and no attribute stands bare on the line of the item\'s or cell\'s start tag, as in
  • Bolt
  • : found "

    Bolt

    \\n\\n"'], - ['
      \n
    • \n
    \n', 'an item\'s or cell\'s first paragraph holding neither content nor an attribute stands on a line of its own: found "

    \\n\\n"'], - ['
      \n
    • \n

      \n
    • \n
    \n', 'an item\'s or cell\'s first paragraph holding an attribute stands as its

    straight after the item\'s or cell\'s start tag: found "

    b

    \n', 'an image stands alone in its paragraph as …, alt left out where it holds none, inside where a link marks it: found "b

    \\n"'], - ['

    \n', 'an image stands alone in its paragraph as …, alt left out where it holds none, inside
    where a link marks it: found "\\"\\"

    "…'], - ['
      \n
    1. a
    2. \n
    \n', 'an ordered list starts at an integer, written as JavaScript writes it: found "03\\">\\n
  • a
  • \\n\\n"'], - ['

    a

    \n', '

    takes no class attribute'], - ['

    a

    \n', '

    takes no data-color attribute'], - ['

      \n
    1. a
    2. \n
    \n', '
      takes start once, before its data-* attributes: found start after data-local-id'], - ['
        \n
      1. a
      2. \n
      \n', '
        takes start once, before its data-* attributes: found start after start'], - ['
          \n
        1. a
        2. \n
        \n', '
          takes no data-bogus attribute'], - ['
          x
          \n', 'data-wrap holds no boolean written as compact JSON'], - ['

          \n', ' holds its text, which is not empty: found "

          \\n"'], - ['

          Spec

          \n', ' holds one URL, as its href and as its text, or the URL its data-data holds as its href and its name, else that URL, as its text; to change the card, change them together: found "Spec

          \\n"'], - [`

          Plan

          \n`, ' holds one URL, as its href and as its text, or the URL its data-data holds as its href and its name, else that URL, as its text; to change the card, change them together: found "Plan

          \\n"'], - ['

          \n', '

          \\n"'], - ['

          a

          \n', ' takes no data-size attribute'], - ['

          a&b

          \n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found "&b

          \\n"'], +const messages: readonly (readonly [string, readonly string[]])[] = [ + ['

          ab

          \n', ['', 'found "b

          \\n"']], + ['

          a > b

          \n', ['& as &', '< as <', 'found "> b

          \\n"']], + ['

          a

          \n', ['', '& as &', '" as "', 'found "b\\">a

          \\n"']], + ['

          &b

          \n', ['& as &', '< as <', 'found "&b

          \\n"']], + ['
          a&b
          \n', ['& as &', '< as <', 'found "&b
          \\n"']], + ['

          a 

          \n', ['& as &', '< as <', 'found " 

          \\n"']], + ['
          ax
          \n', ['', 'found "\n
        1. a\n', ['< as <', 'found "\\n"']], + ['
            \n
          • ab
          • \n
          \n', ['', 'found "b
        2. \\n\\n"']], + ['
            \n
          • if a < b
          • \n
          \n', ['< as <', 'found "< b\\n\\n"']], + ['
            \n
          • see x
          • \n
          \n', ['', '& as &', '" as "', 'found "&b=2\\">x\\n\\n"']], + ['
            \n
          • a

            x
          • \n
          \n', ['found "x\\n"…']], + ['
            \n
          • Bolt
              \n
            • a
            • \n
          • \n
          \n', ['found "
            \\n
          • a
          • \\n
          \n
        3. a
        4. b
        5. \n\n', ['found "
        6. b
        7. \\n\\n"']], + ['
            \n
          • a\n
          • b
          • \n
          \n', ['found "
        8. b
        9. \\n\\n"']], + ['

          x

          \n', ['', 'found "x

          \\n"']], + ['


          x

          \n', ['
          ', 'found "
          x

          \\n"']], + ['

          \n', ['found "

          \\n"']], + ['

          x

          \n', ['', '& as &', '" as "', 'found "&b=2\\">x

          "…']], + [`

          a

          \n${carry('div', panel, '

          Check the collation.

          ')}\n`, ['to change the node, edit its ADF and write the HTML again', 'found "collation.

          a

          \n', ['found "a

          \\n"']], + ['
          \n', ['found "{ "type":""…']], + [`${documentCarry}\n
          \n`, ['found "\\n
          \\n"']], + [`
          \n${documentCarry}\n`, ['found "
          \n
        10. Bolt

        11. \n\n', ['found "

          Bolt

          \\n\\n"']], + ['
            \n
          • \n
          \n', ['found "

          \\n\\n"']], + ['
            \n
          • \n

            \n
          • \n
          \n', ['found "

          b

          \n', ['found "b

          \\n"']], + ['

          \n', ['found "\\"\\"

          "…']], + ['
            \n
          1. a
          2. \n
          \n', ['found "03\\">\\n
        12. a
        13. \\n
        \\n"']], + ['

        a

        \n', ['

        ', 'takes no class attribute']], + ['

        a

        \n', ['

        ', 'takes no data-color attribute']], + ['

          \n
        1. a
        2. \n
        \n', ['
          ', 'found start after data-local-id']], + ['
            \n
          1. a
          2. \n
          \n', ['
            ', 'found start after start']], + ['
              \n
            1. a
            2. \n
            \n', ['
              ', 'takes no data-bogus attribute']], + ['
              x
              \n', ['data-wrap', 'boolean']], + ['

              \n', ['', 'found "

              \\n"']], + ['

              Spec

              \n', ['', 'to change the card, change them together', 'found "Spec

              \\n"']], + [`

              Plan

              \n`, ['', 'to change the card, change them together', 'found "Plan

              \\n"']], + ['

              \n', ['

              \\n"']], + ['

              a

              \n', ['', 'takes no data-size attribute']], + ['

              a&b

              \n', ['& as &', '< as <', 'found "&b

              \\n"']], ] -for (const [html, expected] of messages) test(`names what it found where reading stopped, a character no one sees as its escape: ${JSON.stringify(html)}`, () => assert.equal(message(htmlToAdf(html)), expected)) +for (const [html, facts] of messages) test(`names what it found where reading stopped, a character no one sees as its escape: ${JSON.stringify(html)}`, () => names(htmlToAdf(html), ...facts)) test('reads blocks nested 500 levels, and refuses the next level, a list\'s items one level below it', () => { const quotes = (levels: number): string => `${'
              \n'.repeat(levels)}

              x

              ${'\n
              '.repeat(levels)}\n` assert.equal(refusal(htmlToAdf(quotes(500))), 'read') assert.equal(refusal(htmlToAdf(quotes(501))).startsWith(`unsupported-nesting-depth at /${'content/0/'.repeat(501)}content/0 line ${502}`), true) - assert.equal(message(htmlToAdf(quotes(501))), 'blocks nest deeper than 500 levels') + names(htmlToAdf(quotes(501)), 'blocks', '500 levels') const lists = (levels: number): string => `${'
                \n
              • x\n'.repeat(levels)}
                ${'\n
              • \n
              '.repeat(levels)}\n` assert.equal(refusal(htmlToAdf(lists(500))), 'read') assert.equal(refusal(htmlToAdf(lists(501))).startsWith('unsupported-nesting-depth'), true) diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index fbaa05f..1635f22 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' -import type { ParseError, Result, SourcePosition } from '../../result.ts' +import type { ConvertErrorCode, ParseError, Result, SourcePosition } from '../../result.ts' import { commonMarkToAdf, losslessMarkdownToAdf } from './markdown-to-adf.ts' import { largestNesting } from '../../nesting.ts' import { portableMarkdownToAdf } from '../portable/portable-markdown-to-adf.ts' @@ -28,6 +28,13 @@ function position(result: Result): SourcePosition | str return result.ok ? 'built' : result.error.position } +// A message is free text: a refusal pins its code and only the facts the message must name. +function refused(result: Result, errorCode: ConvertErrorCode, ...facts: string[]): void { + assert.equal(code(result), errorCode) + const message = result.ok ? '' : result.error.message + for (const fact of facts) assert.ok(message.includes(fact), `${JSON.stringify(message)} names ${fact}`) +} + function text(value: string): AdfNode { return { text: value, type: 'text' } } @@ -96,10 +103,9 @@ test('refuses a value that is not a string, in every reader, at the start of the const found: [unknown, string][] = [[null, 'null'], [undefined, 'undefined'], [42, 'a number'], [10n, 'a bigint'], [['x'], 'an object'], [revoked.proxy, 'an object']] for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf]) { for (const [value, described] of found) { - assert.deepEqual(Reflect.apply(read, undefined, [value]), { - error: { code: 'not-a-string', message: `markdown is a string: found ${described}`, path: [], position: { line: 1, offset: 0 } }, - ok: false, - }) + const result = Reflect.apply(read, undefined, [value]) + refused(result, 'not-a-string', 'a string', `found ${described}`) + assert.deepEqual([path(result), position(result)], [[], { line: 1, offset: 0 }]) } assert.notStrictEqual(position(Reflect.apply(read, undefined, [null])), position(Reflect.apply(read, undefined, [null]))) } @@ -133,19 +139,16 @@ test('builds an empty document from input holding no block, and one holding no c assert.deepEqual(losslessMarkdownToAdf('\n \n\t\n'), { ok: true, value: { content: [], type: 'doc', version: 1 } }) assert.deepEqual(losslessMarkdownToAdf('!adf:doc {content=none}\n'), { ok: true, value: { type: 'doc', version: 1 } }) assert.deepEqual(losslessMarkdownToAdf('\n!adf:doc {content=none}\n\n'), { ok: true, value: { type: 'doc', version: 1 } }) - const form = 'unsupported-node-shape: doc spells the one form !adf:doc {content=none}: this one spells another' - assert.equal(content(losslessMarkdownToAdf('!adf:doc\n')), form) - assert.equal(content(losslessMarkdownToAdf('!adf:doc x {content=none}\n')), form) - assert.equal( - content(losslessMarkdownToAdf('!adf:doc {content=empty}\n')), - 'unsupported-node-shape: an empty document is empty markdown, and !adf:doc {content=none} spells a document holding no content key: this one spells content=empty', - ) - assert.equal(content(losslessMarkdownToAdf('!adf:doc {content="none"}\n')), form) - const alone = 'unsupported-node-shape: delete the !adf:doc {content=none} line to give the document content: it stands only as the whole document' - assert.equal(content(losslessMarkdownToAdf('x\n\n!adf:doc {content=none}\n')), alone) - assert.equal(content(losslessMarkdownToAdf('!adf:panel info\n!adf:doc {content=none}\n!adf:/panel\n')), alone) - assert.equal(content(losslessMarkdownToAdf('!adf:doc\n!adf:/doc\n')), 'malformed-directive: doc takes no body, so no !adf:/doc closes it; \\!adf: keeps the prefix literal') - assert.equal(content(losslessMarkdownToAdf('a !adf:doc{content=none}\n')), 'unsupported-node-shape: doc takes the block form, !adf:doc, never the inline form') + const form = ['!adf:doc {content=none}'] + refused(losslessMarkdownToAdf('!adf:doc\n'), 'unsupported-node-shape', ...form) + refused(losslessMarkdownToAdf('!adf:doc x {content=none}\n'), 'unsupported-node-shape', ...form) + refused(losslessMarkdownToAdf('!adf:doc {content=empty}\n'), 'unsupported-node-shape', 'empty markdown', '!adf:doc {content=none}', 'content=empty') + refused(losslessMarkdownToAdf('!adf:doc {content="none"}\n'), 'unsupported-node-shape', ...form) + const alone = ['delete the !adf:doc {content=none} line', 'whole document'] + refused(losslessMarkdownToAdf('x\n\n!adf:doc {content=none}\n'), 'unsupported-node-shape', ...alone) + refused(losslessMarkdownToAdf('!adf:panel info\n!adf:doc {content=none}\n!adf:/panel\n'), 'unsupported-node-shape', ...alone) + refused(losslessMarkdownToAdf('!adf:doc\n!adf:/doc\n'), 'malformed-directive', '!adf:/doc', '\\!adf:') + refused(losslessMarkdownToAdf('a !adf:doc{content=none}\n'), 'unsupported-node-shape', '!adf:doc', 'inline form') }) test('builds one paragraph from the lines a blank line does not part', () => { @@ -209,15 +212,12 @@ test('reads the codeBlock directive body as the node content, the info string it }) test('names the slot a codeBlock spells its language outside of', () => { - const slot = 'unsupported-node-shape: codeBlock spells its language in the fence info string, or in the attribute where no info string carries it back' - assert.equal(content(losslessMarkdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), "unsupported-node-shape: move language=rust to the fences' info strings: they can spell this language") - assert.equal( - content(losslessMarkdownToAdf('!adf:codeBlock {language="c sharp"}\n```\nx\n```\n```\ny\n```\n!adf:/codeBlock\n')), - "unsupported-node-shape: move language=\"c sharp\" to the fences' info strings: they can spell this language", - ) - assert.equal(content(losslessMarkdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n')), slot) - assert.equal(content(losslessMarkdownToAdf('!adf:codeBlock {wrap=true}\n```adf:x\nx\n```\n!adf:/codeBlock\n')), slot) - assert.equal(content(losslessMarkdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\n')), slot) + const slot = ['codeBlock', 'info string'] + refused(losslessMarkdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n'), 'unsupported-node-shape', 'language=rust', 'info strings') + refused(losslessMarkdownToAdf('!adf:codeBlock {language="c sharp"}\n```\nx\n```\n```\ny\n```\n!adf:/codeBlock\n'), 'unsupported-node-shape', 'language="c sharp"', 'info strings') + refused(losslessMarkdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n'), 'unsupported-node-shape', ...slot) + refused(losslessMarkdownToAdf('!adf:codeBlock {wrap=true}\n```adf:x\nx\n```\n!adf:/codeBlock\n'), 'unsupported-node-shape', ...slot) + refused(losslessMarkdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\n'), 'unsupported-node-shape', ...slot) }) test('reads a pipe table into the header row and the body rows under it', () => { @@ -245,23 +245,23 @@ 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(losslessMarkdownToAdf('| a | b |\n')), 'malformed-pipe-table: a pipe table underlines its header with a row of `-` runs: this one has none; \\| at the start of every row keeps them literal text') - assert.equal(content(losslessMarkdownToAdf('| a |\n| x |\n')), 'malformed-pipe-table: a pipe table underlines its header with a row of `-` runs: this one has none; \\| at the start of every row keeps them literal text') - assert.equal(content(losslessMarkdownToAdf('| 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(losslessMarkdownToAdf('| a | b |\n| --- |\n')), 'malformed-pipe-table: a pipe table row holds 1 cell where its header holds 2 cells') - assert.equal(content(losslessMarkdownToAdf('| a |\n| --- |\n| b | c |\n')), 'malformed-pipe-table: a pipe table row holds 2 cells where its header holds 1 cell') + refused(losslessMarkdownToAdf('| a | b |\n'), 'malformed-pipe-table', '`-`', '\\|') + refused(losslessMarkdownToAdf('| a |\n| x |\n'), 'malformed-pipe-table', '`-`', '\\|') + refused(losslessMarkdownToAdf('| a | b |\n| :--- | ---: |\n'), 'malformed-pipe-table', 'alignment colon') + refused(losslessMarkdownToAdf('| a | b |\n| --- |\n'), 'malformed-pipe-table', '1 cell', '2 cells') + refused(losslessMarkdownToAdf('| a |\n| --- |\n| b | c |\n'), 'malformed-pipe-table', '2 cells', '1 cell') assert.deepEqual(path(losslessMarkdownToAdf('Part.\n\n| a |\n')), ['content', 1]) }) test('names the pipe table whose rows open with no pipe', () => { - const bare = 'malformed-pipe-table: a pipe table opens every row with `|`: this one does not; \\| keeps a pipe literal text' - assert.equal(content(losslessMarkdownToAdf('a | b\n--- | ---\n')), bare) - assert.equal(content(losslessMarkdownToAdf('Intro.\na | b\n--- | ---\n')), bare) - assert.equal(content(losslessMarkdownToAdf('a | b\n--- | ---\n===\n')), bare) - assert.equal(content(losslessMarkdownToAdf('a | b\n:--- | ---:\n')), bare) - assert.equal(content(losslessMarkdownToAdf('a |\n--- |\n')), bare) + const bare = ['`|`', '\\|'] + refused(losslessMarkdownToAdf('a | b\n--- | ---\n'), 'malformed-pipe-table', ...bare) + refused(losslessMarkdownToAdf('Intro.\na | b\n--- | ---\n'), 'malformed-pipe-table', ...bare) + refused(losslessMarkdownToAdf('a | b\n--- | ---\n===\n'), 'malformed-pipe-table', ...bare) + refused(losslessMarkdownToAdf('a | b\n:--- | ---:\n'), 'malformed-pipe-table', ...bare) + refused(losslessMarkdownToAdf('a |\n--- |\n'), 'malformed-pipe-table', ...bare) assert.deepEqual(content(losslessMarkdownToAdf('| a | b |\n| --- | --- |\nc\n')), [table(row(cell('tableHeader', text('a')), cell('tableHeader', text('b')))), paragraph('c')]) - assert.equal(content(losslessMarkdownToAdf('| a | b |\n| --- | --- |\n| c |\n')), 'malformed-pipe-table: a pipe table row holds 1 cell where its header holds 2 cells') + refused(losslessMarkdownToAdf('| a | b |\n| --- | --- |\n| c |\n'), 'malformed-pipe-table', '1 cell', '2 cells') assert.deepEqual(content(losslessMarkdownToAdf('a | b\nc | d\n')), [paragraph('a | b c | d')]) assert.deepEqual(content(losslessMarkdownToAdf('a | b\n--- | --- | ---\n')), [paragraph('a | b --- | --- | ---')]) assert.deepEqual(content(losslessMarkdownToAdf('a \\| b\n--- | ---\n')), [paragraph('a | b --- | ---')]) @@ -270,11 +270,11 @@ test('names the pipe table whose rows open with no pipe', () => { }) test('gives back the refusal an inline body holds, never the shape check above it', () => { - const bare = 'malformed-pipe-table: a pipe table opens every row with `|`: this one does not; \\| keeps a pipe literal text' - assert.equal(content(losslessMarkdownToAdf('!adf:caption\na | b\n--- | ---\n!adf:/caption\n')), bare) + const bare = ['`|`', '\\|'] + refused(losslessMarkdownToAdf('!adf:caption\na | b\n--- | ---\n!adf:/caption\n'), 'malformed-pipe-table', ...bare) assert.deepEqual(position(losslessMarkdownToAdf('!adf:caption\na | b\n--- | ---\n!adf:/caption\n')), { line: 2, offset: 13 }) assert.equal(code(losslessMarkdownToAdf('!adf:caption\n| a |\n!adf:/caption\n')), 'malformed-pipe-table') - assert.equal(content(losslessMarkdownToAdf('!adf:caption\n- a\n!adf:/caption\n')), 'unsupported-node-shape: caption takes one paragraph as its body: this body is not one') + refused(losslessMarkdownToAdf('!adf:caption\n- a\n!adf:/caption\n'), 'unsupported-node-shape', 'caption', 'one paragraph') }) test('reads a change of bullet character or ordered delimiter as a second list, in every reader', () => { @@ -314,7 +314,7 @@ test('claims a block line the prefix opens with no directive to parse it', () => assert.equal(code(losslessMarkdownToAdf('!adf:/panel\n')), 'malformed-directive') assert.equal(code(losslessMarkdownToAdf('!adf:panel info\n')), 'malformed-directive') assert.equal(code(losslessMarkdownToAdf('!adf:panel {a=1 a=2}\n!adf:/panel\n')), 'malformed-directive') - assert.equal(content(losslessMarkdownToAdf('!adf:Panel\n')), 'malformed-directive: an unescaped !adf: completes no directive; \\!adf: keeps the prefix literal') + refused(losslessMarkdownToAdf('!adf:Panel\n'), 'malformed-directive', '\\!adf:') assert.deepEqual(content(losslessMarkdownToAdf(':::panel info\nPart.\n:::\n')), [paragraph(':::panel info Part. :::')]) assert.deepEqual(content(losslessMarkdownToAdf('::rule\n')), [paragraph('::rule')]) }) @@ -334,74 +334,58 @@ test('reads the three directive forms into the nodes the tables name', () => { }) test('names the CommonMark spelling to write for the directive form a node CommonMark spells', () => { - const named = (type: string, spelling: string): string => `unsupported-node-shape: ${type} takes the CommonMark spelling, not the directive form: write ${spelling}` - assert.equal(content(losslessMarkdownToAdf('!adf:rule\n')), named('rule', '`---`')) - assert.equal(content(losslessMarkdownToAdf('!adf:blockquote\nPart.\n!adf:/blockquote\n')), named('blockquote', 'each line behind `> `')) - assert.equal(content(losslessMarkdownToAdf('!adf:heading {level=2}\nPart.\n!adf:/heading\n')), named('heading', 'one `#` per level before its text, `## Title` at level 2')) - assert.equal(content(losslessMarkdownToAdf('!adf:paragraph\nPart.\n!adf:/paragraph\n')), named('paragraph', 'its text alone')) - assert.equal(content(losslessMarkdownToAdf('!adf:bulletList\n!adf:listItem\nPart.\n!adf:/listItem\n!adf:/bulletList\n')), named('bulletList', 'each item behind `- `, or behind `* ` where the list right before it takes `- `')) - assert.equal( - content(losslessMarkdownToAdf('!adf:orderedList {order=5}\n!adf:listItem\nPart.\n!adf:/listItem\n!adf:/orderedList\n')), - named('orderedList', 'each item behind its number, counting up from `5. `, or from `5) ` where the list right before it takes `.`'), - ) - assert.equal(content(losslessMarkdownToAdf('!adf:codeBlock\n```\nx\n```\n!adf:/codeBlock\n')), named('codeBlock', 'its text in a fence of more backticks than any run it holds, its language after the opening fence')) - assert.equal( - content(losslessMarkdownToAdf('!adf:mediaSingle {layout=center}\n!adf:media {alt=a type=external url=u}\n!adf:/mediaSingle\n')), - named('mediaSingle', '`![alt](url)`, or `[![alt](url)](href)` when linked'), - ) - assert.equal( - content(losslessMarkdownToAdf('!adf:table\n!adf:tableRow\n!adf:tableHeader\nPart.\n!adf:/tableHeader\n!adf:/tableRow\n!adf:/table\n')), - named('table', 'a pipe table'), - ) + refused(losslessMarkdownToAdf('!adf:rule\n'), 'unsupported-node-shape', 'rule', '`---`') + refused(losslessMarkdownToAdf('!adf:blockquote\nPart.\n!adf:/blockquote\n'), 'unsupported-node-shape', 'blockquote', 'each line behind `> `') + refused(losslessMarkdownToAdf('!adf:heading {level=2}\nPart.\n!adf:/heading\n'), 'unsupported-node-shape', 'heading', '`## Title`') + refused(losslessMarkdownToAdf('!adf:paragraph\nPart.\n!adf:/paragraph\n'), 'unsupported-node-shape', 'paragraph', 'its text alone') + refused(losslessMarkdownToAdf('!adf:bulletList\n!adf:listItem\nPart.\n!adf:/listItem\n!adf:/bulletList\n'), 'unsupported-node-shape', 'bulletList', 'behind `- `', 'behind `* `') + refused(losslessMarkdownToAdf('!adf:orderedList {order=5}\n!adf:listItem\nPart.\n!adf:/listItem\n!adf:/orderedList\n'), 'unsupported-node-shape', 'orderedList', '`5. `', '`5) `') + refused(losslessMarkdownToAdf('!adf:codeBlock\n```\nx\n```\n!adf:/codeBlock\n'), 'unsupported-node-shape', 'codeBlock', 'fence') + refused(losslessMarkdownToAdf('!adf:mediaSingle {layout=center}\n!adf:media {alt=a type=external url=u}\n!adf:/mediaSingle\n'), 'unsupported-node-shape', 'mediaSingle', '`![alt](url)`', '`[![alt](url)](href)`') + refused(losslessMarkdownToAdf('!adf:table\n!adf:tableRow\n!adf:tableHeader\nPart.\n!adf:/tableHeader\n!adf:/tableRow\n!adf:/table\n'), 'unsupported-node-shape', 'table', 'a pipe table') // The item whose first line reads back as a thematic break keeps the directive form the emitter falls back to. assert.deepEqual(content(losslessMarkdownToAdf('!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')), [bulletList(item({ type: 'rule' }))]) }) test('refuses the directive form of a blockquote opening with a code span', () => { - assert.equal( - content(losslessMarkdownToAdf('!adf:blockquote\n` `` `\n!adf:/blockquote\n')), - 'unsupported-node-shape: blockquote takes the CommonMark spelling, not the directive form: write each line behind `> `', - ) + refused(losslessMarkdownToAdf('!adf:blockquote\n` `` `\n!adf:/blockquote\n'), 'unsupported-node-shape', 'blockquote', 'each line behind `> `') }) // The input is some 1000 characters short of V8's longest string; the `> ` the quote adds to each of its 600 lines passes it. Reading this much outlasts Bun's 5 s default timeout. test('refuses a blockquote directive whose CommonMark spelling would pass V8\'s longest string', { timeout: 120_000 }, () => { const fence = `\`\`\`\n${`${'a'.repeat(894_782)}\n`.repeat(300)}\`\`\`\n` - const refused = losslessMarkdownToAdf(`!adf:blockquote\n${fence}\n${fence}!adf:/blockquote\n`) - assert.equal(code(refused), 'unsupported-node-shape') - assert.equal(refused.ok ? '' : refused.error.message.includes('536870888'), true) + refused(losslessMarkdownToAdf(`!adf:blockquote\n${fence}\n${fence}!adf:/blockquote\n`), 'unsupported-node-shape', '536870888') }) test('names the directive name no node reads back to', () => { assert.equal(code(losslessMarkdownToAdf('!adf:widget info\nx\n!adf:/widget\n')), 'unknown-directive-name') - assert.equal(content(losslessMarkdownToAdf('!adf:widget\n')), 'unknown-directive-name: the directive name widget reads back to no node; \\!adf: keeps the prefix literal') - assert.equal(content(losslessMarkdownToAdf('!adf:widget[x]\n')), 'unknown-directive-name: the directive name widget reads back to no node; \\!adf: keeps the prefix literal') - assert.equal(content(losslessMarkdownToAdf('ratio a!adf:b[c]{d}\n')), 'malformed-directive: an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal') + refused(losslessMarkdownToAdf('!adf:widget\n'), 'unknown-directive-name', 'widget', '\\!adf:') + refused(losslessMarkdownToAdf('!adf:widget[x]\n'), 'unknown-directive-name', 'widget', '\\!adf:') + refused(losslessMarkdownToAdf('ratio a!adf:b[c]{d}\n'), 'malformed-directive', 'key=value', '\\!adf:') assert.deepEqual(path(losslessMarkdownToAdf('Part.\n\n!adf:widget\n')), ['content', 1]) }) test('names the container no closer closes inside the block holding it', () => { - const unclosed = (name: string): string => `malformed-directive: the ${name} container is unclosed: no !adf:/${name} follows inside the block holding it; \\!adf: keeps the prefix literal` - assert.equal(content(losslessMarkdownToAdf('Part.\n!adf:expand\n')), unclosed('expand')) + const unclosed = (name: string): string[] => [`${name} container is unclosed`, `!adf:/${name}`, '\\!adf:'] + refused(losslessMarkdownToAdf('Part.\n!adf:expand\n'), 'malformed-directive', ...unclosed('expand')) assert.deepEqual(path(losslessMarkdownToAdf('Part.\n!adf:expand\n')), ['content', 1]) - assert.equal(content(losslessMarkdownToAdf('!adf:bulletList\n')), unclosed('bulletList')) - assert.equal(content(losslessMarkdownToAdf('- !adf:panel info\n Part.\n!adf:/panel\n')), unclosed('panel')) - assert.equal(content(losslessMarkdownToAdf('> !adf:panel info\n> - a\n> !adf:/panel\n')), unclosed('panel')) - assert.equal(content(losslessMarkdownToAdf('!adf:panel info\n!adf:expand\nPart.\n!adf:/panel\n')), unclosed('expand')) + refused(losslessMarkdownToAdf('!adf:bulletList\n'), 'malformed-directive', ...unclosed('bulletList')) + refused(losslessMarkdownToAdf('- !adf:panel info\n Part.\n!adf:/panel\n'), 'malformed-directive', ...unclosed('panel')) + refused(losslessMarkdownToAdf('> !adf:panel info\n> - a\n> !adf:/panel\n'), 'malformed-directive', ...unclosed('panel')) + refused(losslessMarkdownToAdf('!adf:panel info\n!adf:expand\nPart.\n!adf:/panel\n'), 'malformed-directive', ...unclosed('expand')) assert.deepEqual(path(losslessMarkdownToAdf('!adf:panel info\n!adf:expand\nPart.\n!adf:/panel\n')), ['content', 0, 'content', 0]) }) test('names the closer that finds no container open where it stands', () => { - const unopened = (name: string): string => `malformed-directive: the closer !adf:/${name} closes no ${name} container open where it stands; \\!adf: keeps the prefix literal` - assert.equal(content(losslessMarkdownToAdf('Part.\n\n!adf:/panel\n')), unopened('panel')) - assert.equal(content(losslessMarkdownToAdf('!adf:panel info\n> !adf:/panel\n!adf:/panel\n')), unopened('panel')) - assert.equal(content(losslessMarkdownToAdf('!adf:panel info\n- !adf:/panel\n!adf:/panel\n')), unopened('panel')) - assert.equal(content(losslessMarkdownToAdf('!adf:panel info\nPart.\n!adf:/expand\n!adf:/panel\n')), unopened('expand')) + const unopened = (name: string): string[] => [`!adf:/${name} closes no ${name}`, '\\!adf:'] + refused(losslessMarkdownToAdf('Part.\n\n!adf:/panel\n'), 'malformed-directive', ...unopened('panel')) + refused(losslessMarkdownToAdf('!adf:panel info\n> !adf:/panel\n!adf:/panel\n'), 'malformed-directive', ...unopened('panel')) + refused(losslessMarkdownToAdf('!adf:panel info\n- !adf:/panel\n!adf:/panel\n'), 'malformed-directive', ...unopened('panel')) + refused(losslessMarkdownToAdf('!adf:panel info\nPart.\n!adf:/expand\n!adf:/panel\n'), 'malformed-directive', ...unopened('expand')) }) test('names the leaf given a body at its opener, ahead of any refusal the leaf holds itself', () => { - const body = (name: string): string => `malformed-directive: ${name} takes no body, so no !adf:/${name} closes it; \\!adf: keeps the prefix literal` - assert.equal(content(losslessMarkdownToAdf('!adf:rule\nPart.\n!adf:/rule\n')), body('rule')) + refused(losslessMarkdownToAdf('!adf:rule\nPart.\n!adf:/rule\n'), 'malformed-directive', '!adf:/rule', '\\!adf:') assert.deepEqual(position(losslessMarkdownToAdf('Part.\n\n!adf:rule\nPart.\n!adf:/rule\n')), { line: 3, offset: 7 }) assert.deepEqual(path(losslessMarkdownToAdf('!adf:rule {localId=a-1}\n!adf:rule {localId=a-2}\nPart.\n!adf:/rule\n')), ['content', 1]) assert.deepEqual(path(losslessMarkdownToAdf('!adf:rule {localId=a-1}\n!adf:/rule\n!adf:/rule\n')), ['content', 0]) @@ -410,21 +394,21 @@ test('names the leaf given a body at its opener, ahead of any refusal the leaf h }) test('names the position a directive name the other one spells belongs to', () => { - assert.equal(content(losslessMarkdownToAdf('!adf:em\na\n!adf:/em\n')), 'unsupported-node-shape: em is spelled _x_, never as a block directive') - assert.equal(content(losslessMarkdownToAdf('!adf:underline\n')), 'unsupported-node-shape: underline is spelled !adf:underline[…], never as a block directive') - assert.equal(content(losslessMarkdownToAdf('!adf:link\n')), 'unsupported-node-shape: link is spelled [x](url) or !adf:link[…], never as a block directive') - assert.equal(content(losslessMarkdownToAdf('!adf:text {text=" "}\n')), 'unsupported-node-shape: text takes the inline form, !adf:text{…}, never the block form') - assert.equal(content(losslessMarkdownToAdf('!adf:date {timestamp=1}\n')), 'unsupported-node-shape: date takes the inline form, !adf:date{…}, never the block form') - assert.equal(content(losslessMarkdownToAdf('!adf:paragraph[a]\n')), 'unsupported-node-shape: paragraph takes the block form, !adf:paragraph, never the inline form') - assert.equal(content(losslessMarkdownToAdf('!adf:rule[a]\n')), 'unsupported-node-shape: rule takes the block form, !adf:rule, never the inline form') + refused(losslessMarkdownToAdf('!adf:em\na\n!adf:/em\n'), 'unsupported-node-shape', '_x_', 'block directive') + refused(losslessMarkdownToAdf('!adf:underline\n'), 'unsupported-node-shape', '!adf:underline[…]', 'block directive') + refused(losslessMarkdownToAdf('!adf:link\n'), 'unsupported-node-shape', '[x](url)', '!adf:link[…]', 'block directive') + refused(losslessMarkdownToAdf('!adf:text {text=" "}\n'), 'unsupported-node-shape', '!adf:text{…}', 'block form') + refused(losslessMarkdownToAdf('!adf:date {timestamp=1}\n'), 'unsupported-node-shape', '!adf:date{…}', 'block form') + refused(losslessMarkdownToAdf('!adf:paragraph[a]\n'), 'unsupported-node-shape', '!adf:paragraph', 'inline form') + refused(losslessMarkdownToAdf('!adf:rule[a]\n'), 'unsupported-node-shape', '!adf:rule', 'inline form') assert.equal(code(losslessMarkdownToAdf('!adf:widget\na\n!adf:/widget\n')), 'unknown-directive-name') assert.equal(code(losslessMarkdownToAdf('!adf:widget[a]\n')), 'unknown-directive-name') }) test('names the reserved carry name a block directive spells', () => { - const reserved = 'malformed-directive: the name carry is reserved for the opaque carry, whose block form is the adf: fence' - assert.equal(content(losslessMarkdownToAdf('!adf:carry\n')), reserved) - assert.equal(content(losslessMarkdownToAdf('!adf:carry\nx\n!adf:/carry\n')), reserved) + const reserved = ['carry', 'adf: fence'] + refused(losslessMarkdownToAdf('!adf:carry\n'), 'malformed-directive', ...reserved) + refused(losslessMarkdownToAdf('!adf:carry\nx\n!adf:/carry\n'), 'malformed-directive', ...reserved) assert.deepEqual(content(losslessMarkdownToAdf('```adf\nx\n```\n')), [{ attrs: { language: 'adf' }, content: [text('x')], type: 'codeBlock' }]) assert.deepEqual(content(losslessMarkdownToAdf('```carry\nx\n```\n')), [{ attrs: { language: 'carry' }, content: [text('x')], type: 'codeBlock' }]) }) @@ -436,14 +420,11 @@ test('reads the carry fence back to the node its info string names and its JSON { attrs: { url: 'https://example.com/x' }, type: 'blockCard' }, ]) assert.deepEqual(content(losslessMarkdownToAdf('```adf:\n{\n "type": "a`b"\n}\n```\n')), [{ type: 'a`b' }]) - assert.equal( - content(losslessMarkdownToAdf('```adf:blockCard\n{\n "type": "blockCard"\n}\n```\n')), - 'unsupported-node-shape: the adf:blockCard fence names its node\'s type: this JSON holds a type as well; !adf:codeBlock {language="adf:blockCard"} around a bare fence keeps it a code block', - ) - assert.equal(content(losslessMarkdownToAdf('```adf:\n{\n "type": "blockCard"\n}\n```\n')), 'unsupported-node-shape: the adf: fence holds a type its info string can carry: spell the fence adf:blockCard and drop type from the JSON') - assert.equal(content(losslessMarkdownToAdf('```adf:\n{}\n```\n')), "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; !adf:codeBlock {language=\"adf:\"} around a bare fence keeps it a code block") - const unnamed = 'unsupported-node-shape: the carry fence names a type no info string carries back: spell it adf: with the type in the JSON; !adf:codeBlock {language="adf:\\\\"} around a bare fence keeps it a code block' - assert.equal(content(losslessMarkdownToAdf('```adf:\\\\\n{}\n```\n')), unnamed) + refused(losslessMarkdownToAdf('```adf:blockCard\n{\n "type": "blockCard"\n}\n```\n'), 'unsupported-node-shape', 'adf:blockCard', 'holds a type as well', '!adf:codeBlock {language="adf:blockCard"}') + refused(losslessMarkdownToAdf('```adf:\n{\n "type": "blockCard"\n}\n```\n'), 'unsupported-node-shape', 'adf:blockCard', 'drop type') + refused(losslessMarkdownToAdf('```adf:\n{}\n```\n'), 'unsupported-node-shape', 'no ADF node', '!adf:codeBlock {language="adf:"}') + const unnamed = ['spell it adf: with the type in the JSON', '!adf:codeBlock {language="adf:\\\\"}'] + refused(losslessMarkdownToAdf('```adf:\\\\\n{}\n```\n'), 'unsupported-node-shape', ...unnamed) }) test('reads the inline carry back to the node its json attribute holds', () => { @@ -460,93 +441,80 @@ test('reads an escaped JSON key as the key it spells after reading a key holding }) test('names the invalid JSON no opaque carry holds, and the spelling that keeps it literal', () => { - const invalid = 'malformed-directive: the opaque carry holds invalid JSON; ' - const fence = `${invalid}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block` - assert.equal(content(losslessMarkdownToAdf('```adf:x\n{"attrs":\n```\n')), fence) - assert.equal(content(losslessMarkdownToAdf('```adf:x\n```\n')), fence) - assert.equal(content(losslessMarkdownToAdf('!adf:carry{json="{"}\n')), `${invalid}\\!adf: keeps the prefix literal`) - assert.equal(content(losslessMarkdownToAdf('!adf:carry{json=abc}\n')), `${invalid}\\!adf: keeps the prefix literal`) + const fence = ['invalid JSON', '!adf:codeBlock {language="adf:x"}'] + refused(losslessMarkdownToAdf('```adf:x\n{"attrs":\n```\n'), 'malformed-directive', ...fence) + refused(losslessMarkdownToAdf('```adf:x\n```\n'), 'malformed-directive', ...fence) + refused(losslessMarkdownToAdf('!adf:carry{json="{"}\n'), 'malformed-directive', 'invalid JSON', '\\!adf:') + refused(losslessMarkdownToAdf('!adf:carry{json=abc}\n'), 'malformed-directive', 'invalid JSON', '\\!adf:') }) test('names the canonical spelling a carried JSON reads alone', () => { - const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: " - const literal = '; \\!adf: keeps the prefix literal' - assert.equal( - content(losslessMarkdownToAdf('```adf:blockCard\n{"attrs":{}}\n```\n')), - `${canonically}two-space indent, keys sorted; !adf:codeBlock {language="adf:blockCard"} around a bare fence keeps it a code block`, - ) - assert.equal(content(losslessMarkdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted${literal}`) - assert.equal(content(losslessMarkdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted${literal}`) + const compact = ['compact, keys sorted', '\\!adf:'] + refused(losslessMarkdownToAdf('```adf:blockCard\n{"attrs":{}}\n```\n'), 'unsupported-node-shape', 'two-space indent', '!adf:codeBlock {language="adf:blockCard"}') + refused(losslessMarkdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n'), 'unsupported-node-shape', ...compact) + refused(losslessMarkdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n'), 'unsupported-node-shape', ...compact) }) test('refuses compact JSON in the carry fence without building its two-space spelling', () => { const json = `{"attrs":{"value":${'['.repeat(495)}${new Array(560_000).fill(0).join(',')}${']'.repeat(495)}}}` - assert.equal( - content(losslessMarkdownToAdf(`\`\`\`adf:unknownBlock\n${json}\n\`\`\`\n`)), - "unsupported-node-shape: the opaque carry spells its node's JSON canonically: two-space indent, keys sorted; !adf:codeBlock {language=\"adf:unknownBlock\"} around a bare fence keeps it a code block", - ) + refused(losslessMarkdownToAdf(`\`\`\`adf:unknownBlock\n${json}\n\`\`\`\n`), 'unsupported-node-shape', 'two-space indent', '!adf:codeBlock {language="adf:unknownBlock"}') }) test('names the node JSON an opaque carry restores alone', () => { - const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; " - assert.equal(content(losslessMarkdownToAdf('```adf:x\n[]\n```\n')), `${node}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`) - assert.equal(content(losslessMarkdownToAdf('!adf:carry{json=null}\n')), `${node}\\!adf: keeps the prefix literal`) - assert.equal(content(losslessMarkdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), `${node}\\!adf: keeps the prefix literal`) + refused(losslessMarkdownToAdf('```adf:x\n[]\n```\n'), 'unsupported-node-shape', 'no ADF node', '!adf:codeBlock {language="adf:x"}') + refused(losslessMarkdownToAdf('!adf:carry{json=null}\n'), 'unsupported-node-shape', 'no ADF node', '\\!adf:') + refused(losslessMarkdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n'), 'unsupported-node-shape', 'no ADF node', '\\!adf:') }) test('refuses a carry holding an inline card a link marks, whether it stands alone, in a block or in a fence', () => { const card = { attrs: { url: 'https://e.com/r' }, marks: [{ attrs: { href: 'https://e.com/' }, type: 'link' }], type: 'inlineCard' } const json = JSON.stringify(card).replaceAll('"', '\\"') for (const markdown of [`!adf:carry{json="${json}"}\n`, `\`\`\`adf:futureBlock\n${JSON.stringify({ content: [card] }, null, 2)}\n\`\`\`\n`]) { - assert.match(String(content(losslessMarkdownToAdf(markdown))), /^unsupported-node-shape: the opaque carry holds a node every writer refuses: an inlineCard takes no link mark/) + refused(losslessMarkdownToAdf(markdown), 'unsupported-node-shape', 'every writer refuses', 'an inlineCard takes no link mark') } }) test('refuses a carry holding a shape every writer refuses, whether it stands alone, in a block or in a fence', () => { - const refused = 'unsupported-node-shape: the opaque carry holds a node every writer refuses: ' - const refusal = (markdown: string): string => String(content(losslessMarkdownToAdf(markdown))).replace(/; .*$/, '') + const holdsText = ['every writer refuses', 'status', 'neither content nor text'] const status = { text: 'x', type: 'status' } const json = JSON.stringify(status).replaceAll('"', '\\"') - assert.equal(refusal(`!adf:carry{json="${json}"}\n`), `${refused}a node of type status holds neither content nor text: this one holds text`) - assert.equal(refusal(`\`\`\`adf:futureBlock\n${JSON.stringify({ content: [status] }, null, 2)}\n\`\`\`\n`), `${refused}a node of type status holds neither content nor text: this one holds text`) - assert.equal(refusal(`\`\`\`adf:panel\n${JSON.stringify({ text: 'x' }, null, 2)}\n\`\`\`\n`), `${refused}a node of type panel holds no text key: this one holds one`) + refused(losslessMarkdownToAdf(`!adf:carry{json="${json}"}\n`), 'unsupported-node-shape', ...holdsText) + refused(losslessMarkdownToAdf(`\`\`\`adf:futureBlock\n${JSON.stringify({ content: [status] }, null, 2)}\n\`\`\`\n`), 'unsupported-node-shape', ...holdsText) + refused(losslessMarkdownToAdf(`\`\`\`adf:panel\n${JSON.stringify({ text: 'x' }, null, 2)}\n\`\`\`\n`), 'unsupported-node-shape', 'every writer refuses', 'panel', 'no text key') }) test('names the shape the inline carry reads alone', () => { - assert.equal(content(losslessMarkdownToAdf('!adf:carry[x]{json="{}"}\n')), 'unsupported-node-shape: carry takes no content: this one holds some') - assert.equal(content(losslessMarkdownToAdf('!adf:carry{}\n')), 'unsupported-node-shape: carry holds one json attribute alone: this one does not') - assert.equal(content(losslessMarkdownToAdf('!adf:carry{json="{}" localId=x}\n')), 'unsupported-node-shape: carry holds one json attribute alone: this one does not') - assert.equal(content(losslessMarkdownToAdf('!adf:carry{json="null"}\n')), 'unsupported-node-shape: carry spells its json attribute as json=null') + refused(losslessMarkdownToAdf('!adf:carry[x]{json="{}"}\n'), 'unsupported-node-shape', 'carry takes no content') + refused(losslessMarkdownToAdf('!adf:carry{}\n'), 'unsupported-node-shape', 'one json attribute') + refused(losslessMarkdownToAdf('!adf:carry{json="{}" localId=x}\n'), 'unsupported-node-shape', 'one json attribute') + refused(losslessMarkdownToAdf('!adf:carry{json="null"}\n'), 'unsupported-node-shape', 'json=null') }) test('holds a carried JSON value to the nesting its position leaves', () => { const nested = (levels: number): string => `${'['.repeat(levels)}${']'.repeat(levels)}` const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`adf:x\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n` - const deeper = (levels: number): string => `unsupported-nesting-depth: a carried node's JSON nests deeper than the ${levels} levels its position leaves` - assert.equal(content(losslessMarkdownToAdf(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting)) - assert.equal( - content(losslessMarkdownToAdf(fence('', largestNesting + 1))), - 'unsupported-node-shape: the opaque carry spells its node\'s JSON canonically: two-space indent, keys sorted; !adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block', - ) - assert.equal(content(losslessMarkdownToAdf(fence('> ', largestNesting + 1))), deeper(largestNesting - 1)) + const deeper = (levels: number): string[] => [`deeper than the ${levels} levels`] + refused(losslessMarkdownToAdf(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`), 'unsupported-nesting-depth', ...deeper(largestNesting)) + refused(losslessMarkdownToAdf(fence('', largestNesting + 1)), 'unsupported-node-shape', 'two-space indent', '!adf:codeBlock {language="adf:x"}') + refused(losslessMarkdownToAdf(fence('> ', largestNesting + 1)), 'unsupported-nesting-depth', ...deeper(largestNesting - 1)) }) test('names the number no JSON spelling carries in an opaque carry', () => { - const named = 'unsupported-node-shape: the opaque carry holds a number JSON cannot spell' - assert.equal(content(losslessMarkdownToAdf('!adf:carry{json="{\\"attrs\\":{\\"width\\":1e999},\\"type\\":\\"blockCard\\"}"}\n')), named) - assert.equal(content(losslessMarkdownToAdf('```adf:x\n1e999\n```\n')), named) + const named = ['a number JSON cannot spell'] + refused(losslessMarkdownToAdf('!adf:carry{json="{\\"attrs\\":{\\"width\\":1e999},\\"type\\":\\"blockCard\\"}"}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('```adf:x\n1e999\n```\n'), 'unsupported-node-shape', ...named) }) test('names the mark spelling no opaque carry sits inside', () => { - const named = 'unsupported-node-shape: move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks' - assert.equal(content(losslessMarkdownToAdf(`_a ${carried} b_\n`)), named) - assert.equal(content(losslessMarkdownToAdf(`**${carried}**\n`)), named) - assert.equal(content(losslessMarkdownToAdf(`~~a ${carried}~~\n`)), named) - assert.equal(content(losslessMarkdownToAdf(`[a ${carried} b](https://example.com/x)\n`)), named) - assert.equal(content(losslessMarkdownToAdf(`[*${carried}*](/w)\n`)), named) - assert.equal(content(losslessMarkdownToAdf(`!adf:underline[${carried}]\n`)), named) - assert.equal(content(losslessMarkdownToAdf(`!adf:textColor[a ${carried}]{color="#ae2e24"}\n`)), named) - assert.equal(content(losslessMarkdownToAdf(`![_a ${carried}_](https://example.com/i)\n`)), named) + const named = ['marks=empty', 'out of the mark spelling'] + refused(losslessMarkdownToAdf(`_a ${carried} b_\n`), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf(`**${carried}**\n`), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf(`~~a ${carried}~~\n`), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf(`[a ${carried} b](https://example.com/x)\n`), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf(`[*${carried}*](/w)\n`), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf(`!adf:underline[${carried}]\n`), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf(`!adf:textColor[a ${carried}]{color="#ae2e24"}\n`), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf(`![_a ${carried}_](https://example.com/i)\n`), 'unsupported-node-shape', ...named) }) test('keeps the carry a mark spelling does not wrap', () => { @@ -588,74 +556,73 @@ test('reads the reserved marks key as a JSON array where a mark is one the table }) test('names the marks key no marks read back from', () => { - const array = 'unsupported-node-shape: the marks attribute of rule is its marks array in canonical JSON: this one is not' - const named = 'unsupported-node-shape: the marks attribute of rule names its marks one space apart, each as its type and any {attrs}: this one does not' - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[]"}\n')), array) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[1]"}\n')), array) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="{}"}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="em strong"}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="em "}\n')), named) - const inBreakout = (cause: string): string => `unsupported-node-shape: the breakout mark in the marks attribute of rule: ${cause}` - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{width=1 mode=wide}"}\n')), inBreakout('the {attrs} keys read in alphabetical order: mode before width')) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode}"}\n')), inBreakout('an attribute reads key=value, the value bare or double-quoted: this one does not')) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode=wide\\"x\\"}"}\n')), inBreakout('an attribute reads key=value, the value bare or double-quoted: this one does not')) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode=}"}\n')), inBreakout('an attribute reads key=value, the value bare or double-quoted: this one does not')) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode=wide"}\n')), inBreakout('the {attrs} closing brace is missing')) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="em{attrs=empty}strong"}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="em{attrs=x}"}\n')), 'unsupported-node-shape: the reserved key attrs takes only the value empty: this one spells attrs=x') - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks=x}\n')), 'unsupported-node-shape: the marks attribute of rule names x, no mark the flavour lists: write the marks as their JSON array, marks="[{\\"type\\":\\"x\\"}]"') - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{width=wide}"}\n')), 'unsupported-node-shape: the width attribute of breakout is no number') - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{bogus=1}"}\n')), 'unsupported-node-shape: breakout holds no bogus attribute: this one spells it') + const array = ['marks', 'rule', 'canonical JSON'] + const named = ['marks', 'rule', 'one space apart'] + refused(losslessMarkdownToAdf('!adf:rule {marks="[]"}\n'), 'unsupported-node-shape', ...array) + refused(losslessMarkdownToAdf('!adf:rule {marks="[1]"}\n'), 'unsupported-node-shape', ...array) + refused(losslessMarkdownToAdf('!adf:rule {marks="{}"}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:rule {marks="em strong"}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:rule {marks="em "}\n'), 'unsupported-node-shape', ...named) + const inBreakout = ['breakout', 'marks', 'rule'] + refused(losslessMarkdownToAdf('!adf:rule {marks="breakout{width=1 mode=wide}"}\n'), 'unsupported-node-shape', ...inBreakout, 'mode before width') + refused(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode}"}\n'), 'unsupported-node-shape', ...inBreakout, 'key=value') + refused(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode=wide\\"x\\"}"}\n'), 'unsupported-node-shape', ...inBreakout, 'key=value') + refused(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode=}"}\n'), 'unsupported-node-shape', ...inBreakout, 'key=value') + refused(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode=wide"}\n'), 'unsupported-node-shape', ...inBreakout, 'closing brace') + refused(losslessMarkdownToAdf('!adf:rule {marks="em{attrs=empty}strong"}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:rule {marks="em{attrs=x}"}\n'), 'unsupported-node-shape', 'attrs', 'empty', 'attrs=x') + refused(losslessMarkdownToAdf('!adf:rule {marks=x}\n'), 'unsupported-node-shape', 'names x', 'marks="[{\\"type\\":\\"x\\"}]"') + refused(losslessMarkdownToAdf('!adf:rule {marks="breakout{width=wide}"}\n'), 'unsupported-node-shape', 'width', 'breakout', 'no number') + refused(losslessMarkdownToAdf('!adf:rule {marks="breakout{bogus=1}"}\n'), 'unsupported-node-shape', 'breakout', 'bogus') }) test('names the marks key spelled outside the canonical form', () => { - const spells = (spelling: string): string => `unsupported-node-shape: rule spells its marks attribute as marks=${spelling}` - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\"}]"}\n')), spells('em')) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), spells('"em{attrs=empty}"')) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\",\\"attrs\\":{}}]"}\n')), spells('"em{attrs=empty}"')) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="em{}"}\n')), spells('em')) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="em"}\n')), spells('em')) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode=\\"wide\\"}"}\n')), 'unsupported-node-shape: breakout spells its mode attribute as mode=wide') + const spells = (spelling: string): string[] => ['rule', `marks=${spelling}`] + refused(losslessMarkdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\"}]"}\n'), 'unsupported-node-shape', ...spells('em')) + refused(losslessMarkdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n'), 'unsupported-node-shape', ...spells('"em{attrs=empty}"')) + refused(losslessMarkdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\",\\"attrs\\":{}}]"}\n'), 'unsupported-node-shape', ...spells('"em{attrs=empty}"')) + refused(losslessMarkdownToAdf('!adf:rule {marks="em{}"}\n'), 'unsupported-node-shape', ...spells('em')) + refused(losslessMarkdownToAdf('!adf:rule {marks="em"}\n'), 'unsupported-node-shape', ...spells('em')) + refused(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode=\\"wide\\"}"}\n'), 'unsupported-node-shape', 'breakout', 'mode=wide') }) test('names the attribute a node holds no reading for', () => { - assert.equal(content(losslessMarkdownToAdf('!adf:rule {bogus=1}\n')), 'unsupported-node-shape: rule holds no bogus attribute: this one spells it') - assert.equal(content(losslessMarkdownToAdf('!adf:media {width=wide}\n')), 'unsupported-node-shape: the width attribute of media is no number') - assert.equal(content(losslessMarkdownToAdf('!adf:table {isNumberColumnEnabled=yes}\n!adf:/table\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean') - assert.equal(content(losslessMarkdownToAdf('!adf:media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number') - assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="340,"}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no comma list of numbers') - assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="[]"}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no comma list of numbers') - assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="[340,\\"420\\"]"}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no comma list of numbers') - assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth=""}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no comma list of numbers') - assert.equal(content(losslessMarkdownToAdf('!adf:panel info {panelType=note}\nx\n!adf:/panel\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument, never in {attrs}') - assert.equal(content(losslessMarkdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}') + refused(losslessMarkdownToAdf('!adf:rule {bogus=1}\n'), 'unsupported-node-shape', 'rule', 'bogus') + refused(losslessMarkdownToAdf('!adf:media {width=wide}\n'), 'unsupported-node-shape', 'width', 'media', 'no number') + refused(losslessMarkdownToAdf('!adf:table {isNumberColumnEnabled=yes}\n!adf:/table\n'), 'unsupported-node-shape', 'isNumberColumnEnabled', 'table', 'no boolean') + refused(losslessMarkdownToAdf('!adf:media {width=true}\n'), 'unsupported-node-shape', 'width', 'media', 'no number') + refused(losslessMarkdownToAdf('!adf:tableCell {colwidth="340,"}\n!adf:/tableCell\n'), 'unsupported-node-shape', 'colwidth', 'tableCell', 'comma list of numbers') + refused(losslessMarkdownToAdf('!adf:tableCell {colwidth="[]"}\n!adf:/tableCell\n'), 'unsupported-node-shape', 'colwidth', 'tableCell', 'comma list of numbers') + refused(losslessMarkdownToAdf('!adf:tableCell {colwidth="[340,\\"420\\"]"}\n!adf:/tableCell\n'), 'unsupported-node-shape', 'colwidth', 'tableCell', 'comma list of numbers') + refused(losslessMarkdownToAdf('!adf:tableCell {colwidth=""}\n!adf:/tableCell\n'), 'unsupported-node-shape', 'colwidth', 'tableCell', 'comma list of numbers') + refused(losslessMarkdownToAdf('!adf:panel info {panelType=note}\nx\n!adf:/panel\n'), 'unsupported-node-shape', 'panelType', 'directive argument') + refused(losslessMarkdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n'), 'unsupported-node-shape', 'mention', 'content slot') }) test('names the depth an attribute value nests past, never the kind the JSON reads as', () => { const nested = (levels: number): string => `${'['.repeat(levels)}1${']'.repeat(levels)}` - const deeper = (key: string, type: string): string => - `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` - assert.equal(content(losslessMarkdownToAdf(`!adf:extension {parameters="${nested(largestNesting + 1)}"}\n`)), deeper('parameters', 'extension')) - assert.equal(content(losslessMarkdownToAdf(`!adf:rule {marks="${nested(largestNesting + 1)}"}\n`)), deeper('marks', 'rule')) - assert.equal(content(losslessMarkdownToAdf(`!adf:rule {marks="[{\\"attrs\\":{\\"deep\\":${nested(largestNesting - 2)}},\\"type\\":\\"em\\"}]"}\n`)), deeper('marks', 'rule')) - assert.equal(content(losslessMarkdownToAdf(`!adf:media {width="${nested(largestNesting + 1)}"}\n`)), 'unsupported-node-shape: the width attribute of media is no number') + const deeper = (key: string, type: string): string[] => [`the ${key} attribute of ${type}`, `${largestNesting} levels`] + refused(losslessMarkdownToAdf(`!adf:extension {parameters="${nested(largestNesting + 1)}"}\n`), 'unsupported-nesting-depth', ...deeper('parameters', 'extension')) + refused(losslessMarkdownToAdf(`!adf:rule {marks="${nested(largestNesting + 1)}"}\n`), 'unsupported-nesting-depth', ...deeper('marks', 'rule')) + refused(losslessMarkdownToAdf(`!adf:rule {marks="[{\\"attrs\\":{\\"deep\\":${nested(largestNesting - 2)}},\\"type\\":\\"em\\"}]"}\n`), 'unsupported-nesting-depth', ...deeper('marks', 'rule')) + refused(losslessMarkdownToAdf(`!adf:media {width="${nested(largestNesting + 1)}"}\n`), 'unsupported-node-shape', 'width', 'media', 'no number') }) test('names the attribute value spelled outside the canonical form', () => { - assert.equal(content(losslessMarkdownToAdf('!adf:rule {localId="a-1"}\n')), 'unsupported-node-shape: rule spells its localId attribute as localId=a-1') - assert.equal(content(losslessMarkdownToAdf('!adf:media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20') - assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="340, 420"}\n!adf:/tableCell\n')), 'unsupported-node-shape: tableCell spells its colwidth attribute as colwidth="340,420"') - assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="340"}\n!adf:/tableCell\n')), 'unsupported-node-shape: tableCell spells its colwidth attribute as colwidth=340') - assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="[340,420]"}\n!adf:/tableCell\n')), 'unsupported-node-shape: tableCell spells its colwidth attribute as colwidth="340,420"') + refused(losslessMarkdownToAdf('!adf:rule {localId="a-1"}\n'), 'unsupported-node-shape', 'localId=a-1') + refused(losslessMarkdownToAdf('!adf:media {width="20.0"}\n'), 'unsupported-node-shape', 'width=20') + refused(losslessMarkdownToAdf('!adf:tableCell {colwidth="340, 420"}\n!adf:/tableCell\n'), 'unsupported-node-shape', 'colwidth="340,420"') + refused(losslessMarkdownToAdf('!adf:tableCell {colwidth="340"}\n!adf:/tableCell\n'), 'unsupported-node-shape', 'colwidth=340') + refused(losslessMarkdownToAdf('!adf:tableCell {colwidth="[340,420]"}\n!adf:/tableCell\n'), 'unsupported-node-shape', 'colwidth="340,420"') }) test('names the argument and the body a node takes no reading for', () => { - assert.equal(content(losslessMarkdownToAdf('!adf:rule x\n')), 'unsupported-node-shape: rule takes no argument: this one spells one') - assert.equal(content(losslessMarkdownToAdf('!adf:paragraph\nOne.\n\nTwo.\n!adf:/paragraph\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one') - assert.equal(content(losslessMarkdownToAdf('!adf:paragraph\n---\n!adf:/paragraph\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one') - assert.equal(content(losslessMarkdownToAdf('!adf:codeBlock {wrap=true}\nx\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes code blocks as its body: this body holds another block') - assert.equal(content(losslessMarkdownToAdf('!adf:codeBlock {wrap=true}\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes code blocks as its body: this body holds none') - assert.equal(content(losslessMarkdownToAdf('Part !adf:date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content: this one holds some') + refused(losslessMarkdownToAdf('!adf:rule x\n'), 'unsupported-node-shape', 'rule', 'no argument') + refused(losslessMarkdownToAdf('!adf:paragraph\nOne.\n\nTwo.\n!adf:/paragraph\n'), 'unsupported-node-shape', 'paragraph', 'one paragraph') + refused(losslessMarkdownToAdf('!adf:paragraph\n---\n!adf:/paragraph\n'), 'unsupported-node-shape', 'paragraph', 'one paragraph') + refused(losslessMarkdownToAdf('!adf:codeBlock {wrap=true}\nx\n!adf:/codeBlock\n'), 'unsupported-node-shape', 'codeBlock', 'code blocks', 'another block') + refused(losslessMarkdownToAdf('!adf:codeBlock {wrap=true}\n!adf:/codeBlock\n'), 'unsupported-node-shape', 'codeBlock', 'code blocks', 'holds none') + refused(losslessMarkdownToAdf('Part !adf:date[now]{timestamp=1}.\n'), 'unsupported-node-shape', 'date', 'no content') }) test('leaves the text that opens no directive the text it is', () => { @@ -666,15 +633,15 @@ test('leaves the text that opens no directive the text it is', () => { }) test('names the unescaped prefix that completes no directive', () => { - const named = 'malformed-directive: an unescaped !adf: completes no directive; \\!adf: keeps the prefix literal' - assert.equal(content(losslessMarkdownToAdf('Part !adf: here.\n')), named) - assert.equal(content(losslessMarkdownToAdf('Part !adf:Mention[@A] here.\n')), named) - assert.equal(content(losslessMarkdownToAdf('Part !adf:mention @A here.\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:underline[a !adf: b]\n')), named) + const named = ['no directive', '\\!adf:'] + refused(losslessMarkdownToAdf('Part !adf: here.\n'), 'malformed-directive', ...named) + refused(losslessMarkdownToAdf('Part !adf:Mention[@A] here.\n'), 'malformed-directive', ...named) + refused(losslessMarkdownToAdf('Part !adf:mention @A here.\n'), 'malformed-directive', ...named) + refused(losslessMarkdownToAdf('!adf:underline[a !adf: b]\n'), 'malformed-directive', ...named) }) test('names the inline directive left unclosed at the end of its line', () => { - assert.equal(content(losslessMarkdownToAdf('Part !adf:mention[@A\n')), 'malformed-directive: an inline directive [content] is unclosed; \\!adf: keeps the prefix literal') + refused(losslessMarkdownToAdf('Part !adf:mention[@A\n'), 'malformed-directive', '[content] is unclosed', '\\!adf:') assert.equal(code(losslessMarkdownToAdf('Part !adf:mention[@A]{id=\n')), 'malformed-directive') assert.deepEqual(path(losslessMarkdownToAdf('> Part !adf:mention[@A\n')), ['content', 0, 'content', 0]) }) @@ -942,22 +909,23 @@ test('gives an inline node no mark Atlassian\'s schema withholds from it', () => assert.deepEqual(read('!adf:underline[a !adf:emoji[🎉]{shortName=":tada:"}]\n'), [ { content: [marked('a ', underline), { attrs: { shortName: ':tada:', text: '🎉' }, type: 'emoji' }], type: 'paragraph' }, ]) - const unlinkable = 'unsupported-node-shape: the text of this link holds only nodes that take no link: write the link around text beside them' - assert.equal(read('[!adf:status[Done]{color=green}](/u)\n'), unlinkable) - assert.equal(read('[!adf:status[Done]{color=green}\\\n](/u)\n'), unlinkable) + const unlinkable = ['take no link', 'write the link around text beside them'] + refused(losslessMarkdownToAdf('[!adf:status[Done]{color=green}](/u)\n'), 'unsupported-node-shape', ...unlinkable) + refused(losslessMarkdownToAdf('[!adf:status[Done]{color=green}\\\n](/u)\n'), 'unsupported-node-shape', ...unlinkable) assert.deepEqual(read('[a !adf:inlineCard{url="https://x/"}](/u)\n'), [{ content: [marked('a ', link('/u')), { attrs: { url: 'https://x/' }, type: 'inlineCard' }], type: 'paragraph' }]) assert.deepEqual(read('_!adf:mediaInline{id=m1}_\n'), [{ content: [media], type: 'paragraph' }]) assert.deepEqual(read('!adf:border[!adf:mediaInline{id=m1}]{color="#091e42" size=2}\n'), [{ content: [{ ...media, marks: [border] }], type: 'paragraph' }]) assert.deepEqual(read('[!adf:mediaInline{id=m1}](/u)\n'), [{ content: [{ ...media, marks: [link('/u')] }], type: 'paragraph' }]) assert.deepEqual(read('!adf:border[a !adf:mediaInline{id=m1}]{color="#091e42" size=2}\n'), [{ content: [{ text: 'a ', type: 'text' }, { ...media, marks: [border] }], type: 'paragraph' }]) - const unborderable = 'unsupported-node-shape: the border mark wraps the [content] it marks: this one wraps only nodes that take no border mark: only a mediaInline takes border: delete the border spelling' - assert.equal(read('!adf:border[a]{color="#091e42" size=2}\n'), unborderable) - assert.equal(read('_!adf:border[a]{color="#091e42" size=2}_\n'), unborderable) - assert.equal(read('!adf:underline[!adf:border[a]{color="#091e42" size=2}]\n'), unborderable) - assert.equal(read('!adf:border[a!adf:textBreak{}b]{color="#091e42" size=2}\n'), unborderable) - assert.equal(read('!adf:underline[!adf:mention[@A]{id=b1c2}]\n'), 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps only nodes that take no underline mark: write the underline mark around text beside them') - assert.equal(read('!adf:underline[!adf:mediaInline{id=m1}]\n'), 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps only nodes that take no underline mark: write the underline mark around text beside them') - assert.equal(read('!adf:link[!adf:status[Done]{color=green}]{collection=c href="/u"}\n'), 'unsupported-node-shape: the link mark wraps the [content] it marks: this one wraps only nodes that take no link mark: write the link mark around text beside them') + const unborderable = ['take no border mark', 'only a mediaInline takes border', 'delete the border spelling'] + const wrapsOnly = (name: string): string[] => [`take no ${name} mark`, `write the ${name} mark around text beside them`] + refused(losslessMarkdownToAdf('!adf:border[a]{color="#091e42" size=2}\n'), 'unsupported-node-shape', ...unborderable) + refused(losslessMarkdownToAdf('_!adf:border[a]{color="#091e42" size=2}_\n'), 'unsupported-node-shape', ...unborderable) + refused(losslessMarkdownToAdf('!adf:underline[!adf:border[a]{color="#091e42" size=2}]\n'), 'unsupported-node-shape', ...unborderable) + refused(losslessMarkdownToAdf('!adf:border[a!adf:textBreak{}b]{color="#091e42" size=2}\n'), 'unsupported-node-shape', ...unborderable) + refused(losslessMarkdownToAdf('!adf:underline[!adf:mention[@A]{id=b1c2}]\n'), 'unsupported-node-shape', ...wrapsOnly('underline')) + refused(losslessMarkdownToAdf('!adf:underline[!adf:mediaInline{id=m1}]\n'), 'unsupported-node-shape', ...wrapsOnly('underline')) + refused(losslessMarkdownToAdf('!adf:link[!adf:status[Done]{color=green}]{collection=c href="/u"}\n'), 'unsupported-node-shape', ...wrapsOnly('link')) }) test('gives code text no mark but code, link and annotation', () => { @@ -969,7 +937,7 @@ test('gives code text no mark but code, link and annotation', () => { } assert.deepEqual(content(portableMarkdownToAdf('~~`npm ci`~~\n')), [{ content: [marked('npm ci', code)], type: 'paragraph' }]) assert.deepEqual(content(losslessMarkdownToAdf('!adf:underline[run `npm ci`]\n')), [{ content: [marked('run ', underline), marked('npm ci', code)], type: 'paragraph' }]) - assert.equal(content(losslessMarkdownToAdf('!adf:underline[`npm ci`]\n')), 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps only nodes that take no underline mark: write the underline mark around text beside them') + refused(losslessMarkdownToAdf('!adf:underline[`npm ci`]\n'), 'unsupported-node-shape', 'underline', 'write the underline mark around text beside them') }) test('decodes the fenced info string the block walk leaves raw', () => { @@ -980,14 +948,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(losslessMarkdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: ') - assert.equal(content(losslessMarkdownToAdf('Part
    here.\n')), 'unmappable-html: no raw HTML converts at this version:
    ') - assert.equal(content(losslessMarkdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML comment') - assert.equal(content(losslessMarkdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML processing instruction') - assert.equal(content(losslessMarkdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML declaration') - assert.equal(content(losslessMarkdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: a CDATA section') - assert.equal(content(losslessMarkdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML comment') - assert.equal(content(losslessMarkdownToAdf('Part here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML comment') + refused(losslessMarkdownToAdf('Part here.\n'), 'unmappable-html', '') + refused(losslessMarkdownToAdf('Part
    here.\n'), 'unmappable-html', '
    ') + refused(losslessMarkdownToAdf('Part here.\n'), 'unmappable-html', 'an HTML comment') + refused(losslessMarkdownToAdf('Part here.\n'), 'unmappable-html', 'an HTML processing instruction') + refused(losslessMarkdownToAdf('Part here.\n'), 'unmappable-html', 'an HTML declaration') + refused(losslessMarkdownToAdf('Part here.\n'), 'unmappable-html', 'a CDATA section') + refused(losslessMarkdownToAdf('Part here.\n'), 'unmappable-html', 'an HTML comment') + refused(losslessMarkdownToAdf('Part here.\n'), 'unmappable-html', 'an HTML comment') assert.equal(code(losslessMarkdownToAdf('A b\n')), 'unmappable-html') assert.equal(code(losslessMarkdownToAdf('Part.\n\n')), 'unmappable-html') assert.deepEqual(path(losslessMarkdownToAdf('Part.\n\nA b.\n')), ['content', 1]) @@ -1232,7 +1200,7 @@ test('reads an image inside a link not alone in its paragraph as plain alt text marked('image', link('/w')), text(')'), ]) - assert.equal(content(losslessMarkdownToAdf('!adf:link[x [![a](/u)](/v)]{href="a\\nb"}\n')), 'unsupported-node-shape: no link wraps a link: the [content] this one marks already holds one') + refused(losslessMarkdownToAdf('!adf:link[x [![a](/u)](/v)]{href="a\\nb"}\n'), 'unsupported-node-shape', 'no link wraps a link') }) test('writes no (image) after a link to the image itself, unless the image holds a title the link does not', () => { @@ -1268,28 +1236,28 @@ test('reads the content slot as the text attribute the node spells there', () => }) test('names the content slot no lone plain text node reads back from', () => { - const named = 'unsupported-node-shape: the status content slot holds one text node carrying neither marks, attributes nor content: this one holds something else' - assert.equal(content(losslessMarkdownToAdf('!adf:status[**A**]{color=yellow}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:status[!adf:carry{json="{\\"attrs\\":{\\"localId\\":\\"a\\"},\\"text\\":\\"A\\",\\"type\\":\\"text\\"}"}]{color=yellow}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:status[!adf:carry{json="{\\"content\\":[{\\"text\\":\\"B\\",\\"type\\":\\"text\\"}],\\"text\\":\\"A\\",\\"type\\":\\"text\\"}"}]{color=yellow}\n')), named) + const named = ['status content slot', 'one text node'] + refused(losslessMarkdownToAdf('!adf:status[**A**]{color=yellow}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:status[!adf:carry{json="{\\"attrs\\":{\\"localId\\":\\"a\\"},\\"text\\":\\"A\\",\\"type\\":\\"text\\"}"}]{color=yellow}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:status[!adf:carry{json="{\\"content\\":[{\\"text\\":\\"B\\",\\"type\\":\\"text\\"}],\\"text\\":\\"A\\",\\"type\\":\\"text\\"}"}]{color=yellow}\n'), 'unsupported-node-shape', ...named) assert.equal(code(losslessMarkdownToAdf('!adf:status[a`b`]{color=yellow}\n')), 'unsupported-node-shape') assert.equal(code(losslessMarkdownToAdf('!adf:status[!adf:date{timestamp=1}]{color=yellow}\n')), 'unsupported-node-shape') - assert.equal(content(losslessMarkdownToAdf('!adf:status[![a](/u)]{color=yellow}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:link[x !adf:status[![a](/u)]{color=yellow}]{href="a\\nb"}\n')), named) + refused(losslessMarkdownToAdf('!adf:status[![a](/u)]{color=yellow}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:link[x !adf:status[![a](/u)]{color=yellow}]{href="a\\nb"}\n'), 'unsupported-node-shape', ...named) assert.equal(code(losslessMarkdownToAdf('!adf:status[
    ]{color=yellow}\n')), 'unmappable-html') assert.equal(code(losslessMarkdownToAdf('!adf:date[
    ]{timestamp=1}\n')), 'unmappable-html') assert.equal(code(losslessMarkdownToAdf('!adf:widget[
    ]\n')), 'unmappable-html') - const spans = 'unsupported-node-shape: the status content slot holds a line ending, and an inline directive never spans lines: replace it with a space, or write the node as !adf:carry' - assert.equal(content(losslessMarkdownToAdf('!adf:status[!adf:text{text="\\n"}]{color=yellow}\n')), spans) - assert.equal(content(losslessMarkdownToAdf('!adf:status[a b]{color=yellow}\n')), spans) - assert.equal(content(losslessMarkdownToAdf('!adf:status[a b]{color=yellow}\n')), spans) - assert.equal(content(losslessMarkdownToAdf('!adf:status[!adf:text{text="\\u0000"}]{color=yellow}\n')), 'unsupported-node-shape: the status content slot holds a null character: write the node as !adf:carry') - assert.equal(content(losslessMarkdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}') + const spans = ['status content slot', 'line ending', 'replace it with a space', '!adf:carry'] + refused(losslessMarkdownToAdf('!adf:status[!adf:text{text="\\n"}]{color=yellow}\n'), 'unsupported-node-shape', ...spans) + refused(losslessMarkdownToAdf('!adf:status[a b]{color=yellow}\n'), 'unsupported-node-shape', ...spans) + refused(losslessMarkdownToAdf('!adf:status[a b]{color=yellow}\n'), 'unsupported-node-shape', ...spans) + refused(losslessMarkdownToAdf('!adf:status[!adf:text{text="\\u0000"}]{color=yellow}\n'), 'unsupported-node-shape', 'null character', '!adf:carry') + refused(losslessMarkdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n'), 'unsupported-node-shape', 'mention', 'content slot') }) test('reads the characters CommonMark rewrites from their spellings', () => { assert.deepEqual(content(losslessMarkdownToAdf('a b!adf:text{text="\\u0000\\u0000"}\n')), [paragraph('a\rb\u0000\u0000')]) - assert.equal(content(losslessMarkdownToAdf('!adf:text{text="\\u0000a"}\n')), 'unsupported-node-shape: text spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them') + refused(losslessMarkdownToAdf('!adf:text{text="\\u0000a"}\n'), 'unsupported-node-shape', 'one run of null characters') }) test('reads the whitespace the reserved text directive carries', () => { @@ -1300,14 +1268,14 @@ 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, one run of newlines, or one run of null characters: this one spells none of them' - assert.equal(content(losslessMarkdownToAdf('!adf:text{text=hi}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:text{text=" \\n"}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:text{text=""}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:text{}\n')), 'unsupported-node-shape: text holds one text attribute alone: this one does not') - assert.equal(content(losslessMarkdownToAdf('!adf:text{localId=a text=" "}\n')), 'unsupported-node-shape: text holds one text attribute alone: this one does not') - assert.equal(content(losslessMarkdownToAdf('!adf:text[a]{text=" "}\n')), 'unsupported-node-shape: text takes no content: this one holds some') - assert.equal(content(losslessMarkdownToAdf('!adf:text{text="\\u0020"}\n')), 'unsupported-node-shape: text spells its text attribute as text=" "') + const named = ['one run of spaces and tabs, one run of newlines, or one run of null characters'] + refused(losslessMarkdownToAdf('!adf:text{text=hi}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:text{text=" \\n"}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:text{text=""}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:text{}\n'), 'unsupported-node-shape', 'one text attribute') + refused(losslessMarkdownToAdf('!adf:text{localId=a text=" "}\n'), 'unsupported-node-shape', 'one text attribute') + refused(losslessMarkdownToAdf('!adf:text[a]{text=" "}\n'), 'unsupported-node-shape', 'text takes no content') + refused(losslessMarkdownToAdf('!adf:text{text="\\u0020"}\n'), 'unsupported-node-shape', 'text=" "') }) test('reads the directive marks, the nesting outermost first', () => { @@ -1324,24 +1292,24 @@ test('reads the directive marks, the nesting outermost first', () => { assert.deepEqual(content(losslessMarkdownToAdf('_!adf:underline[a!adf:date{timestamp=1}]_\n')), [ { content: [marked('a', em, underline), { attrs: { timestamp: '1' }, type: 'date' }], type: 'paragraph' }, ]) - assert.equal(content(losslessMarkdownToAdf('!adf:border[a]{color="#091e42" size=x}\n')), 'unsupported-node-shape: the size attribute of border is no number') + refused(losslessMarkdownToAdf('!adf:border[a]{color="#091e42" size=x}\n'), 'unsupported-node-shape', 'size', 'border', 'no number') }) test('names the mark markdown spells, never a directive', () => { - assert.equal(content(losslessMarkdownToAdf('!adf:em[a]\n')), 'unsupported-node-shape: em is spelled _x_, never as a directive') - assert.equal(content(losslessMarkdownToAdf('!adf:strong[a]\n')), 'unsupported-node-shape: strong is spelled **x**, never as a directive') - assert.equal(content(losslessMarkdownToAdf('!adf:strike[a]\n')), 'unsupported-node-shape: strike is spelled ~~x~~, never as a directive') - assert.equal(content(losslessMarkdownToAdf('!adf:code[a]\n')), 'unsupported-node-shape: code is spelled `x`, never as a directive') + refused(losslessMarkdownToAdf('!adf:em[a]\n'), 'unsupported-node-shape', '_x_') + refused(losslessMarkdownToAdf('!adf:strong[a]\n'), 'unsupported-node-shape', '**x**') + refused(losslessMarkdownToAdf('!adf:strike[a]\n'), 'unsupported-node-shape', '~~x~~') + refused(losslessMarkdownToAdf('!adf:code[a]\n'), 'unsupported-node-shape', '`x`') }) test('refuses the directive link CommonMark could spell, and reads the one it could not', () => { - const refused = 'unsupported-node-shape: link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or ' - assert.equal(content(losslessMarkdownToAdf('!adf:link[a]{href="/u"}\n')), refused) - assert.equal(content(losslessMarkdownToAdf('See !adf:link[a]{href="/u"}.\n')), refused) - assert.equal(content(losslessMarkdownToAdf('!adf:link[https://example.com/]{href="https://example.com/"}\n')), refused) - assert.equal(content(losslessMarkdownToAdf('# !adf:link[`]: a`]{href="/u"}\n')), refused) - assert.equal(content(losslessMarkdownToAdf('| !adf:link[`]: a`]{href="/u"} |\n| --- |\n')), refused) - assert.equal(content(losslessMarkdownToAdf('!adf:underline[!adf:link[a]{href="/u"}]\n')), refused) + const spellable = ['[text](url "title")', ''] + refused(losslessMarkdownToAdf('!adf:link[a]{href="/u"}\n'), 'unsupported-node-shape', ...spellable) + refused(losslessMarkdownToAdf('See !adf:link[a]{href="/u"}.\n'), 'unsupported-node-shape', ...spellable) + refused(losslessMarkdownToAdf('!adf:link[https://example.com/]{href="https://example.com/"}\n'), 'unsupported-node-shape', ...spellable) + refused(losslessMarkdownToAdf('# !adf:link[`]: a`]{href="/u"}\n'), 'unsupported-node-shape', ...spellable) + refused(losslessMarkdownToAdf('| !adf:link[`]: a`]{href="/u"} |\n| --- |\n'), 'unsupported-node-shape', ...spellable) + refused(losslessMarkdownToAdf('!adf:underline[!adf:link[a]{href="/u"}]\n'), 'unsupported-node-shape', ...spellable) assert.deepEqual(path(losslessMarkdownToAdf('Part.\n\nSee !adf:link[a]{href="/u"}.\n')), ['content', 1]) const opening: AdfNode = { marks: [{ attrs: { href: '/u' }, type: 'link' }, { type: 'code' }], text: ']: a', type: 'text' } assert.deepEqual(content(losslessMarkdownToAdf('!adf:link[`]: a`]{href="/u"}\n')), [{ content: [opening], type: 'paragraph' }]) @@ -1351,60 +1319,53 @@ test('refuses the directive link CommonMark could spell, and reads the one it co }) test('names the href the directive link spells no value for', () => { - const named = 'unsupported-node-shape: the link mark spells its href: this one spells none' - assert.equal(content(losslessMarkdownToAdf('!adf:link[a]\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:link[a]{title=t}\n')), named) - assert.equal(content(losslessMarkdownToAdf('See !adf:link[a]{id=01a032c3-7a90-70c9-88f6-c60f710eda07}.\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:link[]{collection=c}\n')), named) + const named = ['link mark spells its href'] + refused(losslessMarkdownToAdf('!adf:link[a]\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:link[a]{title=t}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('See !adf:link[a]{id=01a032c3-7a90-70c9-88f6-c60f710eda07}.\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:link[]{collection=c}\n'), 'unsupported-node-shape', ...named) }) test('names the link a directive link wraps, no link holding another', () => { - const named = 'unsupported-node-shape: no link wraps a link: the [content] this one marks already holds one' - assert.equal(content(losslessMarkdownToAdf('!adf:link[]{collection=c href="/u"}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:link[[a](/v)]{collection=c href="/u"}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:link[a b]{collection=c href="/u"}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:link[]{href="/u"}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:link[x !adf:underline[[![](/u)]()]]{href="a\\nb"}\n')), named) + const named = ['no link wraps a link'] + refused(losslessMarkdownToAdf('!adf:link[]{collection=c href="/u"}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:link[[a](/v)]{collection=c href="/u"}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:link[a b]{collection=c href="/u"}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:link[]{href="/u"}\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:link[x !adf:underline[[![](/u)]()]]{href="a\\nb"}\n'), 'unsupported-node-shape', ...named) }) test('names the directive mark left without the content it wraps', () => { - const named = 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps none' - assert.equal(content(losslessMarkdownToAdf('!adf:underline[]\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:underline{}\n')), named) - const breaks = (name: string): string => `unsupported-node-shape: the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark: write the ${name} mark around text beside them` - assert.equal(content(losslessMarkdownToAdf('!adf:underline[!adf:hardBreak{}]\n')), breaks('underline')) - assert.equal(content(losslessMarkdownToAdf('!adf:link[!adf:hardBreak{}]{href="/u" title=t}\n')), breaks('link')) + const named = ['underline', 'wraps none'] + refused(losslessMarkdownToAdf('!adf:underline[]\n'), 'unsupported-node-shape', ...named) + refused(losslessMarkdownToAdf('!adf:underline{}\n'), 'unsupported-node-shape', ...named) + const wrapsOnly = (name: string): string[] => [`take no ${name} mark`, `write the ${name} mark around text beside them`] + refused(losslessMarkdownToAdf('!adf:underline[!adf:hardBreak{}]\n'), 'unsupported-node-shape', ...wrapsOnly('underline')) + refused(losslessMarkdownToAdf('!adf:link[!adf:hardBreak{}]{href="/u" title=t}\n'), 'unsupported-node-shape', ...wrapsOnly('link')) }) test('reads the text break only between two text nodes CommonMark joins, building no node', () => { assert.deepEqual(content(losslessMarkdownToAdf('a!adf:textBreak{}b\n')), [{ content: [text('a'), text('b')], type: 'paragraph' }]) assert.deepEqual(content(losslessMarkdownToAdf('==a!adf:textBreak{}b==\n')), [{ content: [text('==a'), text('b==')], type: 'paragraph' }]) - const parts = 'unsupported-node-shape: delete !adf:textBreak{} here: it stands only between two runs of text with the same formatting, which would otherwise read as one' + const parts = ['delete !adf:textBreak{} here', 'two runs of text with the same formatting'] const carried = '!adf:carry{json="{\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}' for (const markdown of ['!adf:textBreak{}a\n', 'a!adf:textBreak{}\n', 'a!adf:textBreak{}!adf:textBreak{}b\n', '**a**!adf:textBreak{}b\n', '[!adf:textBreak{}](/u)\n', `a!adf:textBreak{}${carried}\n`]) { - assert.equal(content(losslessMarkdownToAdf(markdown)), parts, markdown) + refused(losslessMarkdownToAdf(markdown), 'unsupported-node-shape', ...parts) } - assert.equal(content(losslessMarkdownToAdf('!adf:status[a!adf:textBreak{}b]\n')), 'unsupported-node-shape: delete !adf:textBreak{} from this content slot: a slot holds one text node') - assert.equal(content(losslessMarkdownToAdf('![a!adf:textBreak{}b](/i)\n')), 'unsupported-node-shape: delete !adf:textBreak{} from this image description: the description reads as plain alt text') - assert.equal(content(losslessMarkdownToAdf('a!adf:textBreak{x=y}b\n')), 'unsupported-node-shape: textBreak spells the bare leaf form, !adf:textBreak{}: this one spells more') + refused(losslessMarkdownToAdf('!adf:status[a!adf:textBreak{}b]\n'), 'unsupported-node-shape', 'delete !adf:textBreak{}', 'content slot') + refused(losslessMarkdownToAdf('![a!adf:textBreak{}b](/i)\n'), 'unsupported-node-shape', 'delete !adf:textBreak{}', 'image description') + refused(losslessMarkdownToAdf('a!adf:textBreak{x=y}b\n'), 'unsupported-node-shape', '!adf:textBreak{}', 'spells more') assert.deepEqual(content(losslessMarkdownToAdf('!adf:carry{json="{\\"type\\":\\"textBreak\\"}"}\n')), [{ content: [{ type: 'textBreak' }], type: 'paragraph' }]) }) test('leads a refusal ordinary editing meets with the edit that fixes it', () => { - assert.equal( - content(losslessMarkdownToAdf('!adf:paragraph {content=empty}\nText.\n!adf:/paragraph\n')), - 'unsupported-node-shape: remove content=empty to give the paragraph a body: content=empty holds none, and this one holds one', - ) - assert.equal( - content(losslessMarkdownToAdf('!adf:codeBlock\n```\na\n```\n```\n```\n!adf:/codeBlock\n')), - 'unsupported-node-shape: delete the empty fence: a fence beside another holds code, and this one holds none', - ) - assert.equal( - content(losslessMarkdownToAdf('!adf:codeBlock {attrs=empty}\n```js\na\n```\n```js\nb\n```\n!adf:/codeBlock\n')), - "unsupported-node-shape: remove attrs=empty to give the codeBlock the fence's language: attrs=empty holds no language, and this fence names one", - ) - assert.equal( - content(losslessMarkdownToAdf('!adf:panel info {attrs=empty}\nText.\n!adf:/panel\n')), - 'unsupported-node-shape: panel spells attrs=empty beside another attribute, an argument or a [content] slot: drop attrs=empty, or the rest', - ) + const leads = (markdown: string, edit: string): void => { + const result = losslessMarkdownToAdf(markdown) + assert.equal(code(result), 'unsupported-node-shape') + assert.ok(!result.ok && result.error.message.startsWith(edit), edit) + } + leads('!adf:paragraph {content=empty}\nText.\n!adf:/paragraph\n', 'remove content=empty') + leads('!adf:codeBlock\n```\na\n```\n```\n```\n!adf:/codeBlock\n', 'delete the empty fence') + leads('!adf:codeBlock {attrs=empty}\n```js\na\n```\n```js\nb\n```\n!adf:/codeBlock\n', 'remove attrs=empty') + refused(losslessMarkdownToAdf('!adf:panel info {attrs=empty}\nText.\n!adf:/panel\n'), 'unsupported-node-shape', 'drop attrs=empty') }) diff --git a/todo.md b/todo.md index eaa7d45..97817ba 100644 --- a/todo.md +++ b/todo.md @@ -12,7 +12,6 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 171 | 0.4.0 | | principle | **Assert the facts an error message must carry, never its whole sentence, in `markdown-to-adf.test.ts`, `document.test.ts` and `html-to-adf.test.ts`.** | 3 | 1 | 1 | 3 | 6 | code health by 3 | 4.4 | | 173 | 0.4.0 | | principle | **Move each CommonMark spelling's description out of `commonMarkSpellings`, beside the emitter's spelling it describes.** | 2 | 1 | 1 | 2 | 4 | code health by 1 | 4.4 | | 147 | 0.4.0 | | | **Ship `adfToPlainText`, writing a document's content with no markup, under a README goal for plain text.** | 2 | 2 | 1 | 3 | 4 | 3 by 4 | 4.2 | | 141 | 0.4.0 | | decision | **Write `strike` as HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element.** | 1 | 1 | 1 | 1 | 2 | 6 by 2 | 4.0 | @@ -50,17 +49,6 @@ ## Details -### 171. Assert the facts an error message must carry, never its whole sentence, in `markdown-to-adf.test.ts`, `document.test.ts` and `html-to-adf.test.ts`. - -README → Audience says no persona may rely on a message's wording, yet `content()` in -`src/markdown/parse/markdown-to-adf.test.ts` folds a failure into `${code}: ${message}`, about 103 -assertions compare that whole sentence, and `document.test.ts` (7) and `html-to-adf.test.ts` (3) -pin `message:` the same way. A rewording fails tests with no behaviour changed, while the facts -§`message` and `path` requires (the violation, the escape named) are never asserted on their own. -Assert `code`, `path` and `position` exactly, and match `message` against the facts it must carry. -Technical principle: "Pin wording only where the wording is the contract". Found by the principles -audit of the release review of 0.3.0, 2026-11-08. - ### 173. Move each CommonMark spelling's description out of `commonMarkSpellings`, beside the emitter's spelling it describes. `commonMarkSpellings` (`src/markdown/parse/markdown-to-adf.ts`) restates in prose the spellings