35b - the writer spells the plain flavour #146
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
|
|||||||
import fc from 'fast-check'
|
import fc from 'fast-check'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { AdfNode } from '../adf/document.ts'
|
||||||
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
|
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
|
||||||
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||||
import { adfToPlainMarkdown } from '../markdown/emit/plain-reduction.ts'
|
import { adfToPlainMarkdown, reduceToPlain } from '../markdown/emit/plain-reduction.ts'
|
||||||
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
||||||
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||||
import { toEditorNormal } from '../adf/editor-normal.ts'
|
import { toEditorNormal } from '../adf/editor-normal.ts'
|
||||||
@@ -19,6 +20,20 @@ function readsNoDirective(markdown: string): boolean {
|
|||||||
return read.ok && renamed.ok && JSON.stringify(read.value).replaceAll(directivePrefix, renamedPrefix) === JSON.stringify(renamed.value)
|
return read.ok && renamed.ok && JSON.stringify(read.value).replaceAll(directivePrefix, renamedPrefix) === JSON.stringify(renamed.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each block's text, an expand's title and an image's alt and url, in document order: what the plain pair keeps.
|
||||||
|
function shownText(nodes: readonly AdfNode[]): string[] {
|
||||||
|
const shown: string[] = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
const attrs = node.attrs ?? {}
|
||||||
|
if ((node.type === 'expand' || node.type === 'nestedExpand') && typeof attrs['title'] === 'string') shown.push(attrs['title'])
|
||||||
|
if (node.type === 'media') shown.push(`${JSON.stringify(attrs['alt'] ?? '')} ${JSON.stringify(attrs['url'])}`)
|
||||||
|
const content = node.content ?? []
|
||||||
|
if (content.some((child) => child.type === 'text' || child.type === 'hardBreak')) shown.push(content.map((child) => child.text ?? '\n').join(''))
|
||||||
|
else for (const text of shownText(content)) shown.push(text)
|
||||||
|
}
|
||||||
|
return shown.filter((text) => text !== '')
|
||||||
|
}
|
||||||
|
|
||||||
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
|
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
|
||||||
fc.assert(
|
fc.assert(
|
||||||
fc.property(adfDocument, (document) => {
|
fc.property(adfDocument, (document) => {
|
||||||
@@ -32,7 +47,7 @@ test('a generated document refuses to emit, or its markdown reads back to it', {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a generated document writes plain markdown refusing only what the guard refuses, and that markdown reads back to itself', { timeout: propertyTimeout }, () => {
|
test('a generated document writes plain markdown refusing only what the guard refuses, and that markdown reads back to its text and to itself', { timeout: propertyTimeout }, () => {
|
||||||
fc.assert(
|
fc.assert(
|
||||||
fc.property(adfDocument, (document) => {
|
fc.property(adfDocument, (document) => {
|
||||||
const written = adfToPlainMarkdown(document)
|
const written = adfToPlainMarkdown(document)
|
||||||
@@ -40,6 +55,8 @@ test('a generated document writes plain markdown refusing only what the guard re
|
|||||||
assert.ok(readsNoDirective(written.value), `a directive in ${JSON.stringify(written.value)}`)
|
assert.ok(readsNoDirective(written.value), `a directive in ${JSON.stringify(written.value)}`)
|
||||||
const read = plainMarkdownToAdf(written.value)
|
const read = plainMarkdownToAdf(written.value)
|
||||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(written.value)}`)
|
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(written.value)}`)
|
||||||
|
const reduced = reduceToPlain(document)
|
||||||
|
assert.deepEqual(shownText(read.value.content ?? []), reduced.ok ? shownText(reduced.value.content ?? []) : reduced, `reading ${JSON.stringify(written.value)}`)
|
||||||
assert.deepEqual(adfToPlainMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`)
|
assert.deepEqual(adfToPlainMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`)
|
||||||
}),
|
}),
|
||||||
propertyRuns(gateRuns),
|
propertyRuns(gateRuns),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const propertyTimeout = 600000
|
|||||||
const depthIdentifier = fc.createDepthIdentifier()
|
const depthIdentifier = fc.createDepthIdentifier()
|
||||||
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
|
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
|
||||||
const flatCommonMarkShapeWeight = 4
|
const flatCommonMarkShapeWeight = 4
|
||||||
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
|
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
|
||||||
const nestingCommonMarkShapeWeight = 21
|
const nestingCommonMarkShapeWeight = 21
|
||||||
const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)])
|
const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)])
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }):
|
|||||||
|
|
||||||
const mark: Arbitrary<AdfMark> = fc.oneof(
|
const mark: Arbitrary<AdfMark> = fc.oneof(
|
||||||
{ arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 },
|
{ arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 },
|
||||||
|
{ arbitrary: attributes({ color: 'string' }).map((attrs) => ({ attrs, type: 'backgroundColor' })), weight: 2 },
|
||||||
{ arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 },
|
{ arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 },
|
||||||
)
|
)
|
||||||
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
|
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
|
||||||
@@ -135,8 +136,12 @@ const positions = fc.letrec<Positions>((tie) => {
|
|||||||
paragraph,
|
paragraph,
|
||||||
fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable),
|
fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable),
|
||||||
]
|
]
|
||||||
|
const task = (type: string, content: Arbitrary<AdfNode[]>) =>
|
||||||
|
fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type }))
|
||||||
|
const taskItem = fc.oneof({ arbitrary: task('taskItem', inlineContent), weight: 3 }, { arbitrary: task('blockTaskItem', blockContent), weight: 1 })
|
||||||
const nestingCommonMarkShapes = [
|
const nestingCommonMarkShapes = [
|
||||||
blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })),
|
blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })),
|
||||||
|
fc.array(fc.oneof({ arbitrary: taskItem, weight: 3 }, { arbitrary: tie('block'), weight: 1 }), { depthIdentifier, maxLength: 3, minLength: 1 }).map((content): AdfNode => ({ content, type: 'taskList' })),
|
||||||
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
|
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
|
||||||
fc
|
fc
|
||||||
.record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) })
|
.record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) })
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import assert from 'node:assert/strict'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import { adfToMarkdown } from './adf-to-markdown.ts'
|
import { adfToPlainMarkdown, reduceToPlain } from './plain-reduction.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { reduceToPlain } from './plain-reduction.ts'
|
|
||||||
|
|
||||||
const code: AdfMark = { type: 'code' }
|
const code: AdfMark = { type: 'code' }
|
||||||
const em: AdfMark = { type: 'em' }
|
const em: AdfMark = { type: 'em' }
|
||||||
@@ -19,11 +18,8 @@ function plain(...content: AdfNode[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function plainDocument(input: AdfDocument): string {
|
function plainDocument(input: AdfDocument): string {
|
||||||
const reduced = reduceToPlain(input)
|
const markdown = adfToPlainMarkdown(input)
|
||||||
if (!reduced.ok) return `${reduced.error.code} at /${reduced.error.path.join('/')}`
|
return markdown.ok ? markdown.value : `${markdown.error.code} at /${markdown.error.path.join('/')}`
|
||||||
const markdown = adfToMarkdown(reduced.value)
|
|
||||||
if (!markdown.ok) return `emit ${markdown.error.code}: ${markdown.error.message}`
|
|
||||||
return markdown.value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function text(value: string, ...marks: AdfMark[]): AdfNode {
|
function text(value: string, ...marks: AdfMark[]): AdfNode {
|
||||||
@@ -119,6 +115,7 @@ test('spells an expand and a nested expand as a folded callout titled by the mar
|
|||||||
)
|
)
|
||||||
assert.equal(plain(node('expand', {}, said('Line.'))), '> [!NOTE]-\n>\n> Line.\n')
|
assert.equal(plain(node('expand', {}, said('Line.'))), '> [!NOTE]-\n>\n> Line.\n')
|
||||||
assert.equal(plain(node('expand', { title: ' *Two*\nlines ' })), '> [!NOTE]- \\*Two\\* lines\n')
|
assert.equal(plain(node('expand', { title: ' *Two*\nlines ' })), '> [!NOTE]- \\*Two\\* lines\n')
|
||||||
|
assert.equal(plain(node('expand', { title: '**x** [y](z) ==w==' }, said('b'))), '> [!NOTE]- \\*\\*x\\*\\* \\[y](z) ==w==\n>\n> b\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('spells a task list as a bullet list whose items lead with their state', () => {
|
test('spells a task list as a bullet list whose items lead with their state', () => {
|
||||||
@@ -142,6 +139,18 @@ test('spells a highlight as a == pair around the run, whatever its colour', () =
|
|||||||
assert.equal(plain(paragraph(text('hi ', strong, highlight('#fff')), text('b'))), '**==hi==** b\n')
|
assert.equal(plain(paragraph(text('hi ', strong, highlight('#fff')), text('b'))), '**==hi==** b\n')
|
||||||
assert.equal(plain(paragraph(text('a', strong, highlight('#fff')), text('b', highlight('#fff'), em))), '==**a**_b_==\n')
|
assert.equal(plain(paragraph(text('a', strong, highlight('#fff')), text('b', highlight('#fff'), em))), '==**a**_b_==\n')
|
||||||
assert.equal(plain(paragraph(text('a', highlight('#fff'), code))), '==`a`==\n')
|
assert.equal(plain(paragraph(text('a', highlight('#fff'), code))), '==`a`==\n')
|
||||||
|
assert.equal(plain(paragraph(text('=', highlight('#fff')), text(' '), text('a==b', highlight('#fff')))), '==\\=== ==a==b==\n')
|
||||||
|
assert.equal(plain(paragraph(text('x'), text('y', highlight('#fff')), text(' z'))), 'xy z\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('escapes text that would read back as a flavour marker, and only there', () => {
|
||||||
|
assert.equal(plain(said('==x== a == b a==b ===')), '\\==x\\== a == b a==b \\=\\==\n')
|
||||||
|
assert.equal(plain({ content: [said('[!NOTE] x'), said('[!TIP]')], type: 'blockquote' }), '> \\[!NOTE] x\n>\n> [!TIP]\n')
|
||||||
|
assert.equal(plain({ content: [said('[!NOTE]x')], type: 'blockquote' }, said('[!NOTE]')), '> [!NOTE]x\n\n[!NOTE]\n')
|
||||||
|
assert.equal(plain(bulletList(item(said('[x] a')), item(said('[ ]')))), '- \\[x] a\n- [ ]\n')
|
||||||
|
assert.equal(plain(bulletList(item(said('[x] a')), item(said('b'))), node('orderedList', { order: 1 }, item(said('[x] c')))), '- [x] a\n- b\n\n1. [x] c\n')
|
||||||
|
const task = (state: string, value: string): AdfNode => node('taskItem', { state }, text(value))
|
||||||
|
assert.equal(plain(node('taskList', {}, task('DONE', '[x] a'), task('TODO', '==b=='))), '- [x] [x] a\n- [ ] \\==b\\==\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('unwraps the containers plain markdown has no spelling for to their body blocks in order', () => {
|
test('unwraps the containers plain markdown has no spelling for to their body blocks in order', () => {
|
||||||
@@ -311,7 +320,8 @@ test('drops an empty paragraph and merges adjacent lists of one type', () => {
|
|||||||
assert.equal(plain(node('layoutSection', {}, column(bulletList(item(said('a')))), column(bulletList(item(said('b')))))), '- a\n- b\n')
|
assert.equal(plain(node('layoutSection', {}, column(bulletList(item(said('a')))), column(bulletList(item(said('b')))))), '- a\n- b\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns a document the lossless emitter spells without a directive', () => {
|
test('keeps the nodes the plain flavour spells and degrades only what it cannot', () => {
|
||||||
const reduced = reduceToPlain(document(node('panel', { panelType: 'info' }, said('x'))))
|
const tasks = node('taskList', {}, node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state: 'DONE' }, text('t')))
|
||||||
assert.deepEqual(reduced.ok ? reduced.value : undefined, document({ content: [said('[!NOTE]'), said('x')], type: 'blockquote' }))
|
const reduced = reduceToPlain(document(node('panel', { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType: 'info' }, said('x')), tasks))
|
||||||
|
assert.deepEqual(reduced.ok ? reduced.value : undefined, document(node('panel', { panelType: 'info' }, said('x')), node('taskList', {}, node('taskItem', { state: 'DONE' }, text('t')))))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import test from 'node:test'
|
|||||||
|
|
||||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import { adfToMarkdown } from '../emit/adf-to-markdown.ts'
|
import { adfToMarkdown } from '../emit/adf-to-markdown.ts'
|
||||||
|
import { adfToPlainMarkdown } from '../emit/plain-reduction.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
|
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
|
||||||
import { reduceToPlain } from '../emit/plain-reduction.ts'
|
|
||||||
import { toEditorNormal } from '../../adf/editor-normal.ts'
|
import { toEditorNormal } from '../../adf/editor-normal.ts'
|
||||||
|
|
||||||
const code: AdfMark = { type: 'code' }
|
const code: AdfMark = { type: 'code' }
|
||||||
@@ -26,10 +26,8 @@ function document(...content: AdfNode[]): AdfDocument {
|
|||||||
return { content, type: 'doc', version: 1 }
|
return { content, type: 'doc', version: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ADF the reduction wrote, spelled and read back.
|
|
||||||
function roundTripped(...content: AdfNode[]): readonly AdfNode[] | string {
|
function roundTripped(...content: AdfNode[]): readonly AdfNode[] | string {
|
||||||
const reduced = reduceToPlain(document(...content))
|
const markdown = adfToPlainMarkdown(document(...content))
|
||||||
const markdown = reduced.ok ? adfToMarkdown(reduced.value) : reduced
|
|
||||||
return markdown.ok ? read(markdown.value) : markdown.error.code
|
return markdown.ok ? read(markdown.value) : markdown.error.code
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +188,16 @@ test('reads what the reduction wrote back to the node it reduced, less the attri
|
|||||||
assert.deepEqual(roundTripped(tasks), [plainTasks])
|
assert.deepEqual(roundTripped(tasks), [plainTasks])
|
||||||
const colour: AdfMark = { attrs: { color: '#c6edfb' }, type: 'backgroundColor' }
|
const colour: AdfMark = { attrs: { color: '#c6edfb' }, type: 'backgroundColor' }
|
||||||
assert.deepEqual(roundTripped(paragraph(text('a '), text('hi', colour, strong), text(' b'))), [paragraph(text('a '), text('hi', highlight, strong), text(' b'))])
|
assert.deepEqual(roundTripped(paragraph(text('a '), text('hi', colour, strong), text(' b'))), [paragraph(text('a '), text('hi', highlight, strong), text(' b'))])
|
||||||
|
assert.deepEqual(roundTripped(paragraph(text('=', colour), text(' '), text('a==b', colour))), [paragraph(text('=', highlight), text(' '), text('a==b', highlight))])
|
||||||
|
assert.deepEqual(roundTripped(paragraph(text('x'), text('y', colour))), [said('xy')])
|
||||||
|
assert.deepEqual(roundTripped(node('expand', { title: '**x** [y](z)' }, said('b'))), [node('expand', { title: '**x** [y](z)' }, said('b'))])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads text the writer kept from reading as a marker back as text', () => {
|
||||||
|
const quote = bare('blockquote', said('[!NOTE] x'))
|
||||||
|
const list = bare('bulletList', bare('listItem', said('[x] a')), bare('listItem', said('[ ] b')))
|
||||||
|
assert.deepEqual(roundTripped(said('==x== a==b'), quote, list), [said('==x== a==b'), quote, list])
|
||||||
|
assert.deepEqual(roundTripped(bare('taskList', task('DONE', text('[x] ==a==')))), [bare('taskList', task('DONE', text('[x] ==a==')))])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('keeps what markdownToAdf reads that no row reads, and refuses only what it refuses', () => {
|
test('keeps what markdownToAdf reads that no row reads, and refuses only what it refuses', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user