Emitter 2d: the opaque carry in both positions and the reserved info string #15

Merged
lilleman merged 5 commits from opaque-carry into main 2026-08-26 16:55:17 +02:00
8 changed files with 114 additions and 12 deletions
Showing only changes of commit 0ba195dca1 - Show all commits
+7 -5
View File
@@ -27,9 +27,11 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros
## 3. Unknown input policy ## 3. Unknown input policy
- Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats and - 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 restores to a deep-equal node. The round-trip holds for documents newer than the library. A
the node *type* that is unknown: a known type standing where its content model forbids stays an known node the position leaves unspelled carries the same way — validity against ADF's content
error result, never a carry. 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. - 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 - 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. 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 — - 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 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. 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 - Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values an
a carried node's JSON are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that 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. waits near 2000.
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the - 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 fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
+1 -2
View File
@@ -3,8 +3,7 @@
Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and
an HTML dialect. an HTML dialect.
**Status: pre-release — `adfToMarkdown` emits every node the flavour spells but the carry's **Status: pre-release — `adfToMarkdown` is built, nothing else is.**
attribute-level fallback, nothing else is built.**
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar: Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
[`spec/flavour.md`](spec/flavour.md). [`spec/flavour.md`](spec/flavour.md).
@@ -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
}
@@ -0,0 +1,17 @@
> ```adf
> {
> "attrs": {
> "url": "https://example.com/quoted"
> },
> "type": "blockCard"
> }
> ```
- ```adf
{
"attrs": {
"url": "https://example.com/listed"
},
"type": "blockCard"
}
```
@@ -52,6 +52,49 @@
} }
], ],
"type": "paragraph" "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", "type": "doc",
@@ -1,3 +1,7 @@
Draft: :adf{json="{\"attrs\":{\"text\":\"Add a summary\"},\"type\":\"placeholder\"}"} — expected Friday. 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. **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**
+3 -3
View File
@@ -148,7 +148,7 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index] 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 { function carries(node: AdfNode): boolean {
return node.type !== 'hardBreak' && node.type !== 'text' && inlineDirective(node.type) === undefined 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) 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) const directive = inlineDirective(node.type)
if (directive !== undefined) return emitInlineDirective(node, directive, path) if (directive !== undefined) return emitInlineDirective(node, directive, path)
if (node.type === 'text') return emitText(node, context, path) if (node.type === 'hardBreak') return emitHardBreak(node, context, path)
return emitHardBreak(node, context, path) return emitText(node, context, path)
} }
function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<InlineSegment[]> { function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<InlineSegment[]> {
+6 -2
View File
@@ -82,7 +82,9 @@ detail is settled at its own milestone.
one place. one place.
- [ ] **2d — The opaque carry** (§3). Fixtures and emitter together, into - [ ] **2d — The opaque carry** (§3). Fixtures and emitter together, into
`corpus/round-trip/opaque-carry/`: an unknown node in both positions, the reserved `adf` `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 - [ ] **2e — Carve-outs and combinations.** Fixtures and emitter together, into
`corpus/round-trip/combinations/`: the three carve-outs and their escapes, mark runs — the `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 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` 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 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 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/`, `src/` gets its hierarchy at the same split — `adf/`,
`markdown/`, `html/`, the grammar module shared inside `markdown/` — while the rename is `markdown/`, `html/`, the grammar module shared inside `markdown/` — while the rename is
still mechanical. Three files do not move whole: `block-directives.ts` and still mechanical. Three files do not move whole: `block-directives.ts` and