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/spec/flavour.md b/spec/flavour.md index 5839b2d..d3dccb8 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -135,8 +135,10 @@ literal-text fallback — a typo that reparses as prose is the silent loss §2 r ## The opaque carry (AGENTS.md §3) A node no section spells where it stands — an unknown type, or a known one whose spelling belongs -to the other position — rides as its raw JSON and restores to a deep-equal node. Block and inline -positions canonicalize differently, each fitting where it sits: +to the other position — rides as its raw JSON and restores to a deep-equal node. A carry may hold +a node the emitter spells natively: it restores unreinterpreted, and the next emit spells it +canonically (AGENTS.md §2). Block and inline positions canonicalize differently, each fitting +where it sits: - **Block position**: a fenced code block with info string `adf`, body = the node's JSON — two-space indent, object keys sorted. 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/json-value.ts b/src/json-value.ts index 88b548a..5c3ef83 100644 --- a/src/json-value.ts +++ b/src/json-value.ts @@ -2,13 +2,13 @@ import { largestNesting } from './nesting.ts' export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue } -export function isJsonValue(value: unknown): value is JsonValue { +export function isJsonValue(value: unknown, levels: number = largestNesting): value is JsonValue { const pending: { depth: number; item: unknown }[] = [{ depth: 0, item: value }] while (pending.length > 0) { const entry = pending.pop() if (entry === undefined) continue const { depth, item } = entry - if (depth > largestNesting) return false + if (depth > levels) return false if (item === null || typeof item === 'boolean' || typeof item === 'string') continue if (typeof item === 'number') { if (!Number.isFinite(item)) return false diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 94dbf76..7005850 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -77,6 +77,15 @@ export function readInlineDirective(text: string, index: number): Read { + if (span.content !== undefined) return { fault: unsupportedNodeShape(`${span.name} takes no content`) } + const spelled = span.attributes.get(key) + if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupportedNodeShape(`${span.name} holds one ${key} attribute alone`) } + const spelling = spellStringAttribute(spelled.decoded) + if (spelling !== spelled.spelling) return { fault: unsupportedNodeShape(`${span.name} spells its ${key} attribute as ${key}=${spelling}`) } + return { value: spelled.decoded } +} + // Both directions answer alike: an inline directive never spans lines, so no content slot holds a line ending. export function slotLineEndingFault(type: string, text: string): ConvertFault | undefined { if (!/[\n\r]/.test(text)) return undefined @@ -116,6 +125,10 @@ export function unknownDirectiveFault(name: string): ConvertFault { return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node` } } +export function unsupportedNodeShape(message: string): ConvertFault { + return { code: 'unsupported-node-shape', message } +} + function keyOrder(left: string, right: string): number { if (left < right) return -1 return left > right ? 1 : 0 diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 2d2d399..9af0e95 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -167,11 +167,17 @@ test('breaks a mark run at the node it carries', () => { ) }) -test('refuses a carried node nested deeper than the emitter carries', () => { +test('refuses a carried node nested deeper than the levels its position leaves', () => { let node: AdfNode = { type: 'blockCard' } for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockCard' } assert.equal(code(adfToMarkdown(document(node))), 'unsupported-nesting-depth') assert.equal(code(adfToMarkdown(document(paragraph(node)))), 'unsupported-nesting-depth') + let shallow: AdfNode = { type: 'blockCard' } + for (let depth = 0; depth < 200; depth += 1) shallow = { content: [shallow], type: 'blockCard' } + assert.ok(adfToMarkdown(document(shallow)).ok) + let quoted: AdfNode = shallow + for (let depth = 0; depth < 150; depth += 1) quoted = { content: [quoted], type: 'blockquote' } + assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth') }) test('refuses a node whose content model the canonical form cannot emit', () => { diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 7125c34..d7cfef3 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -73,7 +73,7 @@ function interruptsParagraph(node: AdfNode): boolean { function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result { const directive = blockDirective(node.type) - if (directive === undefined) return commonMarkLine(carriedBlock(node, path)) + if (directive === undefined) return commonMarkLine(carriedBlock(node, path, depth)) const readable = readableBlock(node, path, depth) if (readable !== undefined) return readable return emitDirectiveBlock(node, directive, path, depth) @@ -114,9 +114,9 @@ function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: Conv if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path) const content = node.content ?? [] if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path) - if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path) + if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth) const header = spellDirectiveHeader(node, directive) - if (header === undefined) return commonMarkLine(carriedBlock(node, path)) + if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (directive.contentModel === 'none' || (directive.contentModel === 'inline' && content.length === 0)) { return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` }) } @@ -154,10 +154,10 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result { +function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { const slot = languageSlot(node.attrs?.['language']) const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language']) - if (header === undefined) return commonMarkLine(carriedBlock(node, path)) + if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth)) const text = codeBlockText(node, path) if (!text.ok) return text const info = slot.kind === 'fence' ? slot.info : '' diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 61262f4..9d5db85 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -1,29 +1,70 @@ import type { AdfNode } from '../adf/document.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, readSoleStringAttribute, spellAttributes, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { serializeCanonicalJson } from '../canonical-json.ts' -import { spellAttributes, spellStringAttribute } from './directive-syntax.ts' export const carryName = 'adf' -export function carriedBlock(node: AdfNode, path: ConvertErrorPath): Result { - const json = carriedJson(node, 'two-space', path) +const jsonAttribute = 'json' + +export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result { + const json = carriedJson(node, 'two-space', path, largestNesting - depth) if (!json.ok) return json return success(fencedCodeBlock(carryName, json.value)) } export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result { - const json = carriedJson(node, 'compact', path) + const json = carriedJson(node, 'compact', path, largestNesting) if (!json.ok) return json - return success(`:${carryName}${spellAttributes([['json', spellStringAttribute(json.value)]])}`) + return success(`:${carryName}${spellAttributes([[jsonAttribute, spellStringAttribute(json.value)]])}`) } -function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath): Result { - if (!isJsonValue(node)) { - return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${largestNesting} levels the emitter carries`, path) +export function readCarriedBlock(body: string, depth: number): Read { + return readCarriedJson(body, 'two-space', largestNesting - depth) +} + +export function readCarriedInline(span: DirectiveSpan): Read | undefined { + if (span.name !== carryName) return undefined + const spelled = readSoleStringAttribute(span, jsonAttribute) + if (spelled.fault !== undefined) return spelled + return readCarriedJson(spelled.value, 'compact', largestNesting) +} + +function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result { + if (!isJsonValue(node, levels)) { + return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path) } return success(serializeCanonicalJson(node, spelling)) } + +function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): Read { + const parsed = parseJsonText(raw) + if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') } + const { value } = parsed + if (!isJsonValue(value, levels)) { + // Unbounded, the same walk parts the two causes one `false` holds (AGENTS.md §8). + if (!isJsonValue(value, Number.POSITIVE_INFINITY)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') } + 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") } + return { value } +} + +function parseJsonText(raw: string): { value: unknown } | undefined { + try { + const value: unknown = JSON.parse(raw) + return { value } + } catch { + return undefined + } +} diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 5cd322b..6df91e7 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' }]) - const slot = slotNodes(scan, span.content) + if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] }) + const slot = slotContent(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 slotContent(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..b93f805 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -266,10 +266,91 @@ 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 its position leaves', () => { + const nested = (levels: number): string => `${'['.repeat(levels)}${']'.repeat(levels)}` + const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`adf\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{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting)) + assert.equal( + content(markdownToAdf(fence('', largestNesting + 1))), + "unsupported-node-shape: the opaque carry spells its node's JSON canonically: two-space indent, keys sorted", + ) + assert.equal(content(markdownToAdf(fence('> ', largestNesting + 1))), deeper(largestNesting - 1)) +}) + +test('names the number no JSON spelling carries in an opaque carry', () => { + const named = 'unsupported-node-shape: the opaque carry holds a number JSON cannot spell' + assert.equal(content(markdownToAdf(':adf{json="{\\"attrs\\":{\\"width\\":1e999},\\"type\\":\\"blockCard\\"}"}\n')), named) + assert.equal(content(markdownToAdf('```adf\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' + 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..a98be1f 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' @@ -36,7 +36,7 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro case 'bulletList': return listNode({ type: 'bulletList' }, block.items, definitions, path, depth) case 'code': - return codeBlockNode(block.language, block.text, path) + return codeBlockNode(block.language, block.text, path, depth) case 'directive': return directiveNode(block, definitions, path, depth) case 'fault': @@ -134,8 +134,12 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef return success({ ...node, content }) } -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) +function codeBlockNode(language: string, text: string, path: ConvertErrorPath, depth: number): Result { + if (language === carryName) { + const carried = readCarriedBlock(text, depth) + 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/src/markdown/text-directive.ts b/src/markdown/text-directive.ts index f3d7eec..616b593 100644 --- a/src/markdown/text-directive.ts +++ b/src/markdown/text-directive.ts @@ -1,6 +1,5 @@ -import type { ConvertFault } from '../result.ts' import type { DirectiveSpan, Read } from './directive-syntax.ts' -import { spellAttributes, spellLeafDirective, spellStringAttribute } from './directive-syntax.ts' +import { readSoleStringAttribute, spellAttributes, spellLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' const name = 'text' const whitespaceRun = /^(?:[ \t]+|\n+)$/ @@ -13,15 +12,8 @@ export function spellTextDirective(text: string): string { export function readTextDirective(span: DirectiveSpan): Read | undefined { if (span.name !== name) return undefined - if (span.content !== undefined) return { fault: unsupported(`${name} takes no content`) } - const spelled = span.attributes.get(name) - if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupported(`${name} holds one ${name} attribute alone`) } - const spelling = spellStringAttribute(spelled.decoded) - if (spelling !== spelled.spelling) return { fault: unsupported(`${name} spells its ${name} attribute as ${name}=${spelling}`) } - if (!whitespaceRun.test(spelled.decoded)) return { fault: unsupported(`${name} spells one run of spaces and tabs, or one run of newlines`) } - return { value: spelled.decoded } -} - -function unsupported(message: string): ConvertFault { - return { code: 'unsupported-node-shape', message } + const spelled = readSoleStringAttribute(span, name) + if (spelled.fault !== undefined) return spelled + if (!whitespaceRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, or one run of newlines`) } + return spelled } 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..681631c 100644 --- a/todo.md +++ b/todo.md @@ -29,9 +29,8 @@ The numbering is the order the work was planned in, not the order it ships. - [x] **2e5 — Combined documents and the collision property.** - [x] **2f — The attributes CommonMark cannot hold.** - [ ] **3 — `markdownToAdf` (`0.1.0`).** Each sub-item lands the fixtures its own code reads, and - the runner grows a parse half as they do: `parsingDirectories` beside `emittingDirectories`, a - round-trip directory joining it only once every fixture in it reads back to its document, - and readers for `corpus/normalization/` (setext, indented code, loose lists, `*`/`+` + the runner grows a parse half as they do: readers for `corpus/normalization/` (setext, + indented code, loose lists, `*`/`+` bullets, entity references, soft wraps — one-way, the markdown not canonical) and `corpus/errors/` (a markdown input per named error, the code in a `.error` beside it) with the first fixture each. `commonmark-subset/` cannot be the first to green — `::paragraph` @@ -51,17 +50,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. @@ -123,7 +112,9 @@ The numbering is the order the work was planned in, not the order it ships. landed the last decision `corpus/unspellable/` held and the directory went with it, so what the code list holds from here is permanent. The parser's own code additions are read here as one list before that freeze — nine sessions mint them - independently, and one cause wearing two codes is breaking to undo after `0.1.0`. That read + independently, and one cause wearing two codes is breaking to undo after `0.1.0` — one is + known already: a json attribute value past 500 levels reads `unsupported-node-shape` on + parse but `unsupported-nesting-depth` through the carry on emit. That read gets a test rather than an eye — every `ConvertErrorCode` member named at a production call site, the way `spec.test.ts` guards the node tables — since `unspelled-block-separation` outlived its cause until 3h went looking. `0.1.0`