diff --git a/AGENTS.md b/AGENTS.md index aca7e6a..ee49a7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,9 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga `Result` — `{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws. `try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on the spot. +- 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 waits near 2000. diff --git a/src/adf-document.ts b/src/adf-document.ts index 747906e..0bd22a6 100644 --- a/src/adf-document.ts +++ b/src/adf-document.ts @@ -4,6 +4,8 @@ export type AdfAttributes = { [key: string]: JsonValue } export type AttributeKind = 'boolean' | 'json' | 'number' | 'string' +export type AttributeVocabulary = Readonly> + export type AdfMark = { attrs?: AdfAttributes type: string diff --git a/src/adf-to-markdown.ts b/src/adf-to-markdown.ts index 50a2b80..116afc3 100644 --- a/src/adf-to-markdown.ts +++ b/src/adf-to-markdown.ts @@ -2,9 +2,9 @@ import type { AdfDocument, AdfNode } from './adf-document.ts' import type { BlockDirective } from './block-directives.ts' import type { JsonValue } from './json-value.ts' import { blockDirective, spellDirectiveHeader } from './block-directives.ts' -import { emitImage } from './markdown-image.ts' +import { tryImage } from './markdown-image.ts' import { emitInlineLine } from './markdown-inline.ts' -import { emitPipeTable } from './markdown-pipe-table.ts' +import { tryPipeTable } from './markdown-pipe-table.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts' import { isAdfDocument } from './adf-document.ts' @@ -98,7 +98,11 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result function commonMarkLine(text: Result): Result { if (!text.ok) return text - return success({ fenceColons: 0, spelling: 'commonmark', text: text.value }) + return success(commonMarkText(text.value)) +} + +function commonMarkText(text: string): EmittedBlock { + return { fenceColons: 0, spelling: 'commonmark', text } } function commonMarkContainer(body: Result): Result { @@ -131,15 +135,15 @@ function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Re } function emitMediaSingle(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { - const image = emitImage(node, path) + const image = tryImage(node, path) if (image === undefined) return emitDirectiveBlock(node, directive, path, depth) - return commonMarkLine(image) + return success(commonMarkText(image)) } function emitTable(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { - const pipe = emitPipeTable(node, path) + const pipe = tryPipeTable(node, path) if (pipe === undefined) return emitDirectiveBlock(node, directive, path, depth) - return commonMarkLine(pipe) + return success(commonMarkText(pipe)) } function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result { diff --git a/src/block-directives.ts b/src/block-directives.ts index 307eab2..6064a19 100644 --- a/src/block-directives.ts +++ b/src/block-directives.ts @@ -1,5 +1,4 @@ -import type { AdfMark, AdfNode } from './adf-document.ts' -import type { AttributeVocabulary } from './directive-attributes.ts' +import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts' import type { JsonValue } from './json-value.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { attributeFailure, isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } from './directive-attributes.ts' @@ -84,11 +83,11 @@ export function blockDirective(type: string): BlockDirective | undefined { export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result { const argument = spellArgument(node, directive, path) if (!argument.ok) return argument - const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument) - if (!Array.isArray(pairs)) return attributeFailure(node.type, pairs, path) + const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument) + if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path) const marks = node.marks ?? [] - if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))]) - const attributes = spellAttributes(pairs) + if (marks.length > 0) spelled.pairs.push(['marks', spellJsonAttribute(markValues(marks))]) + const attributes = spellAttributes(spelled.pairs) return success(`${node.type}${argument.value}${attributes === '' ? '' : ` ${attributes}`}`) } diff --git a/src/directive-attributes.ts b/src/directive-attributes.ts index c18ee6f..c4d8d1b 100644 --- a/src/directive-attributes.ts +++ b/src/directive-attributes.ts @@ -1,11 +1,11 @@ -import type { AdfAttributes, AttributeKind } from './adf-document.ts' +import type { AdfAttributes, AttributeKind, AttributeVocabulary } from './adf-document.ts' import type { JsonValue } from './json-value.ts' import { failure, type ConvertErrorPath, type Result } from './result.ts' import { serializeCanonicalJson } from './canonical-json.ts' export type AttributeFault = { key: string; kind: AttributeKind | undefined } -export type AttributeVocabulary = Readonly> +export type SpelledPairs = { fault: AttributeFault; pairs?: undefined } | { fault?: undefined; pairs: [string, string][] } const bareToken = /^[A-Za-z0-9_-]+$/ @@ -18,17 +18,17 @@ export function attributeFailure(type: string, fault: AttributeFault, path: C return failure('unsupported-node-shape', `the ${type} attribute ${fault.key} holds no ${fault.kind}`, path) } -export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, slot: string | undefined): AttributeFault | [string, string][] { +export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, slot: string | undefined): SpelledPairs { const pairs: [string, string][] = [] for (const [key, value] of Object.entries(attrs)) { if (key === slot) continue const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined - if (kind === undefined) return { key, kind: undefined } + if (kind === undefined) return { fault: { key, kind: undefined } } const spelled = spellAttributeValue(value, kind) - if (spelled === undefined) return { key, kind } + if (spelled === undefined) return { fault: { key, kind } } pairs.push([key, spelled]) } - return pairs + return { pairs } } export function spellAttributes(pairs: readonly (readonly [string, string])[]): string { diff --git a/src/inline-directives.ts b/src/inline-directives.ts index 920dfdc..46a3dee 100644 --- a/src/inline-directives.ts +++ b/src/inline-directives.ts @@ -1,5 +1,5 @@ -import type { AdfMark, AdfNode } from './adf-document.ts' -import type { AttributeVocabulary } from './directive-attributes.ts' +import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts' +import type { AttributeFault } from './directive-attributes.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { attributeFailure, spellAttributes, vocabularyPairs } from './directive-attributes.ts' @@ -45,16 +45,18 @@ export function markDirective(type: string): AttributeVocabulary | undefined { } export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result { - const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot) - if (!Array.isArray(pairs)) return attributeFailure(node.type, pairs, path) - return success(spellAttributes(pairs)) + const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot) + if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path) + return success(spellAttributes(spelled.pairs)) } export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary, path: ConvertErrorPath): Result { - const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined) - if (!Array.isArray(pairs)) { - if (pairs.kind === undefined) return failure('unspellable-mark', `the ${mark.type} spelling holds no ${pairs.key} attribute`, path) - return failure('unspellable-mark', `the ${mark.type} attribute ${pairs.key} holds no ${pairs.kind}`, path) - } - return success(spellAttributes(pairs)) + const spelled = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined) + if (spelled.fault !== undefined) return markFailure(mark.type, spelled.fault, path) + return success(spellAttributes(spelled.pairs)) +} + +function markFailure(type: string, fault: AttributeFault, path: ConvertErrorPath): Result { + if (fault.kind === undefined) return failure('unspellable-mark', `the ${type} spelling holds no ${fault.key} attribute`, path) + return failure('unspellable-mark', `the ${type} attribute ${fault.key} holds no ${fault.kind}`, path) } diff --git a/src/markdown-image.ts b/src/markdown-image.ts index fce8bd9..77b7cd8 100644 --- a/src/markdown-image.ts +++ b/src/markdown-image.ts @@ -1,16 +1,15 @@ import type { AdfNode } from './adf-document.ts' -import type { ConvertErrorPath, Result } from './result.ts' -import { emitImageLine } from './markdown-inline.ts' +import type { ConvertErrorPath } from './result.ts' import { serializeCanonicalJson } from './canonical-json.ts' +import { tryImageLine } from './markdown-inline.ts' const centeredMediaSingle = '{"layout":"center"}' const imageAttributes = ['alt', 'type', 'url'] -export function emitImage(node: AdfNode, path: ConvertErrorPath): Result | undefined { +export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefined { const image = imageShape(node) if (image === undefined) return undefined - const line = emitImageLine(image.alt, image.url, [...path, 'content', 0]) - return line.ok ? line : undefined + return tryImageLine(image.alt, image.url, [...path, 'content', 0]) } function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined { diff --git a/src/markdown-inline.ts b/src/markdown-inline.ts index 60d7e3a..92e5cce 100644 --- a/src/markdown-inline.ts +++ b/src/markdown-inline.ts @@ -7,7 +7,7 @@ import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullChara import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { longestBacktickRun } from './backtick-runs.ts' import { serializeCanonicalJson } from './canonical-json.ts' -import { spellStringAttribute } from './directive-attributes.ts' +import { spellAttributes, spellStringAttribute } from './directive-attributes.ts' type Brackets = 'directive' | 'link' | 'none' @@ -29,23 +29,21 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain return finishLine(segments.value, container, path) } -// undefined where the cell holds a pipe no backslash reaches, leaving the table its directive form. -export function emitPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): Result | undefined { +export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined { const segments = lineSegments(nodes, 'table-cell', path) - if (!segments.ok) return segments + if (!segments.ok) return undefined if (segments.value.some((segment) => segment.escaping === 'none' && segment.text.includes('|'))) return undefined - return finishLine(segments.value, 'table-cell', path) + const line = finishLine(segments.value, 'table-cell', path) + return line.ok ? line.value : undefined } -export function emitImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): Result { - if (alt !== undefined && /^[ \t]|[ \t]$|[\n\r]/.test(alt)) { - return failure('unspellable-whitespace', 'a media alt holds whitespace no image description spells', path) - } - if (alt !== undefined && holdsNullCharacter(alt)) return failure('unspellable-character', 'a media alt holds a null character CommonMark replaces', path) +export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined { + if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined const destination = spellDestination(href, path) - if (!destination.ok) return destination + if (!destination.ok) return undefined const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }] - return finishLine([syntax('!['), ...description, syntax(`](${destination.value})`)], 'paragraph', path) + const line = finishLine([syntax('!['), ...description, syntax(`](${destination.value})`)], 'paragraph', path) + return line.ok ? line.value : undefined } function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { @@ -71,7 +69,7 @@ function finishLine(segments: readonly InlineSegment[], container: LineContainer return success(line) } -// spec/flavour.md, Inline nodes: whitespace CommonMark strips at a line edge rides the reserved text directive. +// spec/flavour.md, Inline nodes: whitespace CommonMark strips. function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] { const carried: InlineSegment[] = [] for (const [index, segment] of segments.entries()) { @@ -98,7 +96,11 @@ function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean) } function carriedText(text: string): InlineSegment { - return { escaping: 'attribute', text: `:text{text=${spellStringAttribute(text)}}` } + return { escaping: 'attribute', text: spellLeafDirective('text', spellAttributes([['text', spellStringAttribute(text)]])) } +} + +function spellLeafDirective(name: string, attributes: string): string { + return `:${name}${attributes === '' ? '{}' : attributes}` } function syntax(text: string): InlineSegment { @@ -164,7 +166,7 @@ function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertError const empty = refuseContentAndText(node, path) if (!empty.ok) return empty if (context.container === 'paragraph' && !context.atBlockEnd && context.brackets !== 'directive') return success([syntax('\\\n')]) - return success([syntax(':hardBreak{}')]) + return success([syntax(spellLeafDirective('hardBreak', ''))]) } function emitInlineDirective(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result { @@ -173,7 +175,7 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, path: Co const attributes = spellInlineNodeAttributes(node, directive, path) if (!attributes.ok) return attributes const slot = directive.slot === undefined ? undefined : node.attrs?.[directive.slot] - if (slot === undefined) return success([syntax(`:${node.type}${attributes.value === '' ? '{}' : attributes.value}`)]) + if (slot === undefined) return success([syntax(spellLeafDirective(node.type, attributes.value))]) if (typeof slot !== 'string') return failure('unsupported-node-shape', `the ${node.type} attribute ${directive.slot} holds no string`, path) if (/[\n\r]/.test(slot)) return failure('unspellable-whitespace', `a ${node.type} content slot holds a newline no inline directive spans`, path) if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path) diff --git a/src/markdown-pipe-table.ts b/src/markdown-pipe-table.ts index e41127e..0487fbc 100644 --- a/src/markdown-pipe-table.ts +++ b/src/markdown-pipe-table.ts @@ -1,8 +1,8 @@ import type { AdfNode } from './adf-document.ts' -import { emitPipeCell } from './markdown-inline.ts' -import { success, type ConvertErrorPath, type Result } from './result.ts' +import { tryPipeCell } from './markdown-inline.ts' +import type { ConvertErrorPath } from './result.ts' -export function emitPipeTable(node: AdfNode, path: ConvertErrorPath): Result | undefined { +export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | undefined { const rows = pipeRows(node) if (rows === undefined) return undefined const lines: string[] = [] @@ -10,15 +10,14 @@ export function emitPipeTable(node: AdfNode, path: ConvertErrorPath): Result '---').join(' | ')} |`) } - return success(lines.join('\n')) + return lines.join('\n') } function pipeRows(node: AdfNode): AdfNode[][] | undefined { diff --git a/todo.md b/todo.md index 7a1199a..1e7c54f 100644 --- a/todo.md +++ b/todo.md @@ -71,13 +71,13 @@ detail is settled at its own milestone. answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give the emitter's refusals a corpus home while the directories grow: `corpus/unspellable/`, a `.json` beside the `ConvertErrorCode` it must return, the emitter half of `corpus/errors/`. - - [ ] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment.kind` splits into - its two axes here — escapability (`attribute` for `:text{text="…"}`, `backslash`, `none`) - and the emphasis role — rather than gaining a third value that means one of each. A lone - surrogate in a text node emits verbatim and becomes U+FFFD on any UTF-8 encode, a §2 break - plain text still holds open — attribute values already escape it. The inline directives - arriving here emit their attribute values as syntax, so `spellsPipeAsSyntax` grows with - them alongside the `|` escaping `spec/flavour.md` already mandates in a pipe cell. + - [ ] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment` splits into its + two axes — escapability (`attribute` for `:text{text="…"}`, `backslash`, `bracketed`, + `none`) and the emphasis role. A lone surrogate in a text node emits verbatim and becomes + U+FFFD on any UTF-8 encode, a §2 break plain text still holds open — attribute values + already escape it. The pipe form's fallback reads the emitted segments rather than naming + the nodes whose attribute values spell a pipe as syntax, so 2e's `\u007c` narrows it in + one place. - [ ] **2d — The opaque carry** (§3). Fixtures and emitter together, into `corpus/round-trip/opaque-carry/`: an unknown node in both positions, the reserved `adf` info string, and the `codeBlock` whose language is `adf`. @@ -90,6 +90,13 @@ detail is settled at its own milestone. paragraph line inside a container body shaped like a closing fence (`:::`, `::: x`). Guard `fenceNestingFault`'s bare-run pop here too — a run shorter than the open fence is a fault, not a close — which today's emitter cannot reach. + Two moves land before the attribute spelling changes. One mark vocabulary: + `emphasisSpellings`, `linkAttributes` and the `code`/`link` names join + `inline-directives.ts`, which holds four of the nine marks while the rest are branch + literals in the emitter — and the parser (3) needs every name to make `:em[x]` the named + error `spec/flavour.md` promises. And `escaping: 'attribute'` earns its keep at the + `\u007c` rule or collapses into `none`: nothing the escaper does tells the two apart + today, since a carried segment holds only spaces, tabs and newlines. The gate gains the collision property here: no two corpus documents may emit the same bytes — one spelling for two documents is a round-trip break no parser can undo, and it is provable without one. It also settles the emitter's one known approximation: delimiter @@ -108,11 +115,17 @@ detail is settled at its own milestone. assumes — CommonMark flanking, as for `*` — which `spec/flavour.md` does not yet pin. `src/` gets its hierarchy at the same split — `adf/`, `markdown/`, `html/`, the grammar module shared inside `markdown/` — while the rename is - still mechanical. `block-directives.ts` is the one file that does not move whole: the node - table is ADF knowledge milestones 6-7 need too and belongs in `adf/`, `spellDirectiveHeader` - in `markdown/`. `AttributeKind` stays above both — it is the vocabulary a string-typed - attribute grammar needs, which is why HTML will want it too, not a markdown spelling. The table is a second copy of `spec/flavour.md`'s prose with no drift guard, - and a mistyped attribute name degrades into a false refusal no test catches. + still mechanical. Three files do not move whole: `block-directives.ts` and + `inline-directives.ts` each hold a node table milestones 6-7 need in `adf/` beside a + markdown spelling that belongs in `markdown/`, and `directive-attributes.ts` fuses the + format-neutral conformance walk (`vocabularyPairs`) with the markdown value spelling HTML + has no use for. `spellDestination`, `spellTitle` and `balanced` leave `markdown-inline.ts` + here too — CommonMark destination spelling `emitLink` and `tryImageLine` share, and the six + concerns that file carries are one fewer for it. `AttributeKind` and `AttributeVocabulary` + stay above all of it — the vocabulary a string-typed attribute grammar needs, which is why + HTML will want them too, not a markdown spelling. Both node tables are a second copy of + `spec/flavour.md`'s prose with no drift guard, and a mistyped attribute name degrades into a + false refusal no test catches. - [ ] **4 — Round-trip property tests** over the corpus, both ways — the thing that proves 2 and 3. Editor-normal (§2) gets its implementation here — `toEditorNormal(doc)` and the equality the round-trip asserts, which over normalized input is the canonical serializer's compact