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:
@@ -2,7 +2,7 @@ import type { AdfMark } from '../adf/document.ts'
|
||||
import type { BlockType } from '../adf/block-nodes.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { blockNodeModel } from '../adf/block-nodes.ts'
|
||||
import { isAdfMark, nodeAttrs } from '../adf/document.ts'
|
||||
import { isAdfMark } from '../adf/document.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
import { spellDirectiveOpener } from './directive-syntax.ts'
|
||||
|
||||
@@ -14,6 +14,11 @@ const argumentByType = new Map(
|
||||
} satisfies Partial<Record<BlockType, string>>),
|
||||
)
|
||||
|
||||
export const documentName = 'doc'
|
||||
|
||||
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell.
|
||||
export const documentSpelling = spellDirectiveOpener(documentName, undefined, '{content=none}')
|
||||
|
||||
export const listBreakName = 'listBreak'
|
||||
|
||||
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
|
||||
@@ -25,17 +30,14 @@ export function blockArgument(type: string): string | undefined {
|
||||
}
|
||||
|
||||
export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined {
|
||||
if (name === listBreakName) return 'leaf'
|
||||
if (name === listBreakName || name === documentName) return 'leaf'
|
||||
const model = blockNodeModel(name)
|
||||
if (model === undefined) return undefined
|
||||
return model.contentModel === 'none' ? 'leaf' : 'container'
|
||||
}
|
||||
|
||||
export function markValues(marks: readonly AdfMark[]): JsonValue {
|
||||
return marks.map((mark) => {
|
||||
const attrs = nodeAttrs(mark)
|
||||
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
|
||||
})
|
||||
return marks.map((mark) => (mark.attrs === undefined ? { type: mark.type } : { attrs: mark.attrs, type: mark.type }))
|
||||
}
|
||||
|
||||
export function readMarkValues(value: JsonValue): AdfMark[] | undefined {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { carryName } from './opaque-carry.ts'
|
||||
import { holdsControlCharacter } from './commonmark/grammar.ts'
|
||||
import { holdsEntityReference } from './commonmark/entity-references.ts'
|
||||
import { carryFencePrefix } from './opaque-carry.ts'
|
||||
import { infoStringCarries } from './commonmark/grammar.ts'
|
||||
|
||||
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
|
||||
|
||||
// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
|
||||
export function languageSlot(language: JsonValue | undefined): LanguageSlot {
|
||||
if (language === undefined) return { kind: 'none' }
|
||||
if (typeof language !== 'string' || language === '' || language === carryName) return { kind: 'attribute' }
|
||||
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' }
|
||||
if (typeof language !== 'string' || language.startsWith(carryFencePrefix) || !infoStringCarries(language)) return { kind: 'attribute' }
|
||||
return { info: language, kind: 'fence' }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readEntityReference, replacementCharacter } from './entity-references.ts'
|
||||
import { holdsEntityReference, readEntityReference, replacementCharacter } from './entity-references.ts'
|
||||
|
||||
export type LinePosition = 'first' | 'later'
|
||||
|
||||
@@ -133,6 +133,11 @@ export function holdsControlCharacter(text: string): boolean {
|
||||
return controlCharacter.test(text)
|
||||
}
|
||||
|
||||
// What a backtick fence's info string reads back verbatim: escapes and entity references decode, and the edges trim.
|
||||
export function infoStringCarries(text: string): boolean {
|
||||
return text !== '' && !/[`\\]/.test(text) && !holdsControlCharacter(text) && text === text.trim() && !holdsEntityReference(text)
|
||||
}
|
||||
|
||||
export function holdsNullCharacter(text: string): boolean {
|
||||
return nullCharacter.test(text)
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export function spellStringAttribute(text: string): string {
|
||||
export function spellAttributeValue(value: VocabularyValue): string {
|
||||
if (value.kind === 'boolean') return `${value.value}`
|
||||
if (value.kind === 'json') return spellJsonAttribute(value.value)
|
||||
if (value.kind === 'number') return spellStringAttribute(JSON.stringify(value.value))
|
||||
if (value.kind === 'number') return spellStringAttribute(serializeCanonicalJson(value.value, 'compact'))
|
||||
return spellStringAttribute(value.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -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&b' }))), '```adf:\n{\n "type": "a&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 }))), `\n`)
|
||||
assert.equal(markdown(adfToMarkdown(single({ type: 'external', 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`)
|
||||
})
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { DirectiveAttributes, DirectiveValue, Read } from './directive-syntax.ts'
|
||||
import type { EmptyKey } from '../adf/document.ts'
|
||||
import { emptyKeys } from '../adf/document.ts'
|
||||
import { unsupportedNodeShape } from './directive-syntax.ts'
|
||||
|
||||
type EmptyKeysRead = { empty: ReadonlySet<EmptyKey>; rest: Map<string, DirectiveValue> }
|
||||
|
||||
const emptyValue = 'empty'
|
||||
|
||||
// spec/flavour.md, Attributes: no attribute value spells an empty object or array.
|
||||
export function spellEmptyKeys(held: Parameters<typeof emptyKeys>[0]): [string, string][] {
|
||||
return emptyKeys(held).map((key) => [key, emptyValue])
|
||||
}
|
||||
|
||||
export function spellsEmpty(value: DirectiveValue | undefined): boolean {
|
||||
return value?.spelling === emptyValue
|
||||
}
|
||||
|
||||
export function readEmptyKeys(attributes: DirectiveAttributes, keys: readonly EmptyKey[]): Read<EmptyKeysRead> {
|
||||
const rest = new Map(attributes)
|
||||
const empty = new Set<EmptyKey>()
|
||||
for (const key of keys) {
|
||||
const spelled = rest.get(key)
|
||||
if (spelled === undefined) continue
|
||||
if (!spellsEmpty(spelled)) return { fault: unsupportedNodeShape(`the reserved key ${key} reads ${key}=${emptyValue} alone: this one spells ${key}=${spelled.spelling}`) }
|
||||
empty.add(key)
|
||||
rest.delete(key)
|
||||
}
|
||||
return { value: { empty, rest } }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { holdsEntityReference } from './commonmark/entity-references.ts'
|
||||
import { isAutolink } from './commonmark/grammar.ts'
|
||||
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
|
||||
import { nodeAttrs, nodeMarks } from '../adf/document.ts'
|
||||
import { spellEmptyKeys } from './empty-keys.ts'
|
||||
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
||||
|
||||
type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string }
|
||||
@@ -55,7 +56,10 @@ function isBareLink(nodes: readonly AdfNode[], href: string, marksInside: number
|
||||
return nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === marksInside
|
||||
}
|
||||
|
||||
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined {
|
||||
const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, [])
|
||||
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
|
||||
// `undefined` where the spelling holds no such attrs: only a directive spells attrs=empty.
|
||||
export function spellMarkAttributes(mark: AdfMark, spelling: MarkSpelling): string | undefined {
|
||||
const pairs = vocabularyPairs(nodeAttrs(mark), spelling.attributes, [])
|
||||
const empty = spellEmptyKeys(mark)
|
||||
if (pairs === undefined || (empty.length > 0 && spelling.kind !== 'directive')) return undefined
|
||||
return spellAttributes([...spellVocabulary(pairs), ...empty])
|
||||
}
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
import type { AdfNode } from '../adf/document.ts'
|
||||
import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
||||
import type { JsonSpelling } from '../canonical-json.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
|
||||
import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
|
||||
import { infoStringCarries } from './commonmark/grammar.ts'
|
||||
import { isAdfNode } from '../adf/document.ts'
|
||||
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
|
||||
export const carryFencePrefix = 'adf:'
|
||||
|
||||
export const carryName = 'carry'
|
||||
|
||||
const jsonAttribute = 'json'
|
||||
|
||||
// spec/flavour.md, The opaque carry: the info string names the type wherever it carries the type back.
|
||||
export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<{ headroom: number; text: string }> {
|
||||
const json = carriedJson(node, 'two-space', path, largestNesting - depth)
|
||||
const { type, ...untyped } = node
|
||||
const named = infoStringCarries(type)
|
||||
const json = carriedJson(node, named ? untyped : node, 'two-space', path, largestNesting - depth)
|
||||
if (!json.ok) return json
|
||||
return success({ headroom: json.value.headroom, text: fencedCodeBlock(carryName, json.value.json) })
|
||||
return success({ headroom: json.value.headroom, text: fencedCodeBlock(`${carryFencePrefix}${named ? type : ''}`, json.value.json) })
|
||||
}
|
||||
|
||||
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
const json = carriedJson(node, 'compact', path, largestNesting)
|
||||
const json = carriedJson(node, node, 'compact', path, largestNesting)
|
||||
if (!json.ok) return json
|
||||
return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]])))
|
||||
}
|
||||
|
||||
export function readCarriedBlock(body: string, depth: number): Read<AdfNode> {
|
||||
return readCarriedJson(body, 'two-space', largestNesting - depth)
|
||||
export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> {
|
||||
const read = readCarriedJson(body, 'two-space', largestNesting - depth, type)
|
||||
if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read
|
||||
return { fault: unsupportedNodeShape(`the carry fence names a type its info string carries: spell it ${carryFencePrefix}${read.value.type}`) }
|
||||
}
|
||||
|
||||
export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined {
|
||||
@@ -36,28 +45,38 @@ export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefine
|
||||
return readCarriedJson(spelled.value, 'compact', largestNesting)
|
||||
}
|
||||
|
||||
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> {
|
||||
function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> {
|
||||
const headroom = levels - nestingDepth(node)
|
||||
if (!isJsonValue(node) || headroom < 0) {
|
||||
if (!isJsonValue(spelled) || headroom < 0) {
|
||||
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
|
||||
}
|
||||
return success({ headroom, json: serializeCanonicalJson(node, spelling) })
|
||||
return success({ headroom, json: serializeCanonicalJson(spelled, spelling) })
|
||||
}
|
||||
|
||||
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): Read<AdfNode> {
|
||||
// `type` is the one the fence's info string names, `undefined` for the inline carry.
|
||||
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, type?: string): Read<AdfNode> {
|
||||
const parsed = parseJsonText(raw)
|
||||
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') }
|
||||
const { value } = parsed
|
||||
if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
|
||||
if (overNested(value, levels)) {
|
||||
const typed = type === undefined || type === '' ? { value } : typedValue(value, type)
|
||||
if (typed.fault !== undefined) return typed
|
||||
const held = typed.value
|
||||
if (overNested(held, levels)) {
|
||||
return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } }
|
||||
}
|
||||
if (serializeCanonicalJson(value, spelling) !== raw) {
|
||||
const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted'
|
||||
return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}`) }
|
||||
}
|
||||
if (!isAdfNode(value)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON: this JSON is no ADF node") }
|
||||
return { value }
|
||||
if (!isAdfNode(held)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON: this JSON is no ADF node") }
|
||||
return { value: held }
|
||||
}
|
||||
|
||||
function typedValue(value: JsonValue, type: string): Read<JsonValue> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { value }
|
||||
if ('type' in value) return { fault: unsupportedNodeShape(`the ${carryFencePrefix}${type} fence names its node's type: this JSON holds a type as well`) }
|
||||
return { value: { ...value, type } }
|
||||
}
|
||||
|
||||
function parseJsonText(raw: string): { value: unknown } | undefined {
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { ConvertFault } from '../../result.ts'
|
||||
import type { DirectiveAttributes } from '../directive-syntax.ts'
|
||||
import type { MarkSpelling } from '../mark-spellings.ts'
|
||||
import { directivePrefix } from '../directive-syntax.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { markSpelling } from '../mark-spellings.ts'
|
||||
import { readEmptyKeys } from '../empty-keys.ts'
|
||||
import { readVocabulary } from './directive-attributes.ts'
|
||||
|
||||
export function readDirectiveMark(name: string, attributes: DirectiveAttributes, path: ConvertErrorPath): Result<AdfMark> | undefined {
|
||||
@@ -12,8 +13,11 @@ export function readDirectiveMark(name: string, attributes: DirectiveAttributes,
|
||||
if (spelling === undefined) return undefined
|
||||
const markdown = markdownForm(spelling)
|
||||
if (markdown !== undefined) return failure('unsupported-node-shape', `${name} is spelled ${markdown}, never as a directive`, path)
|
||||
const attrs = readVocabulary(name, attributes, spelling.attributes, undefined, path)
|
||||
const empty = readEmptyKeys(attributes, ['attrs'])
|
||||
if (empty.fault !== undefined) return faulted(empty.fault, path)
|
||||
const attrs = readVocabulary(name, empty.value.rest, spelling.attributes, undefined, path)
|
||||
if (!attrs.ok) return attrs
|
||||
if (empty.value.empty.has('attrs')) return Object.keys(attrs.value).length === 0 ? success({ attrs: {}, type: name }) : failure('unsupported-node-shape', `${name} spells attrs=empty beside an attribute it holds`, path)
|
||||
return success(Object.keys(attrs.value).length === 0 ? { type: name } : { attrs: attrs.value, type: name })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { AdfAttributes, AdfMark, AdfNode, EmptyKey } from '../../adf/document.ts'
|
||||
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import type { ConvertFault } from '../../result.ts'
|
||||
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
|
||||
@@ -7,15 +7,18 @@ import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../.
|
||||
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
|
||||
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { carryName } from '../opaque-carry.ts'
|
||||
import { carryFencePrefix, carryName } from '../opaque-carry.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { inlineMarkSpellingFault } from './directive-marks.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts'
|
||||
import { readVocabulary } from './directive-attributes.ts'
|
||||
import { slotLineEndingFault } from '../directive-syntax.ts'
|
||||
import { textBreakName } from '../text-break.ts'
|
||||
import { textDirectiveName } from '../text-directive.ts'
|
||||
|
||||
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode }
|
||||
// `emptyContent` is whether the directive spells content=empty, which holds no body.
|
||||
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; emptyContent: boolean; node: AdfNode }
|
||||
|
||||
export function readBlockDirectiveNode(
|
||||
name: string,
|
||||
@@ -24,12 +27,15 @@ export function readBlockDirectiveNode(
|
||||
path: ConvertErrorPath,
|
||||
): Result<BlockDirectiveNode> {
|
||||
if (name === carryName) {
|
||||
return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryName} fence`, path)
|
||||
return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryFencePrefix} fence`, path)
|
||||
}
|
||||
const model = blockNodeModel(name)
|
||||
if (model === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
||||
const argumentKey = blockArgument(name)
|
||||
const rest = new Map(attributes)
|
||||
const spelled = attributes.get(marksAttribute)
|
||||
const empty = readEmptyKeys(attributes, spellsEmpty(spelled) ? ['attrs', 'content', 'marks'] : ['attrs', 'content'])
|
||||
if (empty.fault !== undefined) return faulted(empty.fault, path)
|
||||
const { rest } = empty.value
|
||||
rest.delete(marksAttribute)
|
||||
const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' }
|
||||
const attrs = readVocabulary(name, rest, model.attributes, elsewhere, path)
|
||||
@@ -38,10 +44,11 @@ export function readBlockDirectiveNode(
|
||||
if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument: this one spells one`, path)
|
||||
attrs.value[argumentKey] = argument
|
||||
}
|
||||
const spelled = attributes.get(marksAttribute)
|
||||
const marks: Result<AdfMark[] | undefined> = spelled === undefined ? success(undefined) : readMarks(name, spelled, path)
|
||||
const marks: Result<AdfMark[] | undefined> = spelled === undefined || spellsEmpty(spelled) ? success(undefined) : readMarks(name, spelled, path)
|
||||
if (!marks.ok) return marks
|
||||
return success({ contentModel: model.contentModel, node: namedNode(name, attrs.value, marks.value) })
|
||||
const node = namedNode(name, attrs.value, marks.value, empty.value.empty, path)
|
||||
if (!node.ok) return node
|
||||
return success({ contentModel: model.contentModel, emptyContent: empty.value.empty.has('content'), node: node.value })
|
||||
}
|
||||
|
||||
export function readInlineDirectiveNode(
|
||||
@@ -55,7 +62,9 @@ export function readInlineDirectiveNode(
|
||||
const slot = model.textAttribute
|
||||
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path)
|
||||
const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' }
|
||||
const attrs = readVocabulary(name, attributes, model.attributes, elsewhere, path)
|
||||
const empty = readEmptyKeys(attributes, ['attrs', 'content', 'marks'])
|
||||
if (empty.fault !== undefined) return faulted(empty.fault, path)
|
||||
const attrs = readVocabulary(name, empty.value.rest, model.attributes, elsewhere, path)
|
||||
if (!attrs.ok) return attrs
|
||||
if (slot !== undefined && content !== undefined) {
|
||||
const text = slotText(content)
|
||||
@@ -66,14 +75,14 @@ export function readInlineDirectiveNode(
|
||||
if (spans !== undefined) return faulted(spans, path)
|
||||
attrs.value[slot] = text
|
||||
}
|
||||
return success(namedNode(name, attrs.value, undefined))
|
||||
return namedNode(name, attrs.value, undefined, empty.value.empty, path)
|
||||
}
|
||||
|
||||
// A name the other position spells names that spelling, never the code a later MINOR may fill (docs/decisions.md §Which code a cause takes).
|
||||
function inlineSpellingFault(name: string): ConvertFault | undefined {
|
||||
const mark = inlineMarkSpellingFault(name)
|
||||
if (mark !== undefined) return mark
|
||||
if (inlineNodeModel(name) === undefined && name !== textDirectiveName) return undefined
|
||||
if (inlineNodeModel(name) === undefined && name !== textDirectiveName && name !== textBreakName) return undefined
|
||||
return { code: 'unsupported-node-shape', message: `${name} takes the inline form, ${directivePrefix}${name}{…}, never the block form` }
|
||||
}
|
||||
|
||||
@@ -100,7 +109,11 @@ function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath
|
||||
return success(marks)
|
||||
}
|
||||
|
||||
function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined): AdfNode {
|
||||
const named = Object.keys(attrs).length === 0 ? { type } : { attrs, type }
|
||||
return marks === undefined ? named : { ...named, marks: [...marks] }
|
||||
function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet<EmptyKey>, path: ConvertErrorPath): Result<AdfNode> {
|
||||
const held = Object.keys(attrs).length > 0
|
||||
if (held && empty.has('attrs')) return failure('unsupported-node-shape', `${type} spells attrs=empty beside an attribute it holds`, path)
|
||||
const node: AdfNode = held || empty.has('attrs') ? { attrs, type } : { type }
|
||||
if (empty.has('content')) node.content = []
|
||||
if (marks !== undefined || empty.has('marks')) node.marks = [...(marks ?? [])]
|
||||
return success(node)
|
||||
}
|
||||
|
||||
@@ -10,16 +10,16 @@ import { commonMarkLink, linkHref } from '../mark-spellings.ts'
|
||||
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts'
|
||||
import { identicalMarks, nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts'
|
||||
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
|
||||
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
|
||||
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
|
||||
import { readCarriedInline } from '../opaque-carry.ts'
|
||||
import { readDirectiveMark } from './directive-marks.ts'
|
||||
import { readInlineDirectiveNode } from './directive-nodes.ts'
|
||||
import { readTextDirective } from '../text-directive.ts'
|
||||
import { readsAsOne, textBreakName, textBreakSpelling } from '../text-break.ts'
|
||||
|
||||
export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] }
|
||||
|
||||
@@ -31,8 +31,10 @@ type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; lin
|
||||
|
||||
type Pairing = EmphasisPairing<Run>
|
||||
|
||||
// A carry piece holds a node whose marks are its own: an opaque carry, or an inline node spelling marks=empty.
|
||||
type Piece =
|
||||
| Bracket
|
||||
| { kind: 'break' }
|
||||
| { kind: 'carry'; node: AdfNode }
|
||||
| { alt: string; kind: 'image'; node: AdfNode }
|
||||
| { kind: 'nodes'; nodes: AdfNode[] }
|
||||
@@ -43,6 +45,8 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb
|
||||
|
||||
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
|
||||
type Scan = {
|
||||
// Nodes a carry piece restored: they never merge, and only identity tells a carried textBreak from the leaf.
|
||||
carried: Set<AdfNode>
|
||||
container: LineContainer | undefined
|
||||
// Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed.
|
||||
deactivatedBefore: number
|
||||
@@ -58,7 +62,7 @@ type Scan = {
|
||||
|
||||
type SlotContent = { carry: boolean; nodes: AdfNode[] }
|
||||
|
||||
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
|
||||
const carriedInMark = 'no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own'
|
||||
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
||||
const hreflessLink = 'the link mark spells its href: this one spells none'
|
||||
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
|
||||
@@ -66,11 +70,11 @@ const linkInLink = 'no link wraps a link: the [content] this one marks already h
|
||||
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
|
||||
|
||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
|
||||
return parseInline(source, definitions, path, container, noSpans, flavour === 'plain')
|
||||
return parseInline(source, { carried: new Set(), container, definitions, highlights: flavour === 'plain', path, spans: noSpans })
|
||||
}
|
||||
|
||||
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans, highlights: boolean): Result<InlineContent> {
|
||||
const scan: Scan = { container, deactivatedBefore: 0, definitions, highlights, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
|
||||
function parseInline(source: string, context: Pick<Scan, 'carried' | 'container' | 'definitions' | 'highlights' | 'path' | 'spans'>): Result<InlineContent> {
|
||||
const scan: Scan = { ...context, deactivatedBefore: 0, openingSpellableLink: false, pending: '', pieces: [], source }
|
||||
let index = 0
|
||||
while (index < source.length) {
|
||||
switch (source.charAt(index)) {
|
||||
@@ -103,7 +107,7 @@ function parseInline(source: string, definitions: LinkDefinitions, path: Convert
|
||||
index = readCharacter(scan, index)
|
||||
}
|
||||
}
|
||||
flush(scan, container !== undefined)
|
||||
flush(scan, scan.container !== undefined)
|
||||
return assemble(scan)
|
||||
}
|
||||
|
||||
@@ -205,8 +209,9 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
|
||||
const carried = readCarriedInline(span)
|
||||
if (carried !== undefined) {
|
||||
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
|
||||
return success({ kind: 'carry', node: carried.value })
|
||||
return success(carryPiece(scan, carried.value))
|
||||
}
|
||||
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success({ kind: 'break' }) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path)
|
||||
const text = readTextDirective(span)
|
||||
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
|
||||
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
|
||||
@@ -216,7 +221,12 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
|
||||
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
|
||||
const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path)
|
||||
if (!node.ok) return node
|
||||
return success({ kind: 'nodes', nodes: [node.value] })
|
||||
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : carryPiece(scan, node.value))
|
||||
}
|
||||
|
||||
function carryPiece(scan: Scan, node: AdfNode): Piece {
|
||||
scan.carried.add(node)
|
||||
return { kind: 'carry', node }
|
||||
}
|
||||
|
||||
function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> {
|
||||
@@ -242,7 +252,7 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
|
||||
|
||||
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
|
||||
if (span.content === undefined) return success(undefined)
|
||||
const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans, false)
|
||||
const parsed = parseInline(span.content, { ...scan, container: undefined, highlights: false, spans: span.spans })
|
||||
if (!parsed.ok) return parsed
|
||||
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
||||
return success(parsed.value)
|
||||
@@ -262,7 +272,8 @@ function assemble(scan: Scan): Result<InlineContent> {
|
||||
const only = scan.pieces[0]
|
||||
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
|
||||
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
||||
const nodes = resolveNodes(scan.pieces, scan.path, true)
|
||||
const resolved = resolveNodes(scan.pieces, scan, true)
|
||||
const nodes = resolved.ok && scan.container !== undefined ? partText(resolved.value, scan) : resolved
|
||||
if (!nodes.ok) return nodes
|
||||
if (scan.openingSpellableLink) {
|
||||
const takesDirective = openingLinkTakesDirective(nodes.value, scan.path)
|
||||
@@ -272,6 +283,27 @@ function assemble(scan: Scan): Result<InlineContent> {
|
||||
return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value })
|
||||
}
|
||||
|
||||
// spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it.
|
||||
function partText(nodes: readonly AdfNode[], scan: Scan): Result<AdfNode[]> {
|
||||
const parted: AdfNode[] = []
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
if (!isTextBreak(node, scan.carried)) {
|
||||
parted.push(node)
|
||||
continue
|
||||
}
|
||||
const previous = nodes[index - 1]
|
||||
const next = nodes[index + 1]
|
||||
if (previous === undefined || next === undefined || scan.carried.has(previous) || scan.carried.has(next) || !readsAsOne(previous, next)) {
|
||||
return failure('unsupported-node-shape', `${textBreakName} parts two text nodes CommonMark reads back as one: this one parts something else`, scan.path)
|
||||
}
|
||||
}
|
||||
return success(parted)
|
||||
}
|
||||
|
||||
function isTextBreak(node: AdfNode, carried: ReadonlySet<AdfNode>): boolean {
|
||||
return node.type === textBreakName && !carried.has(node)
|
||||
}
|
||||
|
||||
function holdsCarry(pieces: readonly Piece[]): boolean {
|
||||
return pieces.some((piece) => piece.kind === 'carry')
|
||||
}
|
||||
@@ -383,7 +415,7 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition:
|
||||
}
|
||||
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
|
||||
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
||||
const resolved = resolveNodes(inner, scan.path, true)
|
||||
const resolved = resolveNodes(inner, scan, true)
|
||||
if (!resolved.ok) return resolved
|
||||
const nodes = resolved.value
|
||||
// An empty link text gives the mark no node to ride, so the brackets stay text.
|
||||
@@ -411,7 +443,7 @@ function truncatePieces(scan: Scan, to: number): void {
|
||||
|
||||
function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
|
||||
if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path)
|
||||
const resolved = imageAlt(inner, scan.path)
|
||||
const resolved = imageAlt(inner, scan)
|
||||
if (!resolved.ok) return resolved
|
||||
const alt = resolved.value
|
||||
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
|
||||
@@ -420,9 +452,10 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition:
|
||||
return success(null)
|
||||
}
|
||||
|
||||
function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<string> {
|
||||
const nodes = resolveNodes(inner, path, false)
|
||||
function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> {
|
||||
const nodes = resolveNodes(inner, scan, false)
|
||||
if (!nodes.ok) return nodes
|
||||
if (nodes.value.some((node) => isTextBreak(node, scan.carried))) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: an image description holds plain text`, scan.path)
|
||||
return success(nodes.value.map(altText).join(''))
|
||||
}
|
||||
|
||||
@@ -435,20 +468,35 @@ function altText(node: AdfNode): string {
|
||||
}
|
||||
|
||||
// An image's alt text is plain, so `highlights` is off there and every `==` stays text.
|
||||
function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath, highlights: boolean): Result<AdfNode[]> {
|
||||
function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean): Result<AdfNode[]> {
|
||||
const nodes = pieces.map(pieceNodes)
|
||||
const runs = delimiterRuns(pieces)
|
||||
const pairings = matchEmphasis(runs)
|
||||
writeUnpaired(nodes, runs, pairings)
|
||||
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path)
|
||||
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
||||
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
|
||||
return success(mergeAdjacentText(nodes.flat()))
|
||||
return success(mergeText(nodes.flat(), scan.carried))
|
||||
}
|
||||
|
||||
function mergeText(nodes: readonly AdfNode[], carried: ReadonlySet<AdfNode>): AdfNode[] {
|
||||
const merged: AdfNode[] = []
|
||||
for (const node of nodes) {
|
||||
const previous = merged[merged.length - 1]
|
||||
if (previous !== undefined && !carried.has(previous) && !carried.has(node) && readsAsOne(previous, node)) {
|
||||
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
|
||||
continue
|
||||
}
|
||||
merged.push(node)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
|
||||
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
|
||||
function pieceNodes(piece: Piece): AdfNode[] {
|
||||
switch (piece.kind) {
|
||||
case 'break':
|
||||
return [{ type: textBreakName }]
|
||||
case 'carry':
|
||||
return [piece.node]
|
||||
case 'highlight':
|
||||
@@ -531,7 +579,7 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][])
|
||||
let candidate = found[closer]
|
||||
while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)]
|
||||
if (candidate === undefined) break
|
||||
if (candidate.line !== opener.line || !sameMarks(opener.holder, candidate.holder)) continue
|
||||
if (candidate.line !== opener.line || !identicalMarks(opener.holder, candidate.holder)) continue
|
||||
paired.push({ closer: candidate.index, opener: opener.index })
|
||||
resume = candidate.position + highlightDelimiter.length
|
||||
}
|
||||
|
||||
@@ -84,9 +84,21 @@ function table(...rows: AdfNode[]): AdfNode {
|
||||
return { content: rows, type: 'table' }
|
||||
}
|
||||
|
||||
test('builds an empty document from input holding no block', () => {
|
||||
assert.deepEqual(markdownToAdf(''), { ok: true, value: { type: 'doc', version: 1 } })
|
||||
assert.deepEqual(content(markdownToAdf('\n \n\t\n')), [])
|
||||
test('builds an empty document from input holding no block, and one holding no content key from the doc directive alone', () => {
|
||||
assert.deepEqual(markdownToAdf(''), { ok: true, value: { content: [], type: 'doc', version: 1 } })
|
||||
assert.deepEqual(markdownToAdf('\n \n\t\n'), { ok: true, value: { content: [], type: 'doc', version: 1 } })
|
||||
assert.deepEqual(markdownToAdf('!adf:doc {content=none}\n'), { ok: true, value: { type: 'doc', version: 1 } })
|
||||
assert.deepEqual(markdownToAdf('\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(markdownToAdf('!adf:doc\n')), form)
|
||||
assert.equal(content(markdownToAdf('!adf:doc x {content=none}\n')), form)
|
||||
assert.equal(content(markdownToAdf('!adf:doc {content=empty}\n')), form)
|
||||
assert.equal(content(markdownToAdf('!adf:doc {content="none"}\n')), form)
|
||||
const alone = 'unsupported-node-shape: !adf:doc {content=none} spells a whole document holding no content key, alone: this one stands among other blocks'
|
||||
assert.equal(content(markdownToAdf('x\n\n!adf:doc {content=none}\n')), alone)
|
||||
assert.equal(content(markdownToAdf('!adf:panel info\n!adf:doc {content=none}\n!adf:/panel\n')), alone)
|
||||
assert.equal(content(markdownToAdf('!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(markdownToAdf('a !adf:doc{content=none}\n')), 'unsupported-node-shape: doc takes the block form, !adf:doc, never the inline form')
|
||||
})
|
||||
|
||||
test('builds one paragraph from the lines a blank line does not part', () => {
|
||||
@@ -153,7 +165,7 @@ 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(markdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), slot)
|
||||
assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n')), slot)
|
||||
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```carry\nx\n```\n!adf:/codeBlock\n')), slot)
|
||||
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```adf:x\nx\n```\n!adf:/codeBlock\n')), slot)
|
||||
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\n')), slot)
|
||||
})
|
||||
|
||||
@@ -343,18 +355,26 @@ test('names the position a directive name the other one spells belongs to', () =
|
||||
})
|
||||
|
||||
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 carry fence'
|
||||
const reserved = 'malformed-directive: the name carry is reserved for the opaque carry, whose block form is the adf: fence'
|
||||
assert.equal(content(markdownToAdf('!adf:carry\n')), reserved)
|
||||
assert.equal(content(markdownToAdf('!adf:carry\nx\n!adf:/carry\n')), reserved)
|
||||
assert.deepEqual(content(markdownToAdf('```adf\nx\n```\n')), [{ attrs: { language: 'adf' }, content: [text('x')], type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf('```carry\nx\n```\n')), [{ attrs: { language: 'carry' }, content: [text('x')], type: 'codeBlock' }])
|
||||
})
|
||||
|
||||
const carried = '!adf:carry{json="{\\"type\\":\\"placeholder\\"}"}'
|
||||
|
||||
test('reads the carry fence back to the node its JSON holds', () => {
|
||||
assert.deepEqual(content(markdownToAdf('```carry\n{\n "attrs": {\n "url": "https://example.com/x"\n },\n "type": "blockCard"\n}\n```\n')), [
|
||||
test('reads the carry fence back to the node its info string names and its JSON holds', () => {
|
||||
assert.deepEqual(content(markdownToAdf('```adf:blockCard\n{\n "attrs": {\n "url": "https://example.com/x"\n }\n}\n```\n')), [
|
||||
{ attrs: { url: 'https://example.com/x' }, type: 'blockCard' },
|
||||
])
|
||||
assert.deepEqual(content(markdownToAdf('```adf:\n{\n "type": "a`b"\n}\n```\n')), [{ type: 'a`b' }])
|
||||
assert.equal(
|
||||
content(markdownToAdf('```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",
|
||||
)
|
||||
assert.equal(content(markdownToAdf('```adf:\n{\n "type": "blockCard"\n}\n```\n')), 'unsupported-node-shape: the carry fence names a type its info string carries: spell it adf:blockCard')
|
||||
assert.equal(content(markdownToAdf('```adf:\n{}\n```\n')), "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node")
|
||||
})
|
||||
|
||||
test('reads the inline carry back to the node its json attribute holds', () => {
|
||||
@@ -365,22 +385,22 @@ test('reads the inline carry back to the node its json attribute holds', () => {
|
||||
|
||||
test('names the invalid JSON no opaque carry holds', () => {
|
||||
const invalid = 'malformed-directive: the opaque carry holds invalid JSON'
|
||||
assert.equal(content(markdownToAdf('```carry\n{"type":\n```\n')), invalid)
|
||||
assert.equal(content(markdownToAdf('```carry\n```\n')), invalid)
|
||||
assert.equal(content(markdownToAdf('```adf:x\n{"attrs":\n```\n')), invalid)
|
||||
assert.equal(content(markdownToAdf('```adf:x\n```\n')), invalid)
|
||||
assert.equal(content(markdownToAdf('!adf:carry{json="{"}\n')), invalid)
|
||||
assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), invalid)
|
||||
})
|
||||
|
||||
test('names the canonical spelling a carried JSON reads alone', () => {
|
||||
const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: "
|
||||
assert.equal(content(markdownToAdf('```carry\n{"type":"blockCard"}\n```\n')), `${canonically}two-space indent, keys sorted`)
|
||||
assert.equal(content(markdownToAdf('```adf:blockCard\n{"attrs":{}}\n```\n')), `${canonically}two-space indent, keys sorted`)
|
||||
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`)
|
||||
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted`)
|
||||
})
|
||||
|
||||
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(markdownToAdf('```carry\n[]\n```\n')), node)
|
||||
assert.equal(content(markdownToAdf('```adf:x\n[]\n```\n')), node)
|
||||
assert.equal(content(markdownToAdf('!adf:carry{json=null}\n')), node)
|
||||
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), node)
|
||||
})
|
||||
@@ -394,7 +414,7 @@ test('names the shape the inline carry reads alone', () => {
|
||||
|
||||
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}\`\`\`carry\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n`
|
||||
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(markdownToAdf(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting))
|
||||
assert.equal(
|
||||
@@ -407,11 +427,11 @@ test('holds a carried JSON value to the nesting its position leaves', () => {
|
||||
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(markdownToAdf('!adf:carry{json="{\\"attrs\\":{\\"width\\":1e999},\\"type\\":\\"blockCard\\"}"}\n')), named)
|
||||
assert.equal(content(markdownToAdf('```carry\n1e999\n```\n')), named)
|
||||
assert.equal(content(markdownToAdf('```adf:x\n1e999\n```\n')), named)
|
||||
})
|
||||
|
||||
test('names the mark spelling no opaque carry sits inside', () => {
|
||||
const named = 'unsupported-node-shape: no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
|
||||
const named = 'unsupported-node-shape: no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own'
|
||||
assert.equal(content(markdownToAdf(`_a ${carried} b_\n`)), named)
|
||||
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
|
||||
assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named)
|
||||
@@ -454,7 +474,9 @@ test('names the marks key no marks array reads back from', () => {
|
||||
assert.equal(content(markdownToAdf('!adf:rule {marks="[1]"}\n')), named)
|
||||
assert.equal(content(markdownToAdf('!adf:rule {marks="{}"}\n')), named)
|
||||
assert.equal(content(markdownToAdf('!adf:rule {marks=x}\n')), named)
|
||||
assert.equal(content(markdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), named)
|
||||
assert.equal(content(markdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\",\\"attrs\\":{}}]"}\n')), named)
|
||||
assert.deepEqual(content(markdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), [{ marks: [{ attrs: {}, type: 'em' }], type: 'rule' }])
|
||||
assert.deepEqual(content(markdownToAdf('!adf:rule {marks=empty}\n')), [{ marks: [], type: 'rule' }])
|
||||
})
|
||||
|
||||
test('names the attribute a node holds no reading for', () => {
|
||||
@@ -487,7 +509,8 @@ test('names the argument and the body a node takes no reading for', () => {
|
||||
assert.equal(content(markdownToAdf('!adf:rule x\n')), 'unsupported-node-shape: rule takes no argument: this one spells one')
|
||||
assert.equal(content(markdownToAdf('!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(markdownToAdf('!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(markdownToAdf('!adf:codeBlock {wrap=true}\nx\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes one code block as its body: this body is not one')
|
||||
assert.equal(content(markdownToAdf('!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(markdownToAdf('!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(markdownToAdf('!adf:paragraph\n\n!adf:/paragraph\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
|
||||
assert.equal(content(markdownToAdf('Part !adf:date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content: this one holds some')
|
||||
})
|
||||
@@ -545,7 +568,7 @@ test('names the line and the offset in the input a refusal sits at, the innermos
|
||||
assert.deepEqual(position(markdownToAdf('- Part.\n- a <span>b</span>\n')), { line: 2, offset: 8 })
|
||||
assert.deepEqual(position(markdownToAdf('Part.\n\n!adf:panel info\nMore.\n')), { line: 3, offset: 7 })
|
||||
assert.deepEqual(position(markdownToAdf('x\n\na <span>b</span>\n===\n')), { line: 3, offset: 3 })
|
||||
assert.deepEqual(position(markdownToAdf('x\n\n```carry\n{\n```\n')), { line: 3, offset: 3 })
|
||||
assert.deepEqual(position(markdownToAdf('x\n\n```adf:x\n{\n```\n')), { line: 3, offset: 3 })
|
||||
assert.deepEqual(position(markdownToAdf('x\n\n| a |\n')), { line: 3, offset: 3 })
|
||||
assert.deepEqual(position(markdownToAdf('a\nb <span>c</span>\n')), { line: 1, offset: 0 })
|
||||
assert.deepEqual(position(markdownToAdf('Part.\r\n\r\n<div>\r\n')), { line: 3, offset: 9 })
|
||||
|
||||
@@ -5,14 +5,14 @@ import type { ConvertFault } from '../../result.ts'
|
||||
import type { Flavour } from '../plain-conventions.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { LinkDefinitions } from './inline-content.ts'
|
||||
import { carryName, readCarriedBlock } from '../opaque-carry.ts'
|
||||
import { carryFencePrefix, readCarriedBlock } from '../opaque-carry.ts'
|
||||
import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts'
|
||||
import { documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts'
|
||||
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
|
||||
import { inlineLeaves } from '../emit/plain-inline.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts'
|
||||
import { listBreakName, listBreakSpelling } from '../block-directive.ts'
|
||||
import { mintTaskIds } from './task-ids.ts'
|
||||
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
@@ -39,10 +39,15 @@ export function plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseE
|
||||
|
||||
function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, ParseError> {
|
||||
const parsed = parseBlocks(markdown)
|
||||
const [only] = parsed.blocks
|
||||
if (parsed.blocks.length === 1 && only?.kind === 'directive' && only.name === documentName) {
|
||||
const fault = documentFault(only)
|
||||
return fault === undefined ? success({ type: 'doc', version: 1 }) : positioned(faulted(fault, []), only.position)
|
||||
}
|
||||
const reading: Reading = { carried: new Set(), definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() }
|
||||
const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart)
|
||||
if (!content.ok) return content
|
||||
const document: AdfDocument = content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 }
|
||||
const document: AdfDocument = { content: content.value, type: 'doc', version: 1 }
|
||||
if (flavour === 'plain') mintTaskIds(document, markdown, reading.carried)
|
||||
return success(document)
|
||||
}
|
||||
@@ -52,6 +57,10 @@ function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErr
|
||||
const content: AdfNode[] = []
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
const nodePath = [...path, 'content', content.length]
|
||||
if (block.kind === 'directive' && block.name === documentName) {
|
||||
const fault = documentFault(block) ?? unsupportedNodeShape(`${documentSpelling} spells a whole document holding no content key, alone: this one stands among other blocks`)
|
||||
return positioned(faulted(fault, nodePath), block.position)
|
||||
}
|
||||
if (block.kind === 'directive' && block.name === listBreakName) {
|
||||
const fault = listBreakFault(block, blocks[index - 1], blocks[index + 1])
|
||||
if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position)
|
||||
@@ -73,6 +82,12 @@ function listBreakFault(block: DirectiveBlock, previous: Block | undefined, next
|
||||
return previous.kind === next?.kind ? undefined : partsFault()
|
||||
}
|
||||
|
||||
function documentFault(block: DirectiveBlock): ConvertFault | undefined {
|
||||
const content = block.attributes.get('content')
|
||||
if (block.argument === undefined && block.attributes.size === 1 && content?.spelling === 'none') return undefined
|
||||
return unsupportedNodeShape(`${documentName} spells the one form ${documentSpelling}: this one spells another`)
|
||||
}
|
||||
|
||||
function partsFault(): ConvertFault {
|
||||
return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`)
|
||||
}
|
||||
@@ -214,24 +229,33 @@ function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErr
|
||||
}
|
||||
|
||||
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const { contentModel, node } = read
|
||||
const { contentModel, emptyContent, node } = read
|
||||
if (blocks === undefined) return success(node)
|
||||
if (emptyContent) return blocks.length === 0 ? success(node) : failure('unsupported-node-shape', `${node.type} spells content=empty, which holds no body: this one holds one`, path)
|
||||
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
|
||||
if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
|
||||
return containerNode(node, blocks, reading, path, depth)
|
||||
}
|
||||
|
||||
// spec/flavour.md, The CommonMark blocks: one fence per text node, every fence carrying the one language.
|
||||
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
|
||||
const only = blocks.length === 1 ? blocks[0] : undefined
|
||||
if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body: this body is not one`, path)
|
||||
const fences: Extract<Block, { kind: 'code' }>[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes code blocks as its body: this body holds another block`, path)
|
||||
fences.push(block)
|
||||
}
|
||||
const [first] = fences
|
||||
if (first === undefined) return failure('unsupported-node-shape', `${node.type} takes code blocks as its body: this body holds none`, path)
|
||||
if (fences.some((fence) => fence.language !== first.language)) return failure('unsupported-node-shape', `${node.type} holds one language, so its fences carry one info string: these differ`, path)
|
||||
if (fences.length > 1 && fences.some((fence) => fence.text === '')) return failure('unsupported-node-shape', `a fence beside another spells a text node, which holds text: this one is empty`, path)
|
||||
const attribute = nodeAttrs(node)['language']
|
||||
const fromFence = only.language !== ''
|
||||
const slot = languageSlot(fromFence ? only.language : attribute)
|
||||
const fromFence = first.language !== ''
|
||||
const slot = languageSlot(fromFence ? first.language : attribute)
|
||||
if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {
|
||||
return failure('unsupported-node-shape', `${node.type} spells its language in the fence info string, or in the attribute where no info string carries it back`, path)
|
||||
}
|
||||
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: only.language } } : node
|
||||
return success(withContent(spelled, only.text === '' ? [] : [{ text: only.text, type: 'text' }]))
|
||||
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: first.language } } : node
|
||||
return success(withContent(spelled, first.text === '' ? [] : fences.map((fence): AdfNode => ({ text: fence.text, type: 'text' }))))
|
||||
}
|
||||
|
||||
function tableNode(rows: readonly string[][], reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
|
||||
@@ -278,8 +302,8 @@ function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, pa
|
||||
}
|
||||
|
||||
function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
if (language === carryName) {
|
||||
const carried = readCarriedBlock(text, depth)
|
||||
if (language.startsWith(carryFencePrefix)) {
|
||||
const carried = readCarriedBlock(language.slice(carryFencePrefix.length), text, depth)
|
||||
if (carried.fault !== undefined) return faulted(carried.fault, path)
|
||||
reading.carried.add(carried.value)
|
||||
return success(carried.value)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { AdfNode } from '../adf/document.ts'
|
||||
import { identicalMarks } from '../adf/document.ts'
|
||||
import { spellInlineLeafDirective } from './directive-syntax.ts'
|
||||
|
||||
export const textBreakName = 'textBreak'
|
||||
|
||||
export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '')
|
||||
|
||||
// Whether CommonMark reads the pair back as one text node, where neither rides the carry (spec/flavour.md, Inline nodes).
|
||||
export function readsAsOne(previous: AdfNode, node: AdfNode): boolean {
|
||||
return spelledAsText(previous) && spelledAsText(node) && identicalMarks(previous, node)
|
||||
}
|
||||
|
||||
function spelledAsText(node: AdfNode): boolean {
|
||||
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
|
||||
}
|
||||
Reference in New Issue
Block a user