Make markdownToAdf(adfToMarkdown(doc)) deep-equal doc: textBreak, empty keys, -0, typed carry fences, code blocks of several text nodes

This commit is contained in:
2026-10-03 12:25:01 +02:00
parent bfc4b2ce00
commit 9ab7286e6d
78 changed files with 1263 additions and 246 deletions
+41 -44
View File
@@ -6,14 +6,13 @@ import type { JsonValue } from '../../json-value.ts'
import type { Result } from '../../result.ts'
import { adfToMarkdown, markdownToAdf } from '../../index.ts'
import { largestNesting } from '../../nesting.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
function paragraph(...content: AdfNode[]): AdfNode {
return { content, type: 'paragraph' }
return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
}
function code(result: Result<string>): string {
@@ -168,21 +167,24 @@ test('parts two adjacent lists of the same kind, the marker spelling being what
const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' }
assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n')
const carried: AdfNode = { ...list, attrs: { unknown: 'x' } }
assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```carry\n'))
assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```adf:bulletList\n'))
assert.ok(markdown(adfToMarkdown(document(carried, list))).endsWith('```\n\n- x\n'))
assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```carry\n'))
assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```adf:bulletList\n'))
})
test('carries a node type no section spells', () => {
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```carry\n{\n "type": "blockCard"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```carry\n{\n "type": "toString"\n}\n```\n')
test('carries a node type no section spells, the fence naming the type wherever an info string carries it', () => {
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf:blockCard\n{}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf:toString\n{}\n```\n')
assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), '!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}\n')
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```carry\n{\n "text": "x",\n "type": "text"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```carry\n{\n "type": "hardBreak"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```adf:text\n{\n "text": "x"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```adf:hardBreak\n{}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'a&amp;b' }))), '```adf:\n{\n "type": "a&amp;b"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'a\\b' }))), '```adf:\n{\n "type": "a\\\\b"\n}\n```\n')
})
test('spells the code block whose language is the reserved info string', () => {
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '!adf:codeBlock {language=carry}\n```\n```\n!adf:/codeBlock\n')
test('spells the code block whose language opens with the reserved info string prefix', () => {
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf:x' }, type: 'codeBlock' }))), '!adf:codeBlock {language="adf:x"}\n```\n```\n!adf:/codeBlock\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '```carry\n```\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), '```adf\n```\n')
})
@@ -208,21 +210,17 @@ test('refuses a carried node nested deeper than the levels its position leaves',
assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth')
})
test('refuses a node whose content model the canonical form cannot emit', () => {
assert.equal(
markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))),
'unsupported-node-shape: a codeBlock holds plain text nodes only: this paragraph node is not one',
)
assert.equal(
markdown(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))),
'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one',
)
test('carries a code block holding a node no fence holds', () => {
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), '```adf:codeBlock\n{\n "content": [\n {\n "type": "paragraph"\n }\n ]\n}\n```\n')
for (const child of [{ attrs: {}, text: 'x', type: 'text' }, { content: [], text: 'x', type: 'text' }, { marks: [], text: 'x', type: 'text' }, { content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }]) {
assert.ok(markdown(adfToMarkdown(document({ content: [child], type: 'codeBlock' }))).startsWith('```adf:codeBlock\n'), JSON.stringify(child))
}
})
test('spells a list its own content shape cannot hold as a directive', () => {
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), '!adf:bulletList\n!adf:/bulletList\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {order=2}\n!adf:/orderedList\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {content=empty order=2}\n!adf:/orderedList\n')
})
test('spells an ordered list no marker fits as a directive', () => {
@@ -334,7 +332,7 @@ test('refuses a node carrying one mark type twice', () => {
})
test('parts a nested list the tight spelling would swallow from the block above it', () => {
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' })
const item = (...content: AdfNode[]): AdfNode => (content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' })
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' }
@@ -345,7 +343,7 @@ test('parts a nested list the tight spelling would swallow from the block above
const list: AdfNode = { content: [item(text('b'))], type: 'bulletList' }
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' }
assert.equal(markdown(adfToMarkdown(outer(panel, list))), '- !adf:panel info\n p\n !adf:/panel\n\n - b\n')
assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```carry\n'))
assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```adf:bulletList\n'))
})
test('refuses marks and attributes nested deeper than the emitter carries', () => {
@@ -372,7 +370,7 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
const read = markdownToAdf(spelled.value)
assert.ok(read.ok, read.ok ? '' : read.error.message)
assert.deepEqual(toEditorNormal(read.value), document(node))
assert.deepEqual(read.value, document(node))
}
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em'))
@@ -442,7 +440,7 @@ test('escapes a hyphen underline a hard break would expose', () => {
})
test('spells a list item whose marker completes a thematic break as a directive', () => {
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' })
const item = (...content: AdfNode[]): AdfNode => (content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' })
assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
@@ -469,10 +467,7 @@ test('refuses the characters CommonMark rewrites', () => {
)
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character')
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character')
assert.equal(
markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))),
'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one',
)
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape: a text node holds text: this one has none')
})
test('refuses a text node the spelling would empty out', () => {
@@ -501,11 +496,11 @@ test('pads a code span whose edges CommonMark would strip', () => {
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n')
})
test('spells one code span over a run of code-marked nodes', () => {
test('spells a code span per code-marked node, parted by the text break', () => {
const code_ = { type: 'code' }
assert.equal(
markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))),
'`ab`\n',
'`a`!adf:textBreak{}`b`\n',
)
})
@@ -545,7 +540,7 @@ test('emits an empty list item without trailing whitespace', () => {
test('spells a block directive as its node type, arg and attributes', () => {
const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' })
assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n')
assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel\nx\n!adf:/panel\n')
assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel {attrs=empty}\nx\n!adf:/panel\n')
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), '!adf:caption\nx\n!adf:/caption\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n')
@@ -553,20 +548,20 @@ test('spells a block directive as its node type, arg and attributes', () => {
test('carries a directive attribute no section spells', () => {
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```carry\n{\n "attrs": {\n "rounded": true\n },\n "type": "panel"\n}\n```\n')
assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```carry\n{\n "attrs": {\n "toString": "x"\n },\n "type": "panel"\n}\n```\n')
assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```carry\n{\n "attrs": {\n "localId": 4\n },\n "type": "panel"\n}\n```\n')
assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "rounded": true\n }\n}\n```\n')
assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "toString": "x"\n }\n}\n```\n')
assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "localId": 4\n }\n}\n```\n')
assert.equal(
carried({ attrs: { width: '50' }, type: 'layoutColumn' }),
'```carry\n{\n "attrs": {\n "width": "50"\n },\n "type": "layoutColumn"\n}\n```\n',
'```adf:layoutColumn\n{\n "attrs": {\n "width": "50"\n }\n}\n```\n',
)
assert.equal(
carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }),
'```carry\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n },\n "type": "table"\n}\n```\n',
'```adf:table\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n }\n}\n```\n',
)
assert.equal(
carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }),
'!adf:mediaGroup\n```carry\n{\n "attrs": {\n "alt": 4\n },\n "type": "media"\n}\n```\n!adf:/mediaGroup\n',
'!adf:mediaGroup\n```adf:media\n{\n "attrs": {\n "alt": 4\n }\n}\n```\n!adf:/mediaGroup\n',
)
})
@@ -574,15 +569,16 @@ test('carries an arg slot value no bare token spells', () => {
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
assert.equal(
carried({ attrs: { panelType: 'extra info' }, type: 'panel' }),
'```carry\n{\n "attrs": {\n "panelType": "extra info"\n },\n "type": "panel"\n}\n```\n',
'```adf:panel\n{\n "attrs": {\n "panelType": "extra info"\n }\n}\n```\n',
)
assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```carry\n{\n "attrs": {\n "state": 2\n },\n "type": "taskItem"\n}\n```\n')
assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```adf:taskItem\n{\n "attrs": {\n "state": 2\n }\n}\n```\n')
})
test('carries a block node mark in the reserved attribute', () => {
const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' })
assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"attrs\\":{},\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
assert.equal(markdown(adfToMarkdown(section())), '!adf:layoutSection {marks=empty}\n!adf:/layoutSection\n')
})
test('refuses the content a directive body has no room for', () => {
@@ -604,7 +600,7 @@ test('separates blocks in a container body by a blank line only where a directiv
test('spells the image form for exactly the centered external media shape', () => {
const url = 'https://example.com/moon.png'
const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument =>
document({ attrs: { layout: 'center' }, content: [{ attrs, content, type: 'media' }], type: 'mediaSingle' })
document({ attrs: { layout: 'center' }, content: [content.length === 0 ? { attrs, type: 'media' } : { attrs, content, type: 'media' }], type: 'mediaSingle' })
assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `![The moon](${url})\n`)
assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `![](${url})\n`)
assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`)
@@ -701,7 +697,8 @@ test('spells the directive marks around the longest run they cover', () => {
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
const underline: AdfMark = { type: 'underline' }
assert.equal(emitted(marked('x', underline)), '!adf:underline[x]\n')
assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[ab]\n')
assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[a!adf:textBreak{}b]\n')
assert.equal(emitted(marked('a', { attrs: {}, type: 'underline' }), marked('b', underline)), '!adf:underline[a]{attrs=empty}!adf:underline[b]\n')
assert.equal(emitted(marked('x', { attrs: { type: 'sub' }, type: 'subsup' })), '!adf:subsup[x]{type=sub}\n')
assert.equal(emitted(marked('x', { attrs: { color: '#ae2e24' }, type: 'textColor' })), '!adf:textColor[x]{color="#ae2e24"}\n')
assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), '!adf:border[x]{color="#091e42" size=2}\n')
@@ -746,5 +743,5 @@ test('carries whitespace CommonMark strips in the reserved text directive', () =
test("joins a mark run's segments as a walk rather than as one call's arguments", () => {
const run = Array.from({ length: 200000 }, (): AdfNode => ({ marks: [{ type: 'strong' }], text: 'a', type: 'text' }))
assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${'a'.repeat(200000)}**\n`)
assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${Array.from({ length: 200000 }, () => 'a').join('!adf:textBreak{}')}**\n`)
})
+27 -27
View File
@@ -1,16 +1,16 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import type { Flavour } from '../plain-conventions.ts'
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain-conventions.ts'
import { blockDirectiveForm, listBreakSpelling } from '../block-directive.ts'
import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts'
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
import { languageSlot } from '../code-language.ts'
import { languageSlot, type LanguageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { spellBlockDirectiveOpener } from './block-directive-spelling.ts'
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts'
@@ -40,7 +40,8 @@ export function writeMarkdown(document: AdfDocument, flavour: Flavour): Result<s
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const walk = walkBlocks(nodeContent(document), [], 0, { flavour, memo: undefined })
if (document.content === undefined) return success(`${documentSpelling}\n`)
const walk = walkBlocks(document.content, [], 0, { flavour, memo: undefined })
if (!walk.ok) return walk
const text = joinBlocks(walk.value.blocks, 'document')
return success(text === '' ? '' : `${text}\n`)
@@ -254,40 +255,39 @@ function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBloc
if (!carriesOnly(node, ['language'])) return undefined
const slot = languageSlot(nodeAttrs(node)['language'])
if (slot.kind === 'attribute') return undefined
const text = codeBlockText(node, path)
if (!text.ok) return text
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
const texts = fencedTexts(node, path)
if (!texts.ok) return texts
const [only, ...others] = texts.value ?? []
if (only === undefined || others.length > 0) return undefined
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only)))
}
// spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does.
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const slot = languageSlot(nodeAttrs(node)['language'])
const texts = fencedTexts(node, path)
if (!texts.ok) return texts
if (texts.value === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const slot: LanguageSlot = texts.value.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (!opener.ok) return opener
const text = codeBlockText(node, path)
if (!text.ok) return text
return success(directivePair(node, opener.value, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
const info = slot.kind === 'fence' ? slot.info : ''
return success(directivePair(node, opener.value, texts.value.map((text) => fencedCodeBlock(info, text)).join('\n')))
}
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
let text = ''
for (const [index, child] of nodeContent(node).entries()) {
// The text each fence holds, or `undefined` where a child is no plain text node, which the carry holds instead.
function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | undefined> {
if (node.content === undefined) return success([''])
if (node.content.some((child) => child.type !== 'text' || child.attrs !== undefined || child.content !== undefined || child.marks !== undefined)) return success(undefined)
const texts: string[] = []
for (const [index, child] of node.content.entries()) {
const childPath = [...path, 'content', index]
if (
child.type !== 'text' ||
typeof child.text !== 'string' ||
child.text === '' ||
nodeContent(child).length > 0 ||
nodeMarks(child).length > 0 ||
Object.keys(nodeAttrs(child)).length > 0
) {
return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath)
}
if (typeof child.text !== 'string' || child.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', childPath)
if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath)
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
text += child.text
texts.push(child.text)
}
return success(text)
return success(texts)
}
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
@@ -340,7 +340,7 @@ function directiveItems(items: readonly WalkedItem[]): PlacedBlock[] {
function listStart(node: AdfNode, items: number): number | undefined {
if (node.type !== 'orderedList') return 0
const start = nodeAttrs(node)['order']
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || Object.is(start, -0) || start > largestListMarker) return undefined
return start + items - 1 > largestListMarker ? undefined : start
}
@@ -5,6 +5,7 @@ import { blockArgument, markValues, marksAttribute } from '../block-directive.ts
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { overNested } from '../../json-value.ts'
import { spellEmptyKeys } from '../empty-keys.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, spelledByBody: readonly string[] = []): Result<string> | undefined {
@@ -14,7 +15,7 @@ export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel,
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled)
if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs)
const spelledPairs = [...spellVocabulary(pairs), ...spellEmptyKeys(node)]
const marks = nodeMarks(node)
if (marks.length > 0) {
const values = markValues(marks)
@@ -2,9 +2,10 @@ import type { AdfNode } from '../../adf/document.ts'
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import { nodeAttrs } from '../../adf/document.ts'
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
import { spellEmptyKeys } from '../empty-keys.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined {
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
return pairs === undefined ? undefined : spellAttributes([...spellVocabulary(pairs), ...spellEmptyKeys(node)])
}
+22 -14
View File
@@ -6,14 +6,14 @@ import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment,
import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { emptyKeys, identicalMark, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { highlightDelimiter } from '../plain-conventions.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { sameMark } from '../../adf/editor-normal.ts'
import { readsAsOne, textBreakSpelling } from '../text-break.ts'
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
import { spellTextDirective } from '../text-directive.ts'
@@ -186,6 +186,7 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
const segments: InlineSegment[] = []
for (const [offset, run] of runs.entries()) {
if (partsText(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling))
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
if (!emitted.ok) return emitted
@@ -206,12 +207,18 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
continue
}
const previous = runs[runs.length - 1]
if (previous?.kind === 'marked' && sameMark(previous.mark, mark)) previous.nodes.push(node)
if (previous?.kind === 'marked' && identicalMark(previous.mark, mark)) previous.nodes.push(node)
else runs.push({ index, kind: 'marked', mark, nodes: [node] })
}
return runs
}
function partsText(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
if (previous?.kind !== 'plain' || run.kind !== 'plain') return false
if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false
return readsAsOne(previous.node, run.node)
}
function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index]
}
@@ -261,7 +268,7 @@ function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: numbe
}
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
if (Object.keys(nodeAttrs(node)).length > 0) return success({ carry: { first: index, last: index } })
if (node.attrs !== undefined || emptyKeys(node).length > 0) return success({ carry: { first: index, last: index } })
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
@@ -277,7 +284,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context)
const spelling = markSpelling(mark.type)
if (spelling === undefined) return success({ carry: range })
const attributes = spellMarkAttributes(mark, spelling.attributes)
const attributes = spellMarkAttributes(mark, spelling)
if (attributes === undefined) return success({ carry: range })
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
@@ -312,19 +319,20 @@ function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRang
})
}
// Each node its own span: CommonMark reads two adjacent text nodes in one span back as one.
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
let text = ''
const spans: string[] = []
for (const node of nodes) {
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1) return success({ carry: range })
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1 || node.attrs !== undefined || emptyKeys(node).length > 0) return success({ carry: range })
const { text } = node
if (typeof text !== 'string' || text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
text += node.text
if (/[\n\r]/.test(text)) return success({ carry: range })
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
const fence = '`'.repeat(longestBacktickRun(text) + 1)
spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
}
if (/[\n\r]/.test(text)) return success({ carry: range })
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
const fence = '`'.repeat(longestBacktickRun(text) + 1)
const padded = needsPadding(text) ? ` ${text} ` : text
return success({ segments: [syntax(`${fence}${padded}${fence}`)] })
return success({ segments: [syntax(spans.join(textBreakSpelling))] })
}
function needsPadding(text: string): boolean {
+1 -1
View File
@@ -177,7 +177,7 @@ test('keeps the CommonMark blocks in their spelling and drops their attributes a
assert.equal(plain(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de' }, text('h'))), 'x\n\n## h\n')
assert.equal(plain({ attrs: localId, content: [said('q')], marks: [{ type: 'breakout' }], type: 'blockquote' }), '> q\n')
assert.equal(plain(node('codeBlock', { language: 'ts', wrap: true }, text('a\r\nb\u0000'))), '```ts\na\nb\n```\n')
assert.equal(plain(node('codeBlock', { language: 'carry' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n')
assert.equal(plain(node('codeBlock', { language: 'adf:x' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n')
assert.equal(plain(node('codeBlock', {})), '```\n```\n')
assert.equal(plain(node('rule', { color: '#000' })), '---\n')
assert.equal(plain(node('orderedList', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', order: 3 }, item(said('c')))), '3. c\n')
+6 -3
View File
@@ -8,6 +8,7 @@ import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { taskMarker } from '../plain-conventions.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
// depth: the level the node reduced stands at, counted as the emitter counts it.
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
@@ -50,8 +51,9 @@ export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const blocks = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
return blocks.ok ? success({ content: blocks.value, type: 'doc', version: 1 }) : blocks
// The plain flavour is lossy: it reads and writes editor-normal ADF, whose shapes CommonMark spells.
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
return blocks.ok ? success({ content: nodeContent(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 })).slice(), type: 'doc', version: 1 }) : blocks
}
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
@@ -262,7 +264,8 @@ function listItem(blocks: Result<AdfNode[]>): Result<AdfNode[]> {
// A list item's first line reads as no rule and holds no line of spaces alone: the rule and the spaces give way.
function itemOf(blocks: readonly AdfNode[]): AdfNode {
const rules = blocks.findIndex((block) => block.type !== 'rule')
return { content: blankedCode(blocks.slice(rules === -1 ? blocks.length : rules)), type: 'listItem' }
const content = blankedCode(blocks.slice(rules === -1 ? blocks.length : rules))
return content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' }
}
function blankedCode(blocks: readonly AdfNode[]): AdfNode[] {