diff --git a/AGENTS.md b/AGENTS.md index 9b3abe2..2b92ede 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,9 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros ## 3. Unknown input policy - Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats and - restores to a deep-equal node. The round-trip holds for documents newer than the library. + restores to a deep-equal node. The round-trip holds for documents newer than the library. It is + the node *type* that is unknown: a known type standing where its content model forbids stays an + error result, never a carry. - Unmappable foreign HTML element: error result naming the element — never a silent drop. - Bare `@name` / `:smile:` in typed text: stays a text node. Only directives produce mention/emoji/media nodes; resolving names to ids needs I/O, which is the consumer's job. @@ -116,8 +118,8 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga - A readable spelling tried ahead of a general one — the image, the pipe table, a pipe cell — returns `string | undefined`, never a `Result`: any failure is the fallback signal, and the general form owns the refusal. Refusing there refuses a document the general form spells. -- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and attribute values - are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that +- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks, attribute values and + a carried node's JSON are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that waits near 2000. - No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states diff --git a/README.md b/README.md index 15b531f..b463488 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and an HTML dialect. -**Status: pre-release — `adfToMarkdown` emits every node the flavour spells, nothing else is -built.** +**Status: pre-release — `adfToMarkdown` emits every node the flavour spells but the carry's +attribute-level fallback, nothing else is built.** Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar: [`spec/flavour.md`](spec/flavour.md). diff --git a/src/adf-to-markdown.ts b/src/adf-to-markdown.ts index 7443e94..1e1983d 100644 --- a/src/adf-to-markdown.ts +++ b/src/adf-to-markdown.ts @@ -10,7 +10,7 @@ import { failure, success, type ConvertErrorPath, type Result } from './result.t import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts' import { isAdfDocument } from './adf-document.ts' import { largestNesting } from './nesting.ts' -import { longestBacktickRun } from './backtick-runs.ts' +import { fencedCodeBlock } from './backtick-runs.ts' type BlockContainer = 'directive' | 'document' | 'list-item' type BlockSpelling = 'commonmark' | 'directive' @@ -81,7 +81,7 @@ function interruptsParagraph(node: AdfNode): boolean { function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result { if (node.type === 'blockquote') return commonMarkContainer(emitBlockquote(node, path, depth)) if (node.type === 'bulletList' || node.type === 'orderedList') return commonMarkContainer(emitList(node, path, depth)) - if (node.type === 'codeBlock') return commonMarkLine(node.attrs?.['language'] === carryName ? carriedBlock(node, path) : emitCodeBlock(node, path)) + if (node.type === 'codeBlock') return commonMarkLine(emitCodeBlock(node, path)) if (node.type === 'heading') return commonMarkLine(emitHeading(node, path)) if (node.type === 'paragraph') return emitParagraph(node, path) if (node.type === 'rule') return commonMarkLine(emitRule(node, path)) @@ -160,6 +160,7 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R } function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result { + if (node.attrs?.['language'] === carryName) return carriedBlock(node, path) const validation = validateBlockNode(node, ['language'], path) if (!validation.ok) return validation const info = spellCodeFenceInfo(node.attrs?.['language'], path) @@ -174,9 +175,7 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result { if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath) text += child.text } - const fence = '`'.repeat(Math.max(3, longestBacktickRun(text) + 1)) - const opening = `${fence}${info.value}` - return success(text === '' ? `${opening}\n${fence}` : `${opening}\n${text}\n${fence}`) + return success(fencedCodeBlock(info.value, text)) } function spellCodeFenceInfo(language: JsonValue | undefined, path: ConvertErrorPath): Result { diff --git a/src/backtick-runs.ts b/src/backtick-runs.ts index e5580db..ab3a15a 100644 --- a/src/backtick-runs.ts +++ b/src/backtick-runs.ts @@ -1,3 +1,8 @@ +export function fencedCodeBlock(info: string, body: string): string { + const fence = '`'.repeat(Math.max(3, longestBacktickRun(body) + 1)) + return body === '' ? `${fence}${info}\n${fence}` : `${fence}${info}\n${body}\n${fence}` +} + export function longestBacktickRun(text: string): number { let longest = 0 let current = 0 diff --git a/src/opaque-carry.ts b/src/opaque-carry.ts index 30145c4..2acbd92 100644 --- a/src/opaque-carry.ts +++ b/src/opaque-carry.ts @@ -2,8 +2,8 @@ import type { AdfNode } from './adf-document.ts' import type { JsonSpelling } from './canonical-json.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { isJsonValue } from './json-value.ts' +import { fencedCodeBlock } from './backtick-runs.ts' import { largestNesting } from './nesting.ts' -import { longestBacktickRun } from './backtick-runs.ts' import { serializeCanonicalJson } from './canonical-json.ts' import { spellAttributes, spellStringAttribute } from './directive-attributes.ts' @@ -12,8 +12,7 @@ export const carryName = 'adf' export function carriedBlock(node: AdfNode, path: ConvertErrorPath): Result { const json = carriedJson(node, 'two-space', path) if (!json.ok) return json - const fence = '`'.repeat(Math.max(3, longestBacktickRun(json.value) + 1)) - return success(`${fence}${carryName}\n${json.value}\n${fence}`) + return success(fencedCodeBlock(carryName, json.value)) } export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result { @@ -24,7 +23,7 @@ export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result { if (!isJsonValue(node)) { - return failure('unsupported-node-shape', `a carried node nests deeper than the ${largestNesting} levels the emitter carries`, path) + return failure('unsupported-node-shape', `a carried node's JSON nests deeper than the ${largestNesting} levels the emitter carries`, path) } return success(serializeCanonicalJson(node, spelling)) } diff --git a/todo.md b/todo.md index 24618b8..c4de1dc 100644 --- a/todo.md +++ b/todo.md @@ -42,7 +42,9 @@ detail is settled at its own milestone. directive block in a container body — an `expand` whose content is `paragraph` "A" then a `panel` (`panelType` `warning`) holding "B" spells `A` and `:::panel warning` either on consecutive lines or with a blank line between. Two defensible spellings, so §8 leaves the - pick here; `unspelled-block-separation` refuses the pair meanwhile, an empty paragraph's + pick here; the answer governs every unknown node type too, the block carry counting as a + CommonMark block since its spelling is a fenced code block. + `unspelled-block-separation` refuses the pair meanwhile, an empty paragraph's `::paragraph` beside a CommonMark block included — and, since a `mediaSingle`'s spelling now follows whether CommonMark can spell its URL, two sibling images differing only by an `&` land in the same refusal.