From 0ba195dca12400a2f983535114ccf0849381f161 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 26 Aug 2026 16:21:58 +0200 Subject: [PATCH] Say what the emitter actually refuses, and pin the carry in the wrappers that rewrite its lines --- AGENTS.md | 12 +++--- README.md | 3 +- .../opaque-carry/carry-in-wrappers.json | 33 ++++++++++++++ .../opaque-carry/carry-in-wrappers.md | 17 ++++++++ .../opaque-carry/unknown-inline.json | 43 +++++++++++++++++++ .../round-trip/opaque-carry/unknown-inline.md | 4 ++ src/markdown-inline.ts | 6 +-- todo.md | 8 +++- 8 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 corpus/round-trip/opaque-carry/carry-in-wrappers.json create mode 100644 corpus/round-trip/opaque-carry/carry-in-wrappers.md diff --git a/AGENTS.md b/AGENTS.md index 2b92ede..c13b05f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,9 +27,11 @@ 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. It is - the node *type* that is unknown: a known type standing where its content model forbids stays an - error result, never a carry. + restores to a deep-equal node. The round-trip holds for documents newer than the library. A + known node the position leaves unspelled carries the same way — validity against ADF's content + models is not the library's business (§14) — except `hardBreak`, `listItem` and `text` where a + block belongs: their spelling lives inside another node's body, so the misplacement is an error + result naming it. - 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. @@ -118,8 +120,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, 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 +- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an + attribute's and a carried node's alike — 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 b463488..6668127 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,7 @@ 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 but the carry's -attribute-level fallback, nothing else is built.** +**Status: pre-release — `adfToMarkdown` is built, nothing else is.** Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar: [`spec/flavour.md`](spec/flavour.md). diff --git a/corpus/round-trip/opaque-carry/carry-in-wrappers.json b/corpus/round-trip/opaque-carry/carry-in-wrappers.json new file mode 100644 index 0000000..6a0c220 --- /dev/null +++ b/corpus/round-trip/opaque-carry/carry-in-wrappers.json @@ -0,0 +1,33 @@ +{ + "content": [ + { + "content": [ + { + "attrs": { + "url": "https://example.com/quoted" + }, + "type": "blockCard" + } + ], + "type": "blockquote" + }, + { + "content": [ + { + "content": [ + { + "attrs": { + "url": "https://example.com/listed" + }, + "type": "blockCard" + } + ], + "type": "listItem" + } + ], + "type": "bulletList" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/opaque-carry/carry-in-wrappers.md b/corpus/round-trip/opaque-carry/carry-in-wrappers.md new file mode 100644 index 0000000..cf1815e --- /dev/null +++ b/corpus/round-trip/opaque-carry/carry-in-wrappers.md @@ -0,0 +1,17 @@ +> ```adf +> { +> "attrs": { +> "url": "https://example.com/quoted" +> }, +> "type": "blockCard" +> } +> ``` + +- ```adf + { + "attrs": { + "url": "https://example.com/listed" + }, + "type": "blockCard" + } + ``` diff --git a/corpus/round-trip/opaque-carry/unknown-inline.json b/corpus/round-trip/opaque-carry/unknown-inline.json index ca6eac8..0dd91b4 100644 --- a/corpus/round-trip/opaque-carry/unknown-inline.json +++ b/corpus/round-trip/opaque-carry/unknown-inline.json @@ -52,6 +52,49 @@ } ], "type": "paragraph" + }, + { + "content": [ + { + "text": ":", + "type": "text" + }, + { + "type": "blockCard" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "strong" + } + ], + "text": "a", + "type": "text" + }, + { + "marks": [ + { + "type": "strong" + } + ], + "type": "placeholder" + }, + { + "marks": [ + { + "type": "strong" + } + ], + "text": "b", + "type": "text" + } + ], + "type": "paragraph" } ], "type": "doc", diff --git a/corpus/round-trip/opaque-carry/unknown-inline.md b/corpus/round-trip/opaque-carry/unknown-inline.md index 26d938e..22343ff 100644 --- a/corpus/round-trip/opaque-carry/unknown-inline.md +++ b/corpus/round-trip/opaque-carry/unknown-inline.md @@ -1,3 +1,7 @@ Draft: :adf{json="{\"attrs\":{\"text\":\"Add a summary\"},\"type\":\"placeholder\"}"} — expected Friday. **Blocked** on :adf{json="{\"attrs\":{\"extensionKey\":\"jira-issue\",\"extensionType\":\"com.atlassian.jira.macro\"},\"marks\":[{\"type\":\"strong\"}],\"type\":\"inlineExtension\"}"} until the rebuild lands. + +\::adf{json="{\"type\":\"blockCard\"}"} + +**a**:adf{json="{\"marks\":[{\"type\":\"strong\"}],\"type\":\"placeholder\"}"}**b** diff --git a/src/markdown-inline.ts b/src/markdown-inline.ts index a1282fe..6d77fb1 100644 --- a/src/markdown-inline.ts +++ b/src/markdown-inline.ts @@ -148,7 +148,7 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath { return [...context.path, 'content', index] } -// spec/flavour.md, Marks: a run breaks at every carried node, so no emitted carry sits inside a mark spelling. +// spec/flavour.md, Marks. function carries(node: AdfNode): boolean { return node.type !== 'hardBreak' && node.type !== 'text' && inlineDirective(node.type) === undefined } @@ -164,8 +164,8 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result< if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path) const directive = inlineDirective(node.type) if (directive !== undefined) return emitInlineDirective(node, directive, path) - if (node.type === 'text') return emitText(node, context, path) - return emitHardBreak(node, context, path) + if (node.type === 'hardBreak') return emitHardBreak(node, context, path) + return emitText(node, context, path) } function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result { diff --git a/todo.md b/todo.md index c4de1dc..677d80c 100644 --- a/todo.md +++ b/todo.md @@ -82,7 +82,9 @@ detail is settled at its own milestone. 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`. + info string, and the `codeBlock` whose language is `adf` — carried whole ahead of the + attribute fallback 2e owes, since the reservation leaves that node no other spelling + whatever 1d decides for its `localId`. - [ ] **2e — Carve-outs and combinations.** Fixtures and emitter together, into `corpus/round-trip/combinations/`: the three carve-outs and their escapes, mark runs — the longest-run rule, attributes included — and the runs a carry breaks, a mark spelling that @@ -120,7 +122,9 @@ detail is settled at its own milestone. it must return). The raw-HTML element mapping is empty until milestone 6, so at `0.1.0` every raw-HTML construct in input is an error result. The CommonMark spec suite runs against it from here (§10). The parser owes `~` the same `can_open`/`can_close` the emitter - assumes — CommonMark flanking, as for `*` — which `spec/flavour.md` does not yet pin. + assumes — CommonMark flanking, as for `*` — which `spec/flavour.md` does not yet pin, and + the precedence between a directive and a code span that overlap, which the emitter already + spells leftmost-first (`` `a`:adf{json="…`…"}`b` ``). `src/` gets its hierarchy at the same split — `adf/`, `markdown/`, `html/`, the grammar module shared inside `markdown/` — while the rename is still mechanical. Three files do not move whole: `block-directives.ts` and