diff --git a/README.md b/README.md index 52cdc73..3e17f7d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and an HTML dialect. -**Status: pre-release — `adfToMarkdown` only, and not yet every document.** +**Status: pre-release — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML not +yet.** Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar: [`spec/flavour.md`](spec/flavour.md). diff --git a/corpus/errors/carry-inside-mark.error b/corpus/errors/carry-inside-mark.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/carry-inside-mark.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/carry-inside-mark.md b/corpus/errors/carry-inside-mark.md new file mode 100644 index 0000000..10bec4d --- /dev/null +++ b/corpus/errors/carry-inside-mark.md @@ -0,0 +1 @@ +_a :adf{json="{\"type\":\"blockCard\"}"} b_ diff --git a/corpus/errors/carry-invalid-json-inline.error b/corpus/errors/carry-invalid-json-inline.error new file mode 100644 index 0000000..4f35411 --- /dev/null +++ b/corpus/errors/carry-invalid-json-inline.error @@ -0,0 +1 @@ +malformed-directive diff --git a/corpus/errors/carry-invalid-json-inline.md b/corpus/errors/carry-invalid-json-inline.md new file mode 100644 index 0000000..db12419 --- /dev/null +++ b/corpus/errors/carry-invalid-json-inline.md @@ -0,0 +1 @@ +:adf{json="{"} diff --git a/corpus/errors/carry-invalid-json.error b/corpus/errors/carry-invalid-json.error new file mode 100644 index 0000000..4f35411 --- /dev/null +++ b/corpus/errors/carry-invalid-json.error @@ -0,0 +1 @@ +malformed-directive diff --git a/corpus/errors/carry-invalid-json.md b/corpus/errors/carry-invalid-json.md new file mode 100644 index 0000000..1d3e3ff --- /dev/null +++ b/corpus/errors/carry-invalid-json.md @@ -0,0 +1,3 @@ +```adf +{"type": +``` diff --git a/corpus/errors/carry-nesting-depth.error b/corpus/errors/carry-nesting-depth.error new file mode 100644 index 0000000..f54ac45 --- /dev/null +++ b/corpus/errors/carry-nesting-depth.error @@ -0,0 +1 @@ +unsupported-nesting-depth diff --git a/corpus/errors/carry-nesting-depth.md b/corpus/errors/carry-nesting-depth.md new file mode 100644 index 0000000..ec39271 --- /dev/null +++ b/corpus/errors/carry-nesting-depth.md @@ -0,0 +1 @@ +:adf{json="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"} diff --git a/package.json b/package.json index 706336c..2746c2c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "node": ">=18" }, "scripts": { - "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=97.8 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", + "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=97.9 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json" }, "devDependencies": { diff --git a/src/adf/document.ts b/src/adf/document.ts index fc696c9..7545571 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -37,6 +37,10 @@ export function isAdfDocument(value: unknown): value is AdfDocument { return !('content' in value) || isNodeArray(value['content']) } +export function isAdfNode(value: unknown): value is AdfNode { + return isNodeArray([value]) +} + export function isAdfMark(value: unknown): value is AdfMark { if (!isRecord(value) || !holdsOnly(value, markKeys)) return false if (typeof value['type'] !== 'string') return false diff --git a/src/corpus.test.ts b/src/corpus.test.ts index 0d9cfee..dcd7674 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -15,9 +15,7 @@ const errorsRoot = join(corpusRoot, 'errors') const normalizationRoot = join(corpusRoot, 'normalization') const roundTripRoot = join(corpusRoot, 'round-trip') -const emittingDirectories = ['block-nodes', 'combinations', 'commonmark-subset', 'inline-nodes', 'opaque-carry'] -// A directory joins once every fixture in it reads back to its document. -const parsingDirectories = ['block-nodes', 'commonmark-subset', 'inline-nodes'] +const roundTripDirectories = ['block-nodes', 'combinations', 'commonmark-subset', 'inline-nodes', 'opaque-carry'] function directoryNames(root: string): string[] { return readdirSync(root, { withFileTypes: true }) @@ -63,11 +61,11 @@ test('every corpus directory is a kind the runner reads', () => { assert.deepEqual(directoryNames(corpusRoot), ['errors', 'normalization', 'round-trip']) }) -test('every round-trip directory emits', () => { - assert.deepEqual(directoryNames(roundTripRoot), [...emittingDirectories].sort()) +test('every round-trip directory is a kind the runner reads', () => { + assert.deepEqual(directoryNames(roundTripRoot), [...roundTripDirectories].sort()) }) -for (const directory of emittingDirectories) { +for (const directory of roundTripDirectories) { const names = [...new Set([...fixtureNames(directory, '.json'), ...fixtureNames(directory, '.md')])].sort() test(`${directory} pairs every .json with a .md`, () => { @@ -89,14 +87,7 @@ for (const directory of emittingDirectories) { } } -test('every parsing directory is one of the emitting directories', () => { - assert.deepEqual( - parsingDirectories.filter((directory) => emittingDirectories.includes(directory)), - parsingDirectories, - ) -}) - -for (const directory of parsingDirectories) { +for (const directory of roundTripDirectories) { for (const name of fixtureNames(directory, '.md')) { test(`${directory}/${name} reads its markdown back to the document beside it`, () => { const expected: unknown = JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8')) @@ -109,7 +100,7 @@ for (const directory of parsingDirectories) { } function roundTripFixtures(): { name: string; path: string }[] { - return emittingDirectories.flatMap((directory) => + return roundTripDirectories.flatMap((directory) => fixtureNames(directory, '.json').map((name) => ({ name: `${directory}/${name}`, path: join(roundTripRoot, directory, `${name}.json`) })), ) } @@ -178,7 +169,7 @@ test('the fence nesting check catches a fence a container cannot hold', () => { assert.equal(fenceNestingFault(':::tableCell\n```text\n:::::::panel warning\n:::\n```\n:::'), undefined) }) -for (const directory of emittingDirectories) { +for (const directory of roundTripDirectories) { for (const name of fixtureNames(directory, '.md')) { test(`${directory}/${name} fences every container longer than its body`, () => { assert.equal(fenceNestingFault(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8')), undefined) diff --git a/src/index.ts b/src/index.ts index 056730f..4210fb3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,4 @@ export type { ConvertError, ConvertErrorCode, Result } from './result.ts' export type { JsonValue } from './json-value.ts' export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' export { isAdfDocument } from './adf/document.ts' +export { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 61262f4..9117c31 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -1,14 +1,19 @@ import type { AdfNode } from '../adf/document.ts' +import type { ConvertFault } from '../result.ts' +import type { DirectiveSpan, Read } from './directive-syntax.ts' import type { JsonSpelling } from '../canonical-json.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' +import { isAdfNode } from '../adf/document.ts' import { isJsonValue } from '../json-value.ts' import { fencedCodeBlock } from './backtick-runs.ts' import { largestNesting } from '../nesting.ts' +import { malformedDirective, spellAttributes, spellStringAttribute } from './directive-syntax.ts' import { serializeCanonicalJson } from '../canonical-json.ts' -import { spellAttributes, spellStringAttribute } from './directive-syntax.ts' export const carryName = 'adf' +const jsonAttribute = 'json' + export function carriedBlock(node: AdfNode, path: ConvertErrorPath): Result { const json = carriedJson(node, 'two-space', path) if (!json.ok) return json @@ -18,7 +23,21 @@ export function carriedBlock(node: AdfNode, path: ConvertErrorPath): Result { const json = carriedJson(node, 'compact', path) if (!json.ok) return json - return success(`:${carryName}${spellAttributes([['json', spellStringAttribute(json.value)]])}`) + return success(`:${carryName}${spellAttributes([[jsonAttribute, spellStringAttribute(json.value)]])}`) +} + +export function readCarriedBlock(body: string): Read { + return readCarriedJson(body, 'two-space') +} + +export function readCarriedInline(span: DirectiveSpan): Read | undefined { + if (span.name !== carryName) return undefined + if (span.content !== undefined) return { fault: unsupported(`${carryName} takes no content`) } + const spelled = span.attributes.get(jsonAttribute) + if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupported(`${carryName} holds one ${jsonAttribute} attribute alone`) } + const spelling = spellStringAttribute(spelled.decoded) + if (spelling !== spelled.spelling) return { fault: unsupported(`${carryName} spells its ${jsonAttribute} attribute as ${jsonAttribute}=${spelling}`) } + return readCarriedJson(spelled.decoded, 'compact') } function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath): Result { @@ -27,3 +46,31 @@ function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPa } return success(serializeCanonicalJson(node, spelling)) } + +function readCarriedJson(raw: string, spelling: JsonSpelling): Read { + const parsed = parseJsonText(raw) + if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') } + const { value } = parsed + if (!isJsonValue(value)) { + return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${largestNesting} levels the parser carries` } } + } + if (serializeCanonicalJson(value, spelling) !== raw) { + const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted' + return { fault: unsupported(`the opaque carry spells its node's JSON canonically: ${shape}`) } + } + if (!isAdfNode(value)) return { fault: unsupported("the opaque carry holds one ADF node's JSON") } + return { value } +} + +function parseJsonText(raw: string): { value: unknown } | undefined { + try { + const value: unknown = JSON.parse(raw) + return { value } + } catch { + return undefined + } +} + +function unsupported(message: string): ConvertFault { + return { code: 'unsupported-node-shape', message } +} diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 5cd322b..917cada 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -9,12 +9,13 @@ import { failure, faulted, success, type ConvertErrorPath, type Result } from '. import { inlineDirective } from '../../adf/inline-directives.ts' import { mergeAdjacentText } from '../../adf/editor-normal.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' +import { readCarriedInline } from '../opaque-carry.ts' import { readDirectiveMark } from './directive-marks.ts' import { readInlineDirective } from '../directive-syntax.ts' import { readInlineDirectiveNode } from './directive-nodes.ts' import { readTextDirective } from '../text-directive.ts' -export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] } +export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] } export type LinkDefinitions = ReadonlyMap @@ -24,6 +25,7 @@ type Pairing = EmphasisPairing type Piece = | Bracket + | { kind: 'carry'; node: AdfNode } | { alt: string; kind: 'image'; node: AdfNode } | { kind: 'nodes'; nodes: AdfNode[] } | { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number } @@ -32,6 +34,9 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb type Scan = { definitions: LinkDefinitions; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string } +type SlotContent = { carry: boolean; nodes: AdfNode[] } + +const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included' const imageAlone = 'an image fits only as a paragraph of its own' export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result { @@ -152,38 +157,44 @@ function readDirective(scan: Scan, index: number): Result { return success(index + 1) } if (directive.fault !== undefined) return faulted(directive.fault, scan.path) - const nodes = directiveNodes(scan, directive.value) - if (!nodes.ok) return nodes + const piece = directivePiece(scan, directive.value) + if (!piece.ok) return piece flush(scan, false) - scan.pieces.push({ kind: 'nodes', nodes: nodes.value }) + scan.pieces.push(piece.value) return success(index + directive.value.length) } -function directiveNodes(scan: Scan, span: DirectiveSpan): Result { +function directivePiece(scan: Scan, span: DirectiveSpan): 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 }) + } const text = readTextDirective(span) if (text?.fault !== undefined) return faulted(text.fault, scan.path) - if (text !== undefined) return success([{ text: text.value, type: 'text' }]) + if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] }) const slot = slotNodes(scan, span.content) if (!slot.ok) return slot const mark = readDirectiveMark(span.name, span.attributes, scan.path) if (mark !== undefined) { if (!mark.ok) return mark - if (slot.value === undefined || slot.value.length === 0) { + if (slot.value === undefined || slot.value.nodes.length === 0) { return failure('unsupported-node-shape', `the ${span.name} mark wraps the [content] it marks`, scan.path) } - return success(applyMark(slot.value, mark.value)) + if (slot.value.carry) return failure('unsupported-node-shape', carriedInMark, scan.path) + return success({ kind: 'nodes', nodes: applyMark(slot.value.nodes, mark.value) }) } - const node = readInlineDirectiveNode(span.name, span.attributes, slot.value, scan.path) + const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path) if (!node.ok) return node - return success([node.value]) + return success({ kind: 'nodes', nodes: [node.value] }) } -function slotNodes(scan: Scan, content: string | undefined): Result { +function slotNodes(scan: Scan, content: string | undefined): Result { if (content === undefined) return success(undefined) const parsed = parseInline(content, scan.definitions, scan.path, false) if (!parsed.ok) return parsed if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path) - return success(parsed.value.nodes) + return success(parsed.value) } function flush(scan: Scan, strip: boolean): void { @@ -200,7 +211,13 @@ function assemble(scan: Scan): Result { 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) - return success({ nodes: resolveNodes(scan.pieces) }) + const nodes = resolveNodes(scan.pieces, scan.path) + if (!nodes.ok) return nodes + return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value }) +} + +function holdsCarry(pieces: readonly Piece[]): boolean { + return pieces.some((piece) => piece.kind === 'carry') } function holdsImage(pieces: readonly Piece[]): boolean { @@ -286,7 +303,10 @@ function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definitio // `false` where the link text is empty: the mark has no node to ride, so the brackets stay text. function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result { if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path) - const nodes = resolveNodes(inner) + if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path) + const resolved = resolveNodes(inner, scan.path) + if (!resolved.ok) return resolved + const nodes = resolved.value if (nodes.length === 0) return success(false) const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title } scan.pieces.length = at @@ -298,15 +318,19 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result { if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path) - const alt = imageAlt(inner) + const resolved = imageAlt(inner, scan.path) + if (!resolved.ok) return resolved + const alt = resolved.value const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination } scan.pieces.length = at scan.pieces.push({ alt, kind: 'image', node: { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } }) return success(null) } -function imageAlt(inner: readonly Piece[]): string { - return resolveNodes(inner).map(altText).join('') +function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result { + const nodes = resolveNodes(inner, path) + if (!nodes.ok) return nodes + return success(nodes.value.map(altText).join('')) } // spec/flavour.md, The CommonMark image: the description's plain text, the content slot included. @@ -317,18 +341,20 @@ function altText(node: AdfNode): string { return typeof spelled === 'string' ? spelled : (node.text ?? '') } -function resolveNodes(pieces: readonly Piece[]): AdfNode[] { +function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath): Result { const nodes = pieces.map(pieceNodes) const runs = delimiterRuns(pieces) const pairings = matchEmphasis(runs) writeUnpaired(nodes, runs, pairings) - markPairings(nodes, pairings) - return mergeAdjacentText(nodes.flat()) + if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path) + return success(mergeAdjacentText(nodes.flat())) } // Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first. function pieceNodes(piece: Piece): AdfNode[] { switch (piece.kind) { + case 'carry': + return [piece.node] case 'image': return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }] case 'nodes': @@ -364,11 +390,15 @@ function writeUnpaired(nodes: AdfNode[][], runs: readonly Run[], pairings: reado } // Innermost pairing first, so prepending leaves the marks array outermost first (spec/flavour.md, Marks). -function markPairings(nodes: AdfNode[][], pairings: readonly Pairing[]): void { +function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: readonly Pairing[]): boolean { for (const pairing of pairings) { const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) } - for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) nodes[index] = applyMark(nodes[index] ?? [], mark) + for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) { + if (pieces[index]?.kind === 'carry') return false + nodes[index] = applyMark(nodes[index] ?? [], mark) + } } + return true } function markType(character: string, used: number): string { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index d7883d7..611d88c 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -266,10 +266,78 @@ test('names the reserved carry name a block directive spells', () => { const reserved = 'malformed-directive: the name adf is reserved for the opaque carry, whose block form is the fence' assert.equal(content(markdownToAdf('::adf\n')), reserved) assert.equal(content(markdownToAdf(':::adf\nx\n:::\n')), reserved) - assert.equal(content(markdownToAdf('```adf\nx\n```\n')), 'malformed-directive: the info string adf is reserved for the opaque carry') assert.deepEqual(content(markdownToAdf('```adfx\nx\n```\n')), [{ attrs: { language: 'adfx' }, content: [text('x')], type: 'codeBlock' }]) }) +const carried = ':adf{json="{\\"type\\":\\"placeholder\\"}"}' + +test('reads the adf fence back to the node its JSON holds', () => { + assert.deepEqual(content(markdownToAdf('```adf\n{\n "attrs": {\n "url": "https://example.com/x"\n },\n "type": "blockCard"\n}\n```\n')), [ + { attrs: { url: 'https://example.com/x' }, type: 'blockCard' }, + ]) +}) + +test('reads the inline carry back to the node its json attribute holds', () => { + assert.deepEqual(content(markdownToAdf(`a ${carried} b\n`)), [ + { content: [text('a '), { type: 'placeholder' }, text(' b')], type: 'paragraph' }, + ]) +}) + +test('names the invalid JSON no opaque carry holds', () => { + const invalid = 'malformed-directive: the opaque carry holds invalid JSON' + assert.equal(content(markdownToAdf('```adf\n{"type":\n```\n')), invalid) + assert.equal(content(markdownToAdf('```adf\n```\n')), invalid) + assert.equal(content(markdownToAdf(':adf{json="{"}\n')), invalid) + assert.equal(content(markdownToAdf(':adf{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('```adf\n{"type":"blockCard"}\n```\n')), `${canonically}two-space indent, keys sorted`) + assert.equal(content(markdownToAdf(':adf{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`) + assert.equal(content(markdownToAdf(':adf{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" + assert.equal(content(markdownToAdf('```adf\n[]\n```\n')), node) + assert.equal(content(markdownToAdf(':adf{json=null}\n')), node) + assert.equal(content(markdownToAdf(':adf{json="{\\"kind\\":\\"x\\"}"}\n')), node) +}) + +test('names the shape the inline carry reads alone', () => { + assert.equal(content(markdownToAdf(':adf[x]{json="{}"}\n')), 'unsupported-node-shape: adf takes no content') + assert.equal(content(markdownToAdf(':adf{}\n')), 'unsupported-node-shape: adf holds one json attribute alone') + assert.equal(content(markdownToAdf(':adf{json="{}" localId=x}\n')), 'unsupported-node-shape: adf holds one json attribute alone') + assert.equal(content(markdownToAdf(':adf{json="null"}\n')), 'unsupported-node-shape: adf spells its json attribute as json=null') +}) + +test('holds a carried JSON value to the nesting the parser carries', () => { + const deep = `:adf{json="${'['.repeat(largestNesting + 2)}${']'.repeat(largestNesting + 2)}"}\n` + assert.equal(content(markdownToAdf(deep)), `unsupported-nesting-depth: a carried node's JSON nests deeper than the ${largestNesting} levels the parser carries`) +}) + +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' + assert.equal(content(markdownToAdf(`_a ${carried} b_\n`)), named) + assert.equal(content(markdownToAdf(`**${carried}**\n`)), named) + assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named) + assert.equal(content(markdownToAdf(`[a ${carried} b](https://example.com/x)\n`)), named) + assert.equal(content(markdownToAdf(`:underline[${carried}]\n`)), named) + assert.equal(content(markdownToAdf(`:textColor[a ${carried}]{color="#ae2e24"}\n`)), named) + assert.equal(content(markdownToAdf(`![_a ${carried}_](https://example.com/i)\n`)), named) +}) + +test('keeps the carry a mark spelling does not wrap', () => { + assert.deepEqual(content(markdownToAdf(`[a ${carried} b]\n`)), [ + { content: [text('[a '), { type: 'placeholder' }, text(' b]')], type: 'paragraph' }, + ]) + assert.deepEqual(content(markdownToAdf(`**a**${carried}**b**\n`)), [ + { content: [marked('a', strong), { type: 'placeholder' }, marked('b', strong)], type: 'paragraph' }, + ]) + assert.deepEqual(content(markdownToAdf(`![a ${carried} b](https://example.com/i)\n`)), [image('https://example.com/i', 'a b')]) +}) + test('reads each attribute value as the type its section assigns', () => { assert.deepEqual(content(markdownToAdf('::media {height=10 id=a-1 type=file url="/x y" width="20.5"}\n')), [ { attrs: { height: 10, id: 'a-1', type: 'file', url: '/x y', width: 20.5 }, type: 'media' }, diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 52d6928..eac2298 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -2,7 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { Block, DirectiveBlock } from './blocks.ts' import type { BlockDirectiveNode } from './directive-nodes.ts' import type { LinkDefinitions } from './inline-content.ts' -import { carryName } from '../opaque-carry.ts' +import { carryName, readCarriedBlock } from '../opaque-carry.ts' import { commonMarkSpelling } from '../emit/adf-to-markdown.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { languageSlot } from '../code-language.ts' @@ -135,7 +135,11 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef } function codeBlockNode(language: string, text: string, path: ConvertErrorPath): Result { - if (language === carryName) return failure('malformed-directive', `the info string ${carryName} is reserved for the opaque carry`, path) + if (language === carryName) { + const carried = readCarriedBlock(text) + if (carried.fault !== undefined) return faulted(carried.fault, path) + return success(carried.value) + } const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' } return success(text === '' ? node : { ...node, content: [{ text, type: 'text' }] }) } diff --git a/todo-history.md b/todo-history.md index 382dd41..b25df0f 100644 --- a/todo-history.md +++ b/todo-history.md @@ -375,3 +375,14 @@ Under **3 — `markdownToAdf` (`0.1.0`)**: CommonMark parsed to a document `adfToMarkdown` then refused. The edges that check covered are carried before the line is assembled, so narrowing it to spaces and tabs left it no cause and it goes with them. + - [x] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back: + the `adf` fence and `:adf{json="…"}` restoring a deep-equal node, invalid JSON in either a + named error, a carry inside a mark spelling another, and the three carve-outs' escapes + reading as the literal text they hold. 3g refuses the `adf` fence rather than reading a + `codeBlock` from it; the refusal goes when the carry reads it. 3i left the slot parse + contextless, so the refusal a carry inside a mark spelling earns needs a channel — a reader + context in place of `parseInline`'s `strip` flag, or a return arm from the slot — and + `directiveNodes` takes its fourth reader beside it. + `index.ts` gains `markdownToAdf` here, and the README's status line with it: this is the + last parser chunk, so `parsingDirectories` becomes `emittingDirectories` and the whole + corpus round-trips both ways — `0.1.0`'s proof, which 4 widens rather than replaces. diff --git a/todo.md b/todo.md index 6198cc1..b157f57 100644 --- a/todo.md +++ b/todo.md @@ -51,17 +51,7 @@ The numbering is the order the work was planned in, not the order it ships. - [x] **3g — The node tables read backwards.** - [x] **3h — The block nodes.** - [x] **3i — The inline nodes and the marks.** - - [ ] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back: - the `adf` fence and `:adf{json="…"}` restoring a deep-equal node, invalid JSON in either a - named error, a carry inside a mark spelling another, and the three carve-outs' escapes - reading as the literal text they hold. 3g refuses the `adf` fence rather than reading a - `codeBlock` from it; the refusal goes when the carry reads it. 3i left the slot parse - contextless, so the refusal a carry inside a mark spelling earns needs a channel — a reader - context in place of `parseInline`'s `strip` flag, or a return arm from the slot — and - `directiveNodes` takes its fourth reader beside it. - `index.ts` gains `markdownToAdf` here, and the README's status line with it: this is the - last parser chunk, so `parsingDirectories` becomes `emittingDirectories` and the whole - corpus round-trips both ways — `0.1.0`'s proof, which 4 widens rather than replaces. + - [x] **3j — The carry and the combinations.** - [ ] **3k — The CommonMark spec suite (`0.2.0`).** Checked in at `corpus/commonmark-spec/`, pinned to the version it ships — the one `html-blocks.ts` names for its start conditions — `corpus/README.md` gaining the kind.