3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Assert the facts a refusal's message must carry, never its whole sentence

This commit is contained in:
2026-11-10 22:51:37 +01:00
parent cc6213467b
commit da561bcf70
5 changed files with 422 additions and 459 deletions
+17
View File
@@ -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
+84 -89
View File
@@ -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<unknown>, 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<unknown> {
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<string, unknown> = {}
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][] = [
+56 -54
View File
@@ -41,8 +41,10 @@ function refusal(result: Result<AdfDocument, ParseError>): 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<AdfDocument, ParseError>): 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<AdfDocument, ParseError>, ...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 [`<p>${carry('span', card, '')}</p>\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(`<p${' a=""'.repeat(3_000_000)}>x</p>\n`)), 'unmappable-html at /content/0 line 1 offset 0')
})
const messages: readonly (readonly [string, string])[] = [
['<p>a<b>b</b></p>\n', '<b> as written here is no inline element of the dialect: found "<b>b</b></p>\\n"'],
['<p>a > b</p>\n', 'text escapes & as &amp;, < as &lt; and > as &gt;, and holds no character HTML cannot hold: found "> b</p>\\n"'],
['<p><a href="a"b">a</a></p>\n', '<a> holds its attributes as name="value", each value escaping & as &amp; and " as &quot;, then >: found "b\\">a</a></p>\\n"'],
['<p>&b</p>\n', 'text escapes & as &amp;, < as &lt; and > as &gt;, and holds no character HTML cannot hold: found "&b</p>\\n"'],
['<pre><code>a&b</code></pre>\n', 'text escapes & as &amp;, < as &lt; and > as &gt;, and holds no character HTML cannot hold: found "&b</code></pre>\\n"'],
['<p>a&nbsp</p>\n', 'text escapes & as &amp;, < as &lt; and > as &gt;, and holds no character HTML cannot hold: found "&nbsp</p>\\n"'],
['<pre><code>a</code></pre\n', 'a code block\'s text ends at </code></pre>: found "</code></pre\\n"'],
['<pre><code class="hljs language-js">x</code></pre>\n', '<code> as written here is no code element of the dialect, which writes <code> or <code class="language-…">: found "<code class=\\"hljs langua"…'],
['<ul>\n<li>a</ul>\n', 'the line ends at </li>, or at a line feed before the next block, and its text spells < as &lt;: found "</ul>\\n"'],
['<ul>\n<li>a<b>b</b></li>\n</ul>\n', '<b> as written here is no inline element of the dialect: found "<b>b</b></li>\\n</ul>\\n"'],
['<ul>\n<li>if a < b</li>\n</ul>\n', 'the line ends at </li>, or at a line feed before the next block, and its text spells < as &lt;: found "< b</li>\\n</ul>\\n"'],
['<ul>\n<li>see <a href="https://x.test/?a=1&b=2">x</a></li>\n</ul>\n', '<a> holds its attributes as name="value", each value escaping & as &amp; and " as &quot;, then >: found "&b=2\\">x</a></li>\\n</ul>\\n"'],
['<ul>\n<li><p>a</p><strong>x</strong></li>\n</ul>\n', 'the line ends at </li>, or at a line feed before the next block: found "<strong>x</strong></li>\\n"…'],
['<ul>\n<li>Bolt<ul>\n<li>a</li>\n</ul></li>\n</ul>\n', 'the line ends at </li>, or at a line feed before the next block: found "<ul>\\n<li>a</li>\\n</ul></l"…'],
['<ul>\n<li>a<li>b</li>\n</ul>\n', 'the line ends at </li>, or at a line feed before the next block: found "<li>b</li>\\n</ul>\\n"'],
['<ul>\n<li>a\n<li>b</li>\n</ul>\n', 'the line ends at </li>, or at a line feed before the next block: found "<li>b</li>\\n</ul>\\n"'],
['<p><strong><b>x</b></strong></p>\n', '<b> as written here is no mark element of the dialect, and a mark element holds text, or one other mark element around text: found "<b>x</b></strong></p>\\n"'],
['<p><strong><br>x</strong></p>\n', '<br> as written here is no mark element of the dialect, and a mark element holds text, or one other mark element around text: found "<br>x</strong></p>\\n"'],
['<p><em></em></p>\n', 'a mark element holds text, or one other mark element around text: found "</em></p>\\n"'],
['<p><strong><a href="https://x.test/?a=1&b=2">x</a></strong></p>\n', '<a> holds its attributes as name="value", each value escaping & as &amp; and " as &quot;, then >: found "&b=2\\">x</a></strong></p>"…'],
[`<p>a</p>\n${carry('div', panel, '<p>Check the <b>collation</b>.</p>')}\n`, 'a carry element shows what adfToHtml writes for its node, as it or a browser spells it, then </div>; to change the node, edit its ADF and write the HTML again: found "<b>collation</b>.</p></d"…'],
['<p><span>a</span></p>\n', 'a <span> 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 "<span>a</span></p>\\n"'],
['<div class="adf-carry" data-adf="{ &quot;type&quot;:&quot;x&quot;}"></div>\n', 'a carry element spells its node\'s JSON canonically: compact, keys sorted, each character HTML cannot hold spelled as a JSON escape: found "{ &quot;type&quot;:&quot"…'],
[`${documentCarry}\n<hr>\n`, 'a carry element holding the document stands alone, as the whole document: found "\\n<hr>\\n"'],
[`<hr>\n${documentCarry}\n`, 'a carry element holding the document stands alone, as the whole document: found "<div class=\\"adf-carry\\" d"…'],
['<ul>\n<li><p>Bolt</p></li>\n</ul>\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 <li>Bolt</li>: found "<p>Bolt</p></li>\\n</ul>\\n"'],
['<ul>\n<li><p></p></li>\n</ul>\n', 'an item\'s or cell\'s first paragraph holding neither content nor an attribute stands on a line of its own: found "<p></p></li>\\n</ul>\\n"'],
['<ul>\n<li>\n<p data-local-id="x"></p>\n</li>\n</ul>\n', 'an item\'s or cell\'s first paragraph holding an attribute stands as its <p> straight after the item\'s or cell\'s start tag: found "<p data-local-id=\\"x\\"></p"…'],
['<p><img src="a">b</p>\n', 'an image stands alone in its paragraph as <img src="…" alt="…">, alt left out where it holds none, inside <a> where a link marks it: found "b</p>\\n"'],
['<p><img src="a" alt=""></p>\n', 'an image stands alone in its paragraph as <img src="…" alt="…">, alt left out where it holds none, inside <a> where a link marks it: found "<img src=\\"a\\" alt=\\"\\"></p>"…'],
['<ol start="03">\n<li>a</li>\n</ol>\n', 'an ordered list starts at an integer, written as JavaScript writes it: found "03\\">\\n<li>a</li>\\n</ol>\\n"'],
['<p class="x">a</p>\n', '<p> takes no class attribute'],
['<p data-color="x">a</p>\n', '<p> takes no data-color attribute'],
['<ol data-local-id="x" start="2">\n<li>a</li>\n</ol>\n', '<ol> takes start once, before its data-* attributes: found start after data-local-id'],
['<ol start="2" start="3">\n<li>a</li>\n</ol>\n', '<ol> takes start once, before its data-* attributes: found start after start'],
['<ol start="2" data-bogus="1" start="3">\n<li>a</li>\n</ol>\n', '<ol> takes no data-bogus attribute'],
['<pre data-wrap="yes"><code>x</code></pre>\n', 'data-wrap holds no boolean written as compact JSON'],
['<p><span class="adf-emoji"></span></p>\n', '<span class="adf-emoji"> holds its text, which is not empty: found "</span></p>\\n"'],
['<p><a class="adf-inline-card" href="https://example.com/">Spec</a></p>\n', '<a class="adf-inline-card"> 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</a></p>\\n"'],
[`<p><a class="adf-inline-card" href="https://example.org/plan" data-data="${planData}">Plan</a></p>\n`, '<a class="adf-inline-card"> 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</a></p>\\n"'],
['<p><time class="adf-date" datetime="2025-08-26" data-timestamp="1756080000000">x</time></p>\n', '<time class="adf-date"> shows the day its data-timestamp names, in datetime and as its text; to change the date, change all three: found "x</time></p>\\n"'],
['<p><span class="adf-emoji" data-size="big">a</span></p>\n', '<span class="adf-emoji"> takes no data-size attribute'],
['<p><span class="adf-status">a&b</span></p>\n', 'text escapes & as &amp;, < as &lt; and > as &gt;, and holds no character HTML cannot hold: found "&b</span></p>\\n"'],
const messages: readonly (readonly [string, readonly string[]])[] = [
['<p>a<b>b</b></p>\n', ['<b>', 'found "<b>b</b></p>\\n"']],
['<p>a > b</p>\n', ['& as &amp;', '< as &lt;', 'found "> b</p>\\n"']],
['<p><a href="a"b">a</a></p>\n', ['<a>', '& as &amp;', '" as &quot;', 'found "b\\">a</a></p>\\n"']],
['<p>&b</p>\n', ['& as &amp;', '< as &lt;', 'found "&b</p>\\n"']],
['<pre><code>a&b</code></pre>\n', ['& as &amp;', '< as &lt;', 'found "&b</code></pre>\\n"']],
['<p>a&nbsp</p>\n', ['& as &amp;', '< as &lt;', 'found "&nbsp</p>\\n"']],
['<pre><code>a</code></pre\n', ['found "</code></pre\\n"']],
['<pre><code class="hljs language-js">x</code></pre>\n', ['<code>', 'found "<code class=\\"hljs langua"…']],
['<ul>\n<li>a</ul>\n', ['< as &lt;', 'found "</ul>\\n"']],
['<ul>\n<li>a<b>b</b></li>\n</ul>\n', ['<b>', 'found "<b>b</b></li>\\n</ul>\\n"']],
['<ul>\n<li>if a < b</li>\n</ul>\n', ['< as &lt;', 'found "< b</li>\\n</ul>\\n"']],
['<ul>\n<li>see <a href="https://x.test/?a=1&b=2">x</a></li>\n</ul>\n', ['<a>', '& as &amp;', '" as &quot;', 'found "&b=2\\">x</a></li>\\n</ul>\\n"']],
['<ul>\n<li><p>a</p><strong>x</strong></li>\n</ul>\n', ['found "<strong>x</strong></li>\\n"…']],
['<ul>\n<li>Bolt<ul>\n<li>a</li>\n</ul></li>\n</ul>\n', ['found "<ul>\\n<li>a</li>\\n</ul></l"…']],
['<ul>\n<li>a<li>b</li>\n</ul>\n', ['found "<li>b</li>\\n</ul>\\n"']],
['<ul>\n<li>a\n<li>b</li>\n</ul>\n', ['found "<li>b</li>\\n</ul>\\n"']],
['<p><strong><b>x</b></strong></p>\n', ['<b>', 'found "<b>x</b></strong></p>\\n"']],
['<p><strong><br>x</strong></p>\n', ['<br>', 'found "<br>x</strong></p>\\n"']],
['<p><em></em></p>\n', ['found "</em></p>\\n"']],
['<p><strong><a href="https://x.test/?a=1&b=2">x</a></strong></p>\n', ['<a>', '& as &amp;', '" as &quot;', 'found "&b=2\\">x</a></strong></p>"…']],
[`<p>a</p>\n${carry('div', panel, '<p>Check the <b>collation</b>.</p>')}\n`, ['to change the node, edit its ADF and write the HTML again', 'found "<b>collation</b>.</p></d"…']],
['<p><span>a</span></p>\n', ['found "<span>a</span></p>\\n"']],
['<div class="adf-carry" data-adf="{ &quot;type&quot;:&quot;x&quot;}"></div>\n', ['found "{ &quot;type&quot;:&quot"…']],
[`${documentCarry}\n<hr>\n`, ['found "\\n<hr>\\n"']],
[`<hr>\n${documentCarry}\n`, ['found "<div class=\\"adf-carry\\" d"…']],
['<ul>\n<li><p>Bolt</p></li>\n</ul>\n', ['found "<p>Bolt</p></li>\\n</ul>\\n"']],
['<ul>\n<li><p></p></li>\n</ul>\n', ['found "<p></p></li>\\n</ul>\\n"']],
['<ul>\n<li>\n<p data-local-id="x"></p>\n</li>\n</ul>\n', ['found "<p data-local-id=\\"x\\"></p"…']],
['<p><img src="a">b</p>\n', ['found "b</p>\\n"']],
['<p><img src="a" alt=""></p>\n', ['found "<img src=\\"a\\" alt=\\"\\"></p>"…']],
['<ol start="03">\n<li>a</li>\n</ol>\n', ['found "03\\">\\n<li>a</li>\\n</ol>\\n"']],
['<p class="x">a</p>\n', ['<p>', 'takes no class attribute']],
['<p data-color="x">a</p>\n', ['<p>', 'takes no data-color attribute']],
['<ol data-local-id="x" start="2">\n<li>a</li>\n</ol>\n', ['<ol>', 'found start after data-local-id']],
['<ol start="2" start="3">\n<li>a</li>\n</ol>\n', ['<ol>', 'found start after start']],
['<ol start="2" data-bogus="1" start="3">\n<li>a</li>\n</ol>\n', ['<ol>', 'takes no data-bogus attribute']],
['<pre data-wrap="yes"><code>x</code></pre>\n', ['data-wrap', 'boolean']],
['<p><span class="adf-emoji"></span></p>\n', ['<span class="adf-emoji">', 'found "</span></p>\\n"']],
['<p><a class="adf-inline-card" href="https://example.com/">Spec</a></p>\n', ['<a class="adf-inline-card">', 'to change the card, change them together', 'found "Spec</a></p>\\n"']],
[`<p><a class="adf-inline-card" href="https://example.org/plan" data-data="${planData}">Plan</a></p>\n`, ['<a class="adf-inline-card">', 'to change the card, change them together', 'found "Plan</a></p>\\n"']],
['<p><time class="adf-date" datetime="2025-08-26" data-timestamp="1756080000000">x</time></p>\n', ['<time class="adf-date">', 'to change the date, change all three', 'found "x</time></p>\\n"']],
['<p><span class="adf-emoji" data-size="big">a</span></p>\n', ['<span class="adf-emoji">', 'takes no data-size attribute']],
['<p><span class="adf-status">a&b</span></p>\n', ['& as &amp;', '< as &lt;', 'found "&b</span></p>\\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 => `${'<blockquote>\n'.repeat(levels)}<p>x</p>${'\n</blockquote>'.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 => `${'<ul>\n<li>x\n'.repeat(levels)}<hr>${'\n</li>\n</ul>'.repeat(levels)}\n`
assert.equal(refusal(htmlToAdf(lists(500))), 'read')
assert.equal(refusal(htmlToAdf(lists(501))).startsWith('unsupported-nesting-depth'), true)
+265 -304
View File
@@ -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<AdfDocument, ParseError>): 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<AdfDocument>, 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(`[*<http://x/>${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(`[*<http://x/>${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 <span> here.\n')), 'unmappable-html: no raw HTML converts at this version: <span>')
assert.equal(content(losslessMarkdownToAdf('Part </div> here.\n')), 'unmappable-html: no raw HTML converts at this version: <div>')
assert.equal(content(losslessMarkdownToAdf('Part <!-- note --> here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML comment')
assert.equal(content(losslessMarkdownToAdf('Part <?php ?> here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML processing instruction')
assert.equal(content(losslessMarkdownToAdf('Part <!DOCTYPE html> here.\n')), 'unmappable-html: no raw HTML converts at this version: an HTML declaration')
assert.equal(content(losslessMarkdownToAdf('Part <![CDATA[x]]> 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 <span> here.\n'), 'unmappable-html', '<span>')
refused(losslessMarkdownToAdf('Part </div> here.\n'), 'unmappable-html', '<div>')
refused(losslessMarkdownToAdf('Part <!-- note --> here.\n'), 'unmappable-html', 'an HTML comment')
refused(losslessMarkdownToAdf('Part <?php ?> here.\n'), 'unmappable-html', 'an HTML processing instruction')
refused(losslessMarkdownToAdf('Part <!DOCTYPE html> here.\n'), 'unmappable-html', 'an HTML declaration')
refused(losslessMarkdownToAdf('Part <![CDATA[x]]> 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 <a href="/x" disabled\nid=y> b\n')), 'unmappable-html')
assert.equal(code(losslessMarkdownToAdf('Part.\n<span>\n')), 'unmappable-html')
assert.deepEqual(path(losslessMarkdownToAdf('Part.\n\nA <b>b</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[<div>]{color=yellow}\n')), 'unmappable-html')
assert.equal(code(losslessMarkdownToAdf('!adf:date[<div>]{timestamp=1}\n')), 'unmappable-html')
assert.equal(code(losslessMarkdownToAdf('!adf:widget[<div>]\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&#10;b]{color=yellow}\n')), spans)
assert.equal(content(losslessMarkdownToAdf('!adf:status[a&#13;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&#10;b]{color=yellow}\n'), 'unsupported-node-shape', ...spans)
refused(losslessMarkdownToAdf('!adf:status[a&#13;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&#13;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 <url>'
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")', '<url>']
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[<http://x/>]{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[<http://x/>]{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[<http://x/>]{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 <http://x/> b]{collection=c href="/u"}\n')), named)
assert.equal(content(losslessMarkdownToAdf('!adf:link[<http://x/>]{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[<http://x/>]{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 <http://x/> b]{collection=c href="/u"}\n'), 'unsupported-node-shape', ...named)
refused(losslessMarkdownToAdf('!adf:link[<http://x/>]{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')
})
-12
View File
@@ -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