From 1676da4725d953f65669fc907e258034843d47e2 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 24 Aug 2026 09:15:29 +0200 Subject: [PATCH 1/3] Flavour spec 1b: block node syntaxes --- AGENTS.md | 9 ++- README.md | 7 +- spec/flavour.md | 193 +++++++++++++++++++++++++++++++++++++++++++++++- todo.md | 2 +- 4 files changed, 201 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e1bb5c4..b965041 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,8 +35,8 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros - Directives, one grammar for everything markdown lacks: `:::panel info` … `:::` blocks, `:mention[@Mikael]{id=5b10a2}` inline. Prior art: CommonMark's generic-directives proposal. -- Plain CommonMark is a subset, with one carve-out (`spec/flavour.md`): directive-shaped literal - text is claimed. +- Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a + directive or a pipe table is claimed. - Tables: one header row plus plain inline cells → pipe table; anything richer → directive form. - Identity-bearing nodes carry their ids in attributes; a document is only portable within its site — accepted. @@ -152,4 +152,7 @@ public, and creating the `NPM_TOKEN` secret. A continuous loop session (`/loop`) counts as a chain of sessions: one chunk per iteration, each iteration starting by re-reading `AGENTS.md` and `todo.md` and trusting them over anything -remembered from earlier iterations. The loop stops when only maintainer-reserved acts remain. +remembered from earlier iterations. The loop session is a thin driver: each chunk's work runs in +a fresh-context subagent holding this file as its charter, and the driver only relays maintainer +questions, runs the review flow, merges, and cleans up. The loop stops when only +maintainer-reserved acts remain. diff --git a/README.md b/README.md index 44404e0..beaadb8 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,10 @@ isAdfDocument(v: unknown): v is AdfDocument (AGENTS.md §3). - `htmlToAdf(adfToHtml(doc))` equals `doc` — fidelity HTML cannot express rides `data-*` attributes. -- Plain CommonMark is valid input to `markdownToAdf`, with one carve-out: literal text matching - directive syntax is claimed (escapable — `spec/flavour.md`). Converting back yields the - library's canonical spelling, which round-trips byte-identically. +- Plain CommonMark is valid input to `markdownToAdf`, with two carve-outs — literal text + matching directive or pipe-table syntax is claimed (escapable — `spec/flavour.md`) — and one + gap: an image title has no ADF home (an error result). Converting back yields the library's + canonical spelling, which round-trips byte-identically. - Foreign HTML maps a documented element set; an unmappable element is an error, never a silent drop. Well-formed HTML only — no tag-soup recovery. - The emitted formats are semver surface (AGENTS.md §8). diff --git a/spec/flavour.md b/spec/flavour.md index 6edb59b..2e78e3a 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -1,9 +1,11 @@ # The markdown flavour The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain -CommonMark is a subset with one carve-out: literal text that matches directive syntax below is -claimed by the flavour (escape the `:` to keep it literal). The emitted form is contract -(AGENTS.md §8). Per-node syntaxes build on this grammar in sections that follow (todo.md 1b–1c). +CommonMark is a subset with two carve-outs: literal text that matches directive syntax below or +opens a pipe table is claimed by the flavour (escape the `:` or `|` to keep it literal) — and +one gap: an image title has no ADF home (a named error). The emitted form is contract +(AGENTS.md §8). Per-node syntaxes build on this grammar in the sections below (inline nodes and +marks: todo.md 1c). ## Canonical form @@ -111,3 +113,188 @@ CommonMark input may contain raw HTML. `markdownToAdf` routes each construct thr HTML element mapping (AGENTS.md §3; specified with the HTML dialect, todo.md milestone 6) — ADF has no raw-HTML node, so a construct without a mapping, comments and processing instructions included, is an error result naming it. The flavour never emits raw HTML. + +## Block nodes + +The directive name is always the ADF node type. A container's body is the node's `content`; a +leaf has none. Every directive parses in any position — `markdownToAdf` builds exactly what is +written; validity against ADF's content models stays the author's business (AGENTS.md §14). + +Each section lists attributes as `name (type)`. A parenthesized value set documents what real +payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by +type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare +(`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys +sorted), quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; +editor-normal ADF (AGENTS.md §2) also reads an empty attrs object, marks array or content array +as the absent key — the grammar's empty-`{attrs}` omission already collapses the two spellings. + +Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a +`json` value: `::::layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`. +Three child nodes hold inline content rather than blocks (`caption`, `decisionItem`, +`taskItem`): their body is at most one paragraph, whose inline content becomes the node's +`content`; any other body is a named error. + +A node the sections cannot spell rides the opaque carry: an attrs key its section does not +list, a value that is not the section's type, or an arg-slot value that is no bare token. In +markdown input the same mismatch is a named error. + +### Panel + +Container; the arg is `panelType` (`custom` `error` `info` `note` `success` `tip` `warning`). +Attributes: `localId` (string), plus `custom` styling `panelColor` (string), `panelIcon` +(string), `panelIconId` (string), `panelIconText` (string). + +``` +:::panel warning +Check the collation before importing. +::: +``` + +### Expand + +`expand` and `nestedExpand`: containers, no arg — same syntax, two node types, the name picks +which. Attributes: `localId` (string), `title` (string). + +``` +:::expand {title="Full build log"} +… +::: +``` + +### The media family + +- `media` — leaf. Attributes: `alt` (string), `collection` (string), `height` (number), `id` + (string), `localId` (string), `occurrenceKey` (string), `type` (`external` `file` `link`), + `url` (string), `width` (number). `file` and `link` media carry `collection` + `id`; + `external` media carry `url`. +- `mediaSingle` — container: one `::media`, then optionally one `:::caption`. Attributes: + `layout` (`align-end` `align-start` `center` `full-width` `wide` `wrap-left` `wrap-right`), + `localId` (string), `width` (number), `widthType` (`percentage` `pixel`). +- `caption` — container, inline body. Attributes: `localId` (string). +- `mediaGroup` — container of `::media` leaves, no attributes. + +``` +::::mediaSingle {layout=center width=50} +::media {collection=MediaServicesSample id=4478e39c-cf9b-41d1-ba92-68589487cd75 type=file} +:::caption +The moon, at night. +::: +:::: +``` + +**The CommonMark image.** A paragraph whose entire inline content is one image `![alt](url)` is +a `mediaSingle` with attrs exactly `{"layout":"center"}` holding an `external` `media` — `url` +from the destination, `alt` from the image text when non-empty. `adfToMarkdown` emits the image +form for exactly that shape — those attrs and no others, no marks, no caption. An image amid +other text, or one carrying a title, is a named error (ADF has a slot for neither). + +### Tables + +One header row plus plain inline cells is a pipe table; anything richer is the directive form +(AGENTS.md §4). Precisely: a table emits as a pipe table exactly when the `table`, every row +and every cell carry no attrs and no marks, the first row is all `tableHeader` and the rest all +`tableCell`, every row has the header's cell count, and every cell holds exactly one attr-less, +mark-less paragraph — an empty cell holds one empty paragraph. A pipe table parses back to +exactly that shape. + +``` +| Part | Qty | +| --- | --- | +| Bolt M8 | 40 | +``` + +Claiming at block level, symmetric with directives: a line opening with an unescaped `|` is +claimed and must parse as part of a pipe table, else it is a named error — escape the pipe +(`\|`) to keep it literal text. A pipe table is a header row, a delimiter row whose cells are +runs of one or more `-` (canonical `---`), and body rows; rows follow code-fence indentation. +Cells split on unescaped `|` before inline parsing; each cell is the inline content of one +paragraph, trimmed; canonical form pads cells with single spaces and ends rows with `|` +(optional in input). Named errors: a delimiter or body row whose cell count differs from the +header's, and an alignment colon in the delimiter row — ADF holds no column alignment. In a +pipe cell a hard break is `:hardBreak{}`, a literal `|` is `\|`, and `|` inside a quoted +attribute value is `\u007c` (JSON has no `\|`). + +The directive form nests cells as containers of block content inside `tableRow` containers: + +``` +:::::table {isNumberColumnEnabled=true width=760} +::::tableRow +:::tableHeader {colspan=2 colwidth="[340,420]"} +Assembly +::: +:::: +::::tableRow +:::tableCell {background="#deebff"} +Bolt M8 +::: +:::tableCell {valign=top} +40 +::: +:::: +::::: +``` + +Attributes — `table`: `displayMode` (`default` `fixed`), `isNumberColumnEnabled` (boolean), +`layout` (`align-end` `align-start` `center` `default` `full-width` `wide`), `localId` +(string), `width` (number, pixels). `tableRow`: `localId` (string). `tableCell` and +`tableHeader`: `background` (string), `colspan` (number), `colwidth` (json, one pixel width per +spanned column), `localId` (string), `rowspan` (number), `valign` (`bottom` `middle` `top`). + +### Task and decision lists + +- `taskList` — container of `taskItem`, `blockTaskItem` and nested `taskList` directives. + Attributes: `localId` (string). +- `taskItem` — container, inline body; the arg is the state (`DONE` `TODO`). Attributes: + `localId` (string). +- `blockTaskItem` — container, block body; arg and attributes as `taskItem`. +- `decisionList` — container of `decisionItem` directives. Attributes: `localId` (string). +- `decisionItem` — container, inline body. Attributes: `localId` (string), `state` (string — + free-form; the editor writes `DECIDED`). + +``` +::::taskList {localId=0198f3a2-7c41-7f2e-9b3a-4d8e2c1a6b90} +:::taskItem DONE {localId=0198f3a2-8d52-70b1-8c4f-5e9f3d2b7ca1} +Write the spec +::: +:::taskItem TODO {localId=0198f3a2-9e63-7d80-a15b-6fa04e3c8db2} +Ship it +::: +:::: +``` + +### Layout + +`layoutSection` — container of `layoutColumn` containers; attributes: `localId` (string). +`layoutColumn` — container, block body; attributes: `localId` (string), `valign` (`bottom` +`middle` `top`), `width` (number — percent). + +``` +::::layoutSection +:::layoutColumn {width=50} +Left. +::: +:::layoutColumn {width=50} +Right. +::: +:::: +``` + +### Extensions + +`extension` — leaf. `bodiedExtension` — container, block body. `multiBodiedExtension` — +container of `extensionFrame` containers; `extensionFrame` — container, block body, no +attributes. The other three share: `extensionKey` (string), `extensionType` (string), `layout` +(`default` `full-width` `wide`), `localId` (string), `parameters` (json), `text` (string). + +``` +::extension {extensionKey=toc extensionType=com.atlassian.confluence.macro.core parameters="{\"maxLevel\":2}"} +``` + +### Sync blocks + +`syncBlock` — leaf. `bodiedSyncBlock` — container, block body. Attributes: `localId` (string), +`resourceId` (string). + +``` +::syncBlock {localId=0198f3a2-af74-7e91-b26c-70b15f4d9ec3 resourceId="ari:cloud:confluence:site/page/123"} +``` diff --git a/todo.md b/todo.md index e0877f3..4000569 100644 --- a/todo.md +++ b/todo.md @@ -11,7 +11,7 @@ detail is settled at its own milestone. - [x] **1a — The directive grammar** (`spec/flavour.md`): inline/block/leaf directive forms, attributes, escaping, nesting, canonical form, the opaque-carry spelling, the raw-HTML input policy. -- [ ] **1b — Block node syntaxes** in `spec/flavour.md`: panel, expand/nestedExpand, the media +- [x] **1b — Block node syntaxes** in `spec/flavour.md`: panel, expand/nestedExpand, the media family, the pipe-vs-directive table rule and the directive table form, task and decision lists, layout, extensions, syncBlock. - [ ] **1c — Inline node syntaxes and marks** in `spec/flavour.md`: mention, emoji, status, date, -- 2.52.0 From af146a54252204ebf4034df468644e9214299cc6 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 24 Aug 2026 09:30:27 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Pin=20the=20pipe-escape=20reading,=20enumer?= =?UTF-8?q?ate=20the=20image=20gap,=20move=20the=20empty-key=20rule=20to?= =?UTF-8?q?=20=C2=A72?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 5 +++-- README.md | 5 +++-- spec/flavour.md | 17 ++++++++++------- todo.md | 3 ++- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b965041..2fd056b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,8 @@ The other direction is a canonical fixpoint, not byte-identity: human markdown n back yields the library's canonical spelling, and that spelling round-trips byte-identically. "Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks -merged, JSON number semantics — the only domain markdown can restore. +merged, JSON number semantics, an empty attrs object, marks array or content array the absent +key — the only domain markdown can restore. Round-trip equality is a property tested over a corpus, not a claim made in prose. @@ -36,7 +37,7 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros - Directives, one grammar for everything markdown lacks: `:::panel info` … `:::` blocks, `:mention[@Mikael]{id=5b10a2}` inline. Prior art: CommonMark's generic-directives proposal. - Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a - directive or a pipe table is claimed. + directive or a pipe table is claimed — plus one image gap. - Tables: one header row plus plain inline cells → pipe table; anything richer → directive form. - Identity-bearing nodes carry their ids in attributes; a document is only portable within its site — accepted. diff --git a/README.md b/README.md index beaadb8..819053c 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,9 @@ isAdfDocument(v: unknown): v is AdfDocument attributes. - Plain CommonMark is valid input to `markdownToAdf`, with two carve-outs — literal text matching directive or pipe-table syntax is claimed (escapable — `spec/flavour.md`) — and one - gap: an image title has no ADF home (an error result). Converting back yields the library's - canonical spelling, which round-trips byte-identically. + gap: a CommonMark image fits only as its own title-less paragraph; mid-text and titled images + are error results. Converting back yields the library's canonical spelling, which round-trips + byte-identically. - Foreign HTML maps a documented element set; an unmappable element is an error, never a silent drop. Well-formed HTML only — no tag-soup recovery. - The emitted formats are semver surface (AGENTS.md §8). diff --git a/spec/flavour.md b/spec/flavour.md index 2e78e3a..1cc89ef 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -3,7 +3,8 @@ The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain CommonMark is a subset with two carve-outs: literal text that matches directive syntax below or opens a pipe table is claimed by the flavour (escape the `:` or `|` to keep it literal) — and -one gap: an image title has no ADF home (a named error). The emitted form is contract +one gap: a CommonMark image fits only as its own title-less paragraph — mid-text and titled +images are named errors (ADF has a slot for neither). The emitted form is contract (AGENTS.md §8). Per-node syntaxes build on this grammar in the sections below (inline nodes and marks: todo.md 1c). @@ -125,8 +126,8 @@ payloads hold; the type stays string and any value round-trips verbatim. Values type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare (`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted), quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; -editor-normal ADF (AGENTS.md §2) also reads an empty attrs object, marks array or content array -as the absent key — the grammar's empty-`{attrs}` omission already collapses the two spellings. +editor-normal ADF reads an empty attrs object, marks array or content array as the absent key +(AGENTS.md §2) — the grammar's empty-`{attrs}` omission already collapses the two spellings. Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a `json` value: `::::layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`. @@ -194,8 +195,9 @@ One header row plus plain inline cells is a pipe table; anything richer is the d (AGENTS.md §4). Precisely: a table emits as a pipe table exactly when the `table`, every row and every cell carry no attrs and no marks, the first row is all `tableHeader` and the rest all `tableCell`, every row has the header's cell count, and every cell holds exactly one attr-less, -mark-less paragraph — an empty cell holds one empty paragraph. A pipe table parses back to -exactly that shape. +mark-less paragraph — an empty cell holds one empty paragraph — with no `|` in a code span: +backslash escapes are inert there, so pipe form cannot spell that pipe and the table takes the +directive form. A pipe table parses back to exactly that shape. ``` | Part | Qty | @@ -207,8 +209,9 @@ Claiming at block level, symmetric with directives: a line opening with an unesc claimed and must parse as part of a pipe table, else it is a named error — escape the pipe (`\|`) to keep it literal text. A pipe table is a header row, a delimiter row whose cells are runs of one or more `-` (canonical `---`), and body rows; rows follow code-fence indentation. -Cells split on unescaped `|` before inline parsing; each cell is the inline content of one -paragraph, trimmed; canonical form pads cells with single spaces and ends rows with `|` +Cells split on unescaped `|` before inline parsing — `\|` stays in the cell text, and the +inline layer's ordinary CommonMark escaping yields the pipe; each cell is the inline content of +one paragraph, trimmed; canonical form pads cells with single spaces and ends rows with `|` (optional in input). Named errors: a delimiter or body row whose cell count differs from the header's, and an alignment colon in the delimiter row — ADF holds no column alignment. In a pipe cell a hard break is `:hardBreak{}`, a literal `|` is `\|`, and `|` inside a quoted diff --git a/todo.md b/todo.md index 4000569..c4c3648 100644 --- a/todo.md +++ b/todo.md @@ -16,7 +16,8 @@ detail is settled at its own milestone. lists, layout, extensions, syncBlock. - [ ] **1c — Inline node syntaxes and marks** in `spec/flavour.md`: mention, emoji, status, date, inlineCard, mediaInline; underline, subsup, textColor, border; the spelling for text nodes - whose whitespace CommonMark cannot hold (literal newlines, leading or trailing spaces). + whose whitespace CommonMark cannot hold (literal newlines, leading or trailing spaces) — + escape-based, never literal, since pipe cells trim and pad. - [ ] **1d — Corpus start** (§10): checked-in ADF ↔ canonical-markdown fixture pairs per spec'd node. - [ ] **2 — `adfToMarkdown`.** First real code — decide here where §10's coverage check lives. -- 2.52.0 From 75f03f74dff600a02232bd9b894af67b0b8222c0 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 24 Aug 2026 09:38:04 +0200 Subject: [PATCH 3/3] Quote the dotted extensionType, unconditional panel attrs, single image-gap ground --- spec/flavour.md | 13 ++++++------- todo.md | 4 +++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/spec/flavour.md b/spec/flavour.md index 1cc89ef..c91e5f0 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -4,9 +4,8 @@ The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` p CommonMark is a subset with two carve-outs: literal text that matches directive syntax below or opens a pipe table is claimed by the flavour (escape the `:` or `|` to keep it literal) — and one gap: a CommonMark image fits only as its own title-less paragraph — mid-text and titled -images are named errors (ADF has a slot for neither). The emitted form is contract -(AGENTS.md §8). Per-node syntaxes build on this grammar in the sections below (inline nodes and -marks: todo.md 1c). +images are named errors. The emitted form is contract (AGENTS.md §8). Per-node syntaxes build on +this grammar in the sections below (inline nodes and marks: todo.md 1c). ## Canonical form @@ -142,8 +141,8 @@ markdown input the same mismatch is a named error. ### Panel Container; the arg is `panelType` (`custom` `error` `info` `note` `success` `tip` `warning`). -Attributes: `localId` (string), plus `custom` styling `panelColor` (string), `panelIcon` -(string), `panelIconId` (string), `panelIconText` (string). +Attributes: `localId` (string), `panelColor` (string), `panelIcon` (string), `panelIconId` +(string), `panelIconText` (string) — the editor writes the last four for `custom` panels. ``` :::panel warning @@ -185,7 +184,7 @@ The moon, at night. **The CommonMark image.** A paragraph whose entire inline content is one image `![alt](url)` is a `mediaSingle` with attrs exactly `{"layout":"center"}` holding an `external` `media` — `url` -from the destination, `alt` from the image text when non-empty. `adfToMarkdown` emits the image +from the destination, `alt` the description's plain-text content when non-empty. `adfToMarkdown` emits the image form for exactly that shape — those attrs and no others, no marks, no caption. An image amid other text, or one carrying a title, is a named error (ADF has a slot for neither). @@ -290,7 +289,7 @@ attributes. The other three share: `extensionKey` (string), `extensionType` (str (`default` `full-width` `wide`), `localId` (string), `parameters` (json), `text` (string). ``` -::extension {extensionKey=toc extensionType=com.atlassian.confluence.macro.core parameters="{\"maxLevel\":2}"} +::extension {extensionKey=toc extensionType="com.atlassian.confluence.macro.core" parameters="{\"maxLevel\":2}"} ``` ### Sync blocks diff --git a/todo.md b/todo.md index c4c3648..1de24d6 100644 --- a/todo.md +++ b/todo.md @@ -17,7 +17,9 @@ detail is settled at its own milestone. - [ ] **1c — Inline node syntaxes and marks** in `spec/flavour.md`: mention, emoji, status, date, inlineCard, mediaInline; underline, subsup, textColor, border; the spelling for text nodes whose whitespace CommonMark cannot hold (literal newlines, leading or trailing spaces) — - escape-based, never literal, since pipe cells trim and pad. + escape-based, never literal, since pipe cells trim and pad. At `mediaInline`, check real + payloads for external-URL support — if it exists, revisit the media section's + mid-text-image error and its "no slot" ground. - [ ] **1d — Corpus start** (§10): checked-in ADF ↔ canonical-markdown fixture pairs per spec'd node. - [ ] **2 — `adfToMarkdown`.** First real code — decide here where §10's coverage check lives. -- 2.52.0