From 341d54c0bf49b394eb23acaaa6610f22bd6149aa Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Tue, 1 Sep 2026 16:47:22 -0400 Subject: [PATCH 1/6] docs(plans): plan ac:adf-extension passthrough and a colour-faithful callout map An editor-authored purple Note panel exports twice: falls through storage_to_md.go's transparent-wrapper default, which renders both the authoritative ac:adf-node and the pre-rendered ac:adf-fallback beside it. Teaching the converter what an extension is makes the purple panel reachable in both directions, which is what the callout map has been missing -- GitHub draws IMPORTANT purple and WARNING orange, where markfluence publishes them yellow and red. The map becomes bijective and CAUTION stops being unrecoverable. Refs #125. --- _plans/030_adf-extension-passthrough.md | 418 ++++++++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 _plans/030_adf-extension-passthrough.md diff --git a/_plans/030_adf-extension-passthrough.md b/_plans/030_adf-extension-passthrough.md new file mode 100644 index 0000000..3e969f7 --- /dev/null +++ b/_plans/030_adf-extension-passthrough.md @@ -0,0 +1,418 @@ +# Plan: render an `ac:adf-extension` once, and make the callout map colour-faithful + +Two changes that turn out to be one. `export`/`read` emit an editor-authored +Note panel's content **twice** (#125), because `` falls +through a transparent-wrapper default that renders both the authoritative node +and its pre-rendered fallback. Fixing it means teaching the converter what an +`ac:adf-extension` is — and once it knows, the purple panel that has no +Confluence macro becomes reachable in *both* directions, which is exactly what +the callout map has been missing. + +Closes #125. + +Reported against +[Cloud Engineering QBR KPIs](https://mozilla-hub.atlassian.net/wiki/spaces/SRE/pages/2496725010/Cloud+Engineering+QBR+KPIs), +which is the live fixture for the read direction. + +## Current state of the codebase + +### The bug + +`storage_to_md.go`'s `renderBlock` ends in + +```go +default: + // Unknown element: render its children as blocks (transparent wrapper). + return strings.Join(r.blockStrings(n.kids, listIndent), "\n\n") +``` + +which is right for a wrapper that adds nothing, and exactly wrong for +``, whose entire purpose is to carry the same content twice: + +```xml + + + note + 54e36e4937ac + …the content… + + +
…the same content…
+
+
+``` + +Both children are unknown, both are transparent, so both render. `inlineString` +has the same defect for the same reason: its `default` is +`renderInlineChildren`. `renderCallout` is never reached — this is not a +callout bug. + +### The callout map + +`callouts.go` maps a GitHub alert to a Confluence macro, and +`storage_to_md.go` inverts it: + +```go +var calloutMacro = map[string]string{ var calloutMacroInverse = map[string]string{ + "note": "info", "info": "NOTE", + "tip": "tip", "tip": "TIP", + "important": "note", "note": "IMPORTANT", + "warning": "warning", "warning": "WARNING", + "caution": "warning", } +} +``` + +Many-to-one, so `CAUTION` is unrecoverable — `README.md:438` says so, and +`calloutMacroInverse`'s comment calls it out. It is also **colour-wrong in two +of five places**, which nobody had measured until now (finding 10). + +### Reusable pieces + +- `renderRawBlock` emits a block-level element for passthrough in a round-trip + safe form: each wrapper tag on its own line (a CommonMark type-7 HTML block), + a *content container*'s body converted to markdown and set off by blank + lines, leaf children serialized raw on one line. The content-container set is + hardcoded to `ac:rich-text-body` and `ac:layout-cell`. +- `serialize` re-emits a node as storage XML for inline passthrough. +- `droppedAttrs` (`ac:macro-id`, `ac:local-id`) omits server-generated + per-instance ids from passthrough serialization. +- `renderBlockquote` in `renderer.go` writes the macro when a blockquote + carries the `calloutAttr` node attribute; `calloutTransformer` sets it. +- `renderCallout` in `storage_to_md.go` reads `ac:rich-text-body`. +- `shieldStorage`, `findChild`, `prefixLines`. + +## What was verified live (2026-09-01) + +Against `mozilla-hub.atlassian.net` through the API gateway. Scratch pages in +the personal space carried the probes and were trashed afterwards. The evidence +goes into `docs/confluence/storage-format.md`. + +1. **The four callout macros map onto four of the five ADF panel types.** + Published as storage, read back as `atlas_doc_format`: + + | macro published | ADF `panelType` | colour | + |---|---|---| + | `info` | `info` | blue | + | `tip` | `success` | green | + | `note` | `warning` | yellow | + | `warning` | `error` | red | + + **The two vocabularies collide on three strings and agree on one.** This is + the trap in the whole area: + + | string | as `ac:name` on a macro | as an ADF `panelType` | + |---|---|---| + | `info` | blue | blue — *the only agreement* | + | `note` | **yellow** | **purple** | + | `warning` | **red** | **yellow** | + | `tip` | green | not a panel type | + | `success` | not a macro | green | + | `error` | not a macro | red | + +2. **Purple `note` is the only panel type with no macro, and the only one that + serializes as an `ac:adf-extension`.** PUTting all five panel types as ADF — + which is what the editor does on any save — and reading the storage back + gave four `ac:structured-macro`s and one `ac:adf-extension`. + +3. **markfluence's current callout output survives an editor save unchanged.** + Follows from 1 and 2: every macro it emits round-trips as a macro. + +4. **A bare `ac:adf-extension` with no `ac:adf-fallback` is accepted on a + storage PUT**, reads back as a real ADF `panel` node with + `panelType: "note"`, and stores byte-identical. So `MdToConfluence` can emit + a purple panel, and passthrough without the fallback is lossless. + +5. **Confluence does not regenerate `ac:adf-fallback` in storage.** The page + from 4 still had no fallback on a later read. + +6. **`ac:adf-fallback` is a cache of a derived rendering, not a source of + truth.** `body-format=export_view` on that fallback-less page returns the + styled div in full — correct `#EAE6FF` background, `#998DD9` border, + complete body — and `body-format=view` likewise. PDF/Word export is built + from `export_view`, so the one consumer the stored fallback plausibly served + is served without it. + +7. **The bug is a live L5 violation with content loss, not just an ugly + export** (#125). `check --show-html` on the QBR export reports **zero** + `adf-extension` and **two** copies of each panel's text: republishing that + export would delete both purple panels and duplicate their prose into the + body. + +8. **An `ac:adf-extension` carrying a panel type that *does* have a macro is + accepted, stored verbatim, and produces identical ADF.** Publishing + `panel-type` `info`/`success`/`warning`/`error` as extensions was not + normalized on write. + +9. **But an editor save rewrites those four back to macros.** Follows from 8 + and 2: the ADF is the same either way, and serializing that ADF to storage + yields the macro. So `ac:structured-macro` is the **canonical** storage + spelling that Confluence's own serializer picks, not a deprecated one it + tolerates — "legacy" is the wrong word for it and is avoided throughout. + The extension is what Confluence falls back to when no macro exists. + +10. **GitHub's alert colours, and what two publishers do with them.** GitHub + renders NOTE blue, TIP green, IMPORTANT **purple**, WARNING orange, + CAUTION red + ([changelog](https://github.blog/changelog/2023-12-14-new-markdown-extension-alerts-provide-distinctive-styling-for-significant-content/)). + `kovetskiy/mark` is publish-only — no `adf`, `panelType` or + `ac:adf-extension` anywhere in it, and no storage→markdown direction, so it + cannot have #125 — and emits the same canonical macros + (`renderer/blockquote.go`, `renderer/gh_alerts_title_test.go`): + + | alert | GitHub | mark → macro | mark's colour | markfluence → macro | our colour | + |---|---|---|---|---|---| + | NOTE | blue | `info` | blue ✓ | `info` | blue ✓ | + | TIP | green | `tip` | green ✓ | `tip` | green ✓ | + | IMPORTANT | **purple** | `info` | blue ✗ | `note` | yellow ✗ | + | WARNING | orange | `note` | yellow ✓ | `warning` | red ✗ | + | CAUTION | red | `warning` | red ✓ | `warning` | red ✓ | + + mark gets four of five and misses only IMPORTANT, because purple is + unreachable from a macro. markfluence gets three of five. Finding 4 says + purple *is* reachable for us. + +## Decisions + +### The double render + +**An `ac:adf-extension` renders its `ac:adf-node` and never its +`ac:adf-fallback`** — type-agnostic, no `type="panel"` condition, because a +fallback is by definition a second rendering of the content the node already +carries. Whatever extension type Atlassian ships next is fixed in advance. + +**One exception: no node, render the fallback.** If an extension has no +`ac:adf-node`, it passes through intact, fallback included. Never observed and +possibly nonexistent; the reason to write the condition is that the failure +mode without it is silent content deletion on export and then, on the next +`update`, from the page. + +**`ac:adf-content` becomes a content container**, joining +`ac:rich-text-body`/`ac:layout-cell`. Otherwise a panel's prose and bullets +serialize as one raw element per line and the export is XML nobody can edit. + +**`` is dropped.** Same rationale as +`droppedAttrs`; finding 4 shows a panel with none is accepted. It is an +*element* keyed by `key`, not an attribute, so it needs its own line. + +**Both block and inline context.** `inlineString`'s default duplicates the same +way. Mirrors `ac:structured-macro`'s existing presence in both switches. + +**markfluence does not synthesize `ac:adf-fallback`.** Findings 5 and 6: it is +never coming back on its own, and nothing needs it. Generating one would mean +hardcoding a colour table inferred from one sample. Preserving the original was +also rejected — it is stale the moment anyone edits the body, and a page that +renders new text while a fallback consumer sees old text is worse than a clean +absence. + +### The colour-faithful remap + +**The map becomes a bijection that matches GitHub's own colours:** + +| alert | GitHub | publishes as | ADF panel | recovered as | +|---|---|---|---|---| +| NOTE | blue | `` | `info` | NOTE | +| TIP | green | `` | `success` | TIP | +| IMPORTANT | purple | `` `panel-type=note` | `note` | IMPORTANT | +| WARNING | orange | `` | `warning` | WARNING | +| CAUTION | red | `` | `error` | CAUTION | + +Three things fall out at once. Every alert now publishes the colour GitHub +draws it in, so a page looks the same on both sides. Nothing is many-to-one any +more, so **`CAUTION` stops being unrecoverable** and `README.md:438`'s +statement of that goes away. And the purple panel — the construct this whole +plan is about — gets the one job it is actually right for. + +**This is why the purple read branch is not speculative.** markfluence now +emits a purple extension, so reading one back as `IMPORTANT` is required for +L5, not insurance. It is the only `type="panel"` branch in the change. + +**No branch for the other four panel types.** An extension carrying +`panel-type` `info`/`success`/`warning`/`error` is not something Confluence +produces (finding 9) and not something markfluence emits, so converting it +would be unreachable code. Those pass through with the rest. + +**The two maps stay separate and disagree on purpose.** Per finding 1, +`warning` is a key in the macro vocabulary (red, CAUTION under the new map) and +in the panel vocabulary (yellow, WARNING). There is deliberately no shared map, +and a test pins the disagreement so a later tidy-up cannot merge them. + +**Published pages change appearance on their next `update`.** IMPORTANT goes +yellow→purple, WARNING goes red→orange, CAUTION stays red. markfluence is +unreleased, so this is a free correction rather than a migration — but it is an +intended, visible output change and the commit message says so. + +### Everything else + +**L5 stays Partial; its stated reason is corrected.** Multi-page export is +still missing, so the status does not move. But the paragraph's claim that +"single-page export's round-trip already works" is false — finding 7 is a +counterexample this change removes. + +**No new guarantee id.** This is a defect against L5 plus an output change, not +a new property. + +## Implementation + +### `internal/convert/callouts.go` + +`calloutMacro` becomes a target table, because IMPORTANT no longer names a +macro: + +```go +// calloutTarget is what a GitHub alert publishes as. Exactly one field is set: +// Confluence has a macro for four of the five colours GitHub draws alerts in, +// and purple exists only as an ADF panel (docs/confluence/storage-format.md). +type calloutTarget struct { + macro string // ac:name on an ac:structured-macro + panelType string // panel-type on an ac:adf-extension +} + +// calloutTargets maps a GitHub alert to the Confluence construct that renders +// in the same colour GitHub uses. Bijective -- every alert has its own target +// and every target its own alert -- which is what makes read/export able to +// recover the alert exactly. +var calloutTargets = map[string]calloutTarget{ + "note": {macro: "info"}, // blue + "tip": {macro: "tip"}, // green + "important": {panelType: "note"}, // purple; no macro exists + "warning": {macro: "note"}, // orange/yellow + "caution": {macro: "warning"}, // red +} +``` + +`calloutTransformer` stores the **alert type** in the `calloutAttr` node +attribute rather than the macro name, so the renderer decides the element. + +### `internal/convert/renderer.go` + +`renderBlockquote` looks the alert up and writes one of two shapes: + +```go + + +%s +``` + +No `ac:adf-fallback` and no `local-id` — findings 4-6. + +### `internal/convert/storage_to_md.go` + +```go +// calloutMacroInverse maps a Confluence callout macro back to a GitHub alert. +// Now one-to-one (calloutTargets is bijective), so nothing is unrecoverable. +var calloutMacroInverse = map[string]string{ + "info": "NOTE", + "tip": "TIP", + "note": "WARNING", // was IMPORTANT + "warning": "CAUTION", // was WARNING +} + +// adfPassthrough returns the ac:adf-extension to serialize: a copy without the +// ac:adf-fallback (a second rendering of the content the node already carries; +// Confluence regenerates it for export_view on demand and does not store one +// back) and without the node's server-generated local-id. An extension with no +// ac:adf-node is returned unchanged: the fallback is then the only copy there +// is, and dropping it would silently delete the content. +func adfPassthrough(n *snode) *snode + +// adfAttr reads an child's text. +func adfAttr(node *snode, key string) string +``` + +`renderBlock` and `inlineString` each gain: + +```go +case "ac:adf-extension": + // A purple panel is the one extension with a markdown spelling: it is what + // MdToConfluence publishes for IMPORTANT. Everything else passes through. + if node := findChild(n, "ac:adf-node"); node != nil && + node.attrs["type"] == "panel" && adfAttr(node, "panel-type") == "note" { + return r.renderCallout(node, "IMPORTANT") // block context only + } + return r.renderRawBlock(adfPassthrough(n)) // serialize(...) when inline +``` + +`renderCallout` takes the alert directly instead of looking a macro up, and +finds its body under `ac:rich-text-body` *or* `ac:adf-content`, so both +spellings of a callout produce identical markdown from one code path. + +`renderRawBlock`'s content-container set is tested in two places; extract +`isContentContainer(name string) bool` so the set exists once. + +`adfPassthrough` copies rather than mutating — `headingSlugs` has already +walked the tree and `renderBlock` may be re-entered. + +## Tests + +- **`testdata/regression/callouts/`** — regenerate. The golden now shows the + new colour targets and an `ac:adf-extension` for IMPORTANT. This is the + clearest single artifact of the remap; review it closely. + `make regen-regressions`. +- **`testdata/storage2md/callouts/`** — regenerate: `note`→WARNING, + `warning`→CAUTION, and a purple extension → `> [!IMPORTANT]`. +- **`testdata/storage2md/adf-panel/`** — new. The QBR page's first panel + verbatim (node + fallback + `local-id`), a non-panel extension, and an + extension with a fallback and no node. Pins that content appears once, that + the body is markdown, and that no `local-id` survives. +- **`testdata/regression/adf-panel/`** — new, the write half for the + *passthrough* form: `main.md` holds a hand-written non-panel extension and + the golden asserts `MdToConfluence` reproduces it through the shield. +- **Round-trip unit test** — for each of the five alerts, `MdToConfluence` then + `StorageToMarkdown` returns the same alert. This is the property the remap + buys and the one thing that would catch a half-applied change to either map. +- **Unit** — the maps disagree on purpose: `calloutMacroInverse["warning"]` is + `CAUTION` while panel type `warning` is orange/WARNING, and `"note"` means + different colours in each vocabulary. Without this a later tidy-up merges + them and silently repaints panels. +- **Unit** — `adfPassthrough`: fallback dropped, `local-id` dropped, + `panel-type` kept, node-less extension returned intact, input not mutated. +- `make check`. +- **One live round trip**, pasted into the PR body: publish a file with all + five alerts, confirm the colours in the editor, `export`, `update` from the + export, and diff the stored storage. Expect equality modulo server-generated + ids. Then the same for the QBR page's editor-authored purple panel. + +## Docs + +- **`docs/confluence/storage-format.md`** — new section holding findings 1-9: + the macro ↔ `panelType` table, the vocabulary collision, purple being the + only extension, the fallback being a regenerable cache, and Confluence's own + serializer picking the macro. Written as a trap, in the register the file + already uses for `data-layout` and cell colour, and carrying two: a fallback + looks like content and is not, and `note`/`warning` mean different colours + depending on which vocabulary you are in. +- **`docs/guarantees.md`** — amend the L5/L6 paragraph. Status stays Partial; + the sentence claiming single-page round-trip already works gains this + construct as the second thing that was breaking it, with finding 7's + measurement. +- **`README.md`** — line 1131's "become info/tip/note/warning panels" becomes + the five-row colour table; line 438's "`CAUTION` alerts … cannot be + recovered" is deleted, since it no longer is. Table cell background colours + stay listed as lossy. +- **Deliberately not `CLAUDE.md`.** Close call — flagged so it can be + overridden on review — but the `internal/convert` bullet is already the + longest in the file and it already directs a reader to `docs/confluence/`. + +## Commits + +1. `docs(plans): plan ac:adf-extension passthrough and a colour-faithful callout map` +2. `docs(confluence): how ADF panels serialize, and what an adf-fallback is` +3. `fix(convert): render an ac:adf-extension once, from its node` — the + passthrough half. Closes #125. +4. `feat(convert): publish each GitHub alert in GitHub's own colour` — the + remap, both directions, regenerated goldens. Notes the visible output + change. +5. `docs: the callout colour map, and L5's single-page claim` — README + + `guarantees.md`. + +## Out of scope + +- Publishing an alert's name as the macro's `title` parameter, which `mark` + does and markfluence does not, so Confluence draws it as a styled header + rather than as the body's first line. Noticed while reading `mark`; worth its + own issue. +- A property test for L5. The Laws table says laws are verified by property + tests, there is none for L5, and that is why #125 went unnoticed. +- Every other unknown storage element still falling through the transparent + wrapper. The default is right for a genuine wrapper; `ac:adf-extension` is + the one known element for which it is wrong. From 88f3d18191284d44545e923500c4ea5bf30d6fe7 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Tue, 1 Sep 2026 16:48:15 -0400 Subject: [PATCH 2/6] docs(confluence): how ADF panels serialize, and what an adf-fallback is Replaces the two-sentence callout section with what was measured on 2026-09-01. Four findings a reader needs before touching callouts.go: - The macro names and ADF panel types collide. Only "info" means the same thing in both; the "note" macro is yellow where panelType note is purple, and the "warning" macro is red where panelType warning is yellow. - Purple has no macro, so it is the one callout that serializes as an ac:adf-extension -- which carries its content twice, once as ac:adf-node and once as a pre-rendered ac:adf-fallback. That duplicate is #125. - The fallback is a regenerable cache: a bare extension is accepted and stored verbatim, Confluence never puts a fallback back, and export_view renders the styled panel without one. - ac:structured-macro is canonical rather than legacy. The extension spelling is accepted for the other four panel types but an editor save rewrites it to the macro, because that is what Confluence's own ADF serializer emits. Also records that a macro title parameter does not survive an editor save -- ADF has no panel title, so it is flattened into a bold first paragraph. --- docs/confluence/storage-format.md | 140 ++++++++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 5 deletions(-) diff --git a/docs/confluence/storage-format.md b/docs/confluence/storage-format.md index 6fa6181..f7a88a1 100644 --- a/docs/confluence/storage-format.md +++ b/docs/confluence/storage-format.md @@ -167,9 +167,139 @@ one set by hand and shows as the selected swatch. Read off an editor-authored pa 2026-08-04; the picker is seven hue columns by three shades, with the grey column running white / light grey / grey. **Transcribed.** -## Callout macros +## Callout macros and ADF panels -GitHub alert types map many-to-one onto Confluence's macros, so the mapping is -lossy in one direction: `CAUTION` folds into `warning` and cannot be recovered, -`note` came from `IMPORTANT`, and `info` came from `NOTE`. `calloutMacroInverse` -in `storage_to_md.go` is the canonical inverse. **Transcribed.** +A callout has **two** storage spellings, and the vocabularies they use collide. +Read this section before touching `callouts.go` or `calloutMacroInverse`. + +### The four macros are four of five ADF panel types + +**Verified 2026-09-01.** Each macro published as storage, read back as +`atlas_doc_format`: + +| macro published | ADF `panelType` | colour | +|---|---|---| +| `info` | `info` | blue | +| `tip` | `success` | green | +| `note` | `warning` | yellow | +| `warning` | `error` | red | + +### Trap: `note` and `warning` mean different colours in each vocabulary + +The macro names and the ADF panel types overlap on three strings and agree on +exactly one: + +| string | as `ac:name` on a macro | as an ADF `panelType` | +|---|---|---| +| `info` | blue | blue — *the only agreement* | +| `note` | **yellow** | **purple** | +| `warning` | **red** | **yellow** | +| `tip` | green | not a panel type | +| `success` | not a macro | green | +| `error` | not a macro | red | + +So "the Note panel" is ambiguous on its own. The `note` *macro* is yellow. The +purple thing the editor calls a Note is `panelType: note`, which has no macro +at all. A map keyed by panel type must never be keyed by macro name or the +other way round. + +### Purple is the only panel that is not a macro + +**Verified 2026-09-01.** PUTting all five panel types as ADF — which is what +the editor does on any save — and reading the storage back gave four +`ac:structured-macro`s and one `ac:adf-extension`: + +```xml + + + note + 54e36e4937ac + …the content… + + +
…the same content…
+
+
+``` + +`ac:adf-extension` is what Confluence falls back to when a construct has no +storage element of its own. It is the only shape a callout takes that is not a +macro, and it only ever appears for a purple panel a human inserted. + +### Trap: `ac:adf-fallback` looks like content and is not + +The extension carries the same content **twice**: once as the authoritative +`ac:adf-node`, once as a pre-rendered `ac:adf-fallback`. Anything walking the +tree generically renders both. This is exactly the bug that produced #125 — +every purple panel exported twice. + +**`ac:adf-fallback` is a cache of a derived rendering, not a source of truth.** +**Verified 2026-09-01:** + +- A bare extension with **no** fallback is accepted on a storage PUT, reads + back as a real ADF `panel` node, and stores byte-identical. +- Confluence does **not** regenerate a stored fallback — the page above still + had none on a later read. +- `body-format=export_view` on that fallback-less page returns the styled div + in full: `#EAE6FF` background, `#998DD9` border, complete body. + `body-format=view` likewise. PDF/Word export is built from `export_view`, so + the one consumer the stored fallback plausibly served is served without it. + +So markfluence neither preserves nor synthesizes a fallback. Preserving one is +worse than dropping it: it goes stale the moment the body is edited, and a page +that renders new text while a fallback consumer sees old text is a silent +divergence. + +### `ac:structured-macro` is canonical, not legacy + +**Verified 2026-09-01.** Publishing `` with `panel-type` +`info`/`success`/`warning`/`error` is also accepted, is stored **verbatim** +(not normalized on write), and produces ADF byte-identical to what the macros +produce. But serializing that ADF back to storage yields the *macro*. Since an +editor save is exactly that round trip, the four panel types with macros always +come back as macros. + +Confluence's own serializer picks the macro. "Legacy" is the wrong word for it: +publish the macro, and let the extension be what it is — the spelling for a +construct with no macro. + +### Trap: a macro `title` parameter does not survive an editor save + +**Verified 2026-09-01.** ADF's `panel` node has no title attribute, only +`panelType`. Publishing `Heads up` +renders a header at first, but the ADF Confluence derives from it is: + +```json +{"type":"panel","attrs":{"panelType":"info"},"content":[ + {"type":"paragraph","content":[{"text":"Heads up","marks":[{"type":"strong"}]}]}, + {"type":"paragraph","content":[{"text":"titled info body"}]}]} +``` + +and the storage after that save has **no `title` parameter** — the title has +become a bold first paragraph of the body. + +This is why markfluence does not publish an alert's name as a title, though +`kovetskiy/mark` does: mark is publish-only and never reads a page back, so it +never meets the consequence. For a tool with an export direction the title +compounds — publish `title="Note"`, the editor demotes it to `**Note**` in the +body, export reads that as body text, the next publish sets the title *and* +keeps the bold line, and the next save makes two of them. + +### The colour-faithful map + +GitHub renders NOTE blue, TIP green, IMPORTANT **purple**, WARNING orange, +CAUTION red +([changelog](https://github.blog/changelog/2023-12-14-new-markdown-extension-alerts-provide-distinctive-styling-for-significant-content/)). +Because purple is reachable, markfluence can match all five: + +| alert | GitHub | publishes as | ADF panel | +|---|---|---|---| +| NOTE | blue | `` | `info` | +| TIP | green | `` | `success` | +| IMPORTANT | purple | `` `panel-type=note` | `note` | +| WARNING | orange | `` | `warning` | +| CAUTION | red | `` | `error` | + +The map is bijective, so **nothing is unrecoverable** — `CAUTION` used to fold +into `warning` and could not be read back. `calloutTargets` in `callouts.go` +and `calloutMacroInverse` in `storage_to_md.go` are the two halves. From 6e8a8d7150d8cf991385453cd87f89ed42ecc279 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Tue, 1 Sep 2026 16:50:58 -0400 Subject: [PATCH 3/6] fix(convert): render an ac:adf-extension once, from its node An holds its content twice: once as the authoritative ac:adf-node, once as an ac:adf-fallback holding a pre-rendered
. Both were unknown to renderBlock, so both hit the transparent-wrapper default and both rendered -- every editor-authored purple Note panel came out of export and read doubled. inlineString's default had the same defect. Give the element a rule of its own: render the node, never the fallback. It is type-agnostic rather than a panel special case, because a fallback is by definition a second rendering of what the node already carries, whatever the extension type. The fallback is safe to drop because it is a cache rather than a source of truth. A bare extension is accepted on a storage PUT and stored byte-identical, Confluence never writes a fallback back, and export_view -- which PDF and Word export are built from -- regenerates the styled div on demand. Verified 2026-09-01; the evidence is in docs/confluence/storage-format.md. Two details. An extension with no ac:adf-node keeps its fallback: it is then the only copy of the content, and dropping it would delete it on export and then from the page. And ac:adf-content joins the content-container set, so a panel's prose and bullets stay editable markdown instead of one raw element per line. The regression case pins that the passthrough form republishes through the shield unchanged. Closes #125. --- internal/convert/storage_to_md.go | 65 ++++++++++++- internal/convert/storage_to_md_test.go | 92 +++++++++++++++++++ .../testdata/regression/adf-panel/main.md | 21 +++++ .../testdata/regression/adf-panel/test.output | 6 ++ .../storage2md/adf-panel/input.storage | 3 + .../testdata/storage2md/adf-panel/output.md | 38 ++++++++ 6 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 internal/convert/testdata/regression/adf-panel/main.md create mode 100644 internal/convert/testdata/regression/adf-panel/test.output create mode 100644 internal/convert/testdata/storage2md/adf-panel/input.storage create mode 100644 internal/convert/testdata/storage2md/adf-panel/output.md diff --git a/internal/convert/storage_to_md.go b/internal/convert/storage_to_md.go index c2a7b06..1f650de 100644 --- a/internal/convert/storage_to_md.go +++ b/internal/convert/storage_to_md.go @@ -214,6 +214,8 @@ func (r *mdRenderer) renderBlock(n *snode, listIndent string) string { return r.renderTable(n) case "ac:structured-macro": return r.renderMacro(n, true) + case "ac:adf-extension": + return r.renderRawBlock(adfPassthrough(n)) case "ac:image", "ac:link", "a", "strong", "b", "em", "i", "code", "del", "s", "strike", "br": // An inline element sitting at block level (Confluence often emits a bare // not wrapped in

) is rendered as its own paragraph. @@ -758,6 +760,10 @@ func (r *mdRenderer) renderInline(n *snode) string { // An inline macro (e.g. status/emoticon) stays raw on one line so it does // not break out of its paragraph. return r.renderMacro(n, false) + case "ac:adf-extension": + // Likewise for an inline ADF extension, whose transparent-wrapper default + // would otherwise render the node and the fallback one after the other. + return serialize(adfPassthrough(n)) default: return r.renderInlineChildren(n) } @@ -937,7 +943,7 @@ func (r *mdRenderer) renderRawBlock(n *snode) string { closeTag := "" // Content container: raw tags around a markdown body. - if n.name == "ac:rich-text-body" || n.name == "ac:layout-cell" { + if isContentContainer(n.name) { if md := strings.Join(r.blockStrings(n.kids, ""), "\n\n"); md != "" { return open + "\n\n" + md + "\n\n" + closeTag } @@ -954,7 +960,7 @@ func (r *mdRenderer) renderRawBlock(n *snode) string { if k.name == "" { continue // drop inter-tag whitespace } - if k.name == "ac:rich-text-body" || k.name == "ac:layout-cell" || hasElementChild(k) { + if isContentContainer(k.name) || hasElementChild(k) { parts = append(parts, r.renderRawBlock(k)) } else { parts = append(parts, serialize(k)) @@ -963,6 +969,61 @@ func (r *mdRenderer) renderRawBlock(n *snode) string { return open + "\n" + strings.Join(parts, "\n") + "\n" + closeTag } +// isContentContainer reports whether an element holds block content that should +// be converted to markdown rather than serialized raw, so a passed-through +// wrapper keeps an editable body. +func isContentContainer(name string) bool { + switch name { + case "ac:rich-text-body", "ac:layout-cell", "ac:adf-content": + return true + } + return false +} + +// adfPassthrough returns the to render: a copy carrying the +// ac:adf-node alone. +// +// An extension holds its content twice -- once as the authoritative node, once +// as an ac:adf-fallback holding a pre-rendered

. Rendering both is what +// made a purple Note panel export twice (#125). The fallback is a cache of a +// derived rendering rather than a source of truth: Confluence accepts an +// extension without one, never stores one back, and regenerates the styled div +// for export_view on demand (docs/confluence/storage-format.md). The node's +// server-generated local-id goes for the same reason droppedAttrs exists. +// +// An extension with no ac:adf-node is returned unchanged: the fallback is then +// the only copy of the content there is, and dropping it would delete it. +func adfPassthrough(n *snode) *snode { + node := findChild(n, "ac:adf-node") + if node == nil { + return n + } + out := &snode{name: n.name, attrs: n.attrs, text: n.text} + for _, k := range n.kids { + if k.name == "ac:adf-fallback" { + continue + } + if k == node { + k = withoutADFAttr(node, "local-id") + } + out.kids = append(out.kids, k) + } + return out +} + +// withoutADFAttr copies an ac:adf-node without one of its +// children. +func withoutADFAttr(node *snode, key string) *snode { + out := &snode{name: node.name, attrs: node.attrs, text: node.text} + for _, k := range node.kids { + if k.name == "ac:adf-attribute" && k.attrs["key"] == key { + continue + } + out.kids = append(out.kids, k) + } + return out +} + // hasElementChild reports whether n has any element (non-text) child. func hasElementChild(n *snode) bool { for _, k := range n.kids { diff --git a/internal/convert/storage_to_md_test.go b/internal/convert/storage_to_md_test.go index 3805f72..a4e900e 100644 --- a/internal/convert/storage_to_md_test.go +++ b/internal/convert/storage_to_md_test.go @@ -560,3 +560,95 @@ func TestStorageToMarkdownStillAutoClosesVoidElements(t *testing.T) { } } } + +// TestStorageToMarkdownRendersADFExtensionOnce is #125: an +// carries its content twice -- once as the authoritative ac:adf-node, once as a +// pre-rendered ac:adf-fallback -- and the transparent-wrapper default rendered +// both, so every editor-authored purple Note panel exported doubled. +// +// Counting occurrences rather than matching a golden, because a golden +// regenerated against the bug looks perfectly plausible. +func TestStorageToMarkdownRendersADFExtensionOnce(t *testing.T) { + const storage = `` + + `note` + + `

UNIQUEBODY

` + + `
` + + `

UNIQUEBODY

` + + md, err := convert.StorageToMarkdown(storage, convert.StorageOptions{}) + if err != nil { + t.Fatalf("StorageToMarkdown: %v", err) + } + if got := strings.Count(md, "UNIQUEBODY"); got != 1 { + t.Errorf("body appears %d times, want 1:\n%s", got, md) + } + if strings.Contains(md, "adf-fallback") { + t.Errorf("fallback survived:\n%s", md) + } + if strings.Contains(md, "panelContent") { + t.Errorf("fallback rendering survived:\n%s", md) + } +} + +// TestStorageToMarkdownADFPassthroughDoesNotMutate: adfPassthrough copies rather +// than editing the parsed tree, which matters because headingSlugs has already +// walked it and a document may hold more than one extension. Two identical +// extensions must render identically. +func TestStorageToMarkdownADFPassthroughDoesNotMutate(t *testing.T) { + const one = `` + + `note` + + `abc123` + + `

same

` + + `

same

` + + md, err := convert.StorageToMarkdown(one+one, convert.StorageOptions{}) + if err != nil { + t.Fatalf("StorageToMarkdown: %v", err) + } + if got := strings.Count(md, ``); got != 2 { + t.Errorf("rendered %d nodes, want 2:\n%s", got, md) + } + if strings.Contains(md, "local-id") { + t.Errorf("server-generated local-id survived:\n%s", md) + } + if got := strings.Count(md, "panel-type"); got != 2 { + t.Errorf("panel-type kept %d times, want 2:\n%s", got, md) + } +} + +// TestStorageToMarkdownKeepsAFallbackThatIsTheOnlyCopy: the fallback is dropped +// because the node holds the same content. With no node there is nothing else, +// so dropping it would silently delete the content on export -- and then, on the +// next update, from the page. +func TestStorageToMarkdownKeepsAFallbackThatIsTheOnlyCopy(t *testing.T) { + const storage = `` + + `

ONLYCOPY

` + + md, err := convert.StorageToMarkdown(storage, convert.StorageOptions{}) + if err != nil { + t.Fatalf("StorageToMarkdown: %v", err) + } + if !strings.Contains(md, "ONLYCOPY") { + t.Errorf("content deleted:\n%s", md) + } +} + +// TestStorageToMarkdownRendersAnInlineADFExtensionOnce covers the other switch: +// inlineString's default is renderInlineChildren, which duplicated an inline +// extension exactly the way the block default did. +func TestStorageToMarkdownRendersAnInlineADFExtensionOnce(t *testing.T) { + const storage = `

before ` + + `INLINEBODY` + + `INLINEBODY after

` + + md, err := convert.StorageToMarkdown(storage, convert.StorageOptions{}) + if err != nil { + t.Fatalf("StorageToMarkdown: %v", err) + } + if got := strings.Count(md, "INLINEBODY"); got != 1 { + t.Errorf("body appears %d times, want 1:\n%s", got, md) + } + if strings.Count(md, "\n") > 1 { + t.Errorf("inline extension broke out of its paragraph:\n%s", md) + } +} diff --git a/internal/convert/testdata/regression/adf-panel/main.md b/internal/convert/testdata/regression/adf-panel/main.md new file mode 100644 index 0000000..219c27b --- /dev/null +++ b/internal/convert/testdata/regression/adf-panel/main.md @@ -0,0 +1,21 @@ +# ADF Extension Passthrough + +An ADF extension exported from a page republishes unchanged, with its body +converted back from markdown: + + + +Details + + +Hidden **body** with a [link](https://example.net). + +- one +- two + + + + + +No `ac:adf-fallback` is written back: Confluence regenerates the rendering it +held. diff --git a/internal/convert/testdata/regression/adf-panel/test.output b/internal/convert/testdata/regression/adf-panel/test.output new file mode 100644 index 0000000..83ce29d --- /dev/null +++ b/internal/convert/testdata/regression/adf-panel/test.output @@ -0,0 +1,6 @@ +{ + "attachments": [], + "broken": [], + "html": "

ADF Extension Passthrough

\n

An ADF extension exported from a page republishes unchanged, with its body converted back from markdown:

\n\n\nDetails\n\n

Hidden body with a link.

\n
    \n
  • one
  • \n
  • two
  • \n
\n
\n
\n
\n

No ac:adf-fallback is written back: Confluence regenerates the rendering it held.

\n", + "warnings": [] +} diff --git a/internal/convert/testdata/storage2md/adf-panel/input.storage b/internal/convert/testdata/storage2md/adf-panel/input.storage new file mode 100644 index 0000000..3888a7c --- /dev/null +++ b/internal/convert/testdata/storage2md/adf-panel/input.storage @@ -0,0 +1,3 @@ +

An editor-authored purple Note panel renders once, from its node:

note54e36e4937ac

Notes / open questions

  • An emphasised item with code.

  • A second item.

+

Notes / open questions

  • An emphasised item with code.

  • A second item.

+

An extension of some other type passes through the same way:

Details

Hidden body.

Hidden body.

An extension with a fallback and no node keeps the fallback, its only copy:

Only copy.

diff --git a/internal/convert/testdata/storage2md/adf-panel/output.md b/internal/convert/testdata/storage2md/adf-panel/output.md new file mode 100644 index 0000000..dfcc45b --- /dev/null +++ b/internal/convert/testdata/storage2md/adf-panel/output.md @@ -0,0 +1,38 @@ +An editor-authored purple Note panel renders once, from its node: + + + +note + + +**Notes / open questions** + +- An *emphasised* item with `code`. +- A second item. + + + + + +An extension of some other type passes through the same way: + + + +Details + + +Hidden **body**. + + + + + +An extension with a fallback and no node keeps the fallback, its only copy: + + + +
+

Only copy.

+
+
+
From a5e6bdbfe43209284311b83871309a5e9fec7f0d Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Tue, 1 Sep 2026 16:55:50 -0400 Subject: [PATCH 4/6] feat(convert): publish each GitHub alert in GitHub's own colour GitHub draws NOTE blue, TIP green, IMPORTANT purple, WARNING orange and CAUTION red. markfluence published IMPORTANT yellow and WARNING red, and folded CAUTION into the same macro as WARNING -- so a published page did not look like its preview, and CAUTION could not be read back at all. Purple was the reason: it has no Confluence macro, so it was assumed unreachable. It is not. A purple panel is an with panel-type=note, which a storage PUT accepts and stores verbatim, so: NOTE -> ac:name="info" (blue) TIP -> ac:name="tip" (green) IMPORTANT -> ac:adf-extension (purple) WARNING -> ac:name="note" (orange) CAUTION -> ac:name="warning" (red) The map is now bijective, which is what lets calloutMacroInverse recover every alert. Nothing is unrecoverable. Both directions move together on purpose: half the change regenerates every golden cleanly while silently repainting alerts, so TestCalloutsRoundTrip pins all five through MdToConfluence and back. Mind the vocabularies. A macro named "note" is yellow where an ADF panel typed "note" is purple, and a macro named "warning" is red where a panel typed "warning" is yellow; only "info" agrees. calloutMacroInverse and adfPanelAlert are deliberately separate lookups and TestCalloutVocabulariesDisagree stops them being merged. Only the purple panel is read back from an extension. Confluence serializes the other four as macros, so a branch for them would be unreachable. This changes how already-published pages look on their next update: IMPORTANT yellow -> purple, WARNING red -> orange. Verified live 2026-09-01: all five published to the intended panelType, exported back to the same five alerts, and came back identical after an editor save. --- internal/convert/callouts.go | 58 +++++++---- internal/convert/renderer.go | 33 +++++-- internal/convert/storage_to_md.go | 68 ++++++++++--- internal/convert/storage_to_md_test.go | 98 ++++++++++++++++++- .../testdata/regression/callouts/test.output | 2 +- .../regression/kitchen-sink/test.output | 2 +- .../storage2md/adf-panel/input.storage | 4 +- .../testdata/storage2md/adf-panel/output.md | 22 ++--- .../storage2md/callouts/input.storage | 2 +- .../testdata/storage2md/callouts/output.md | 12 +-- 10 files changed, 234 insertions(+), 67 deletions(-) diff --git a/internal/convert/callouts.go b/internal/convert/callouts.go index a81137e..c6af3b0 100644 --- a/internal/convert/callouts.go +++ b/internal/convert/callouts.go @@ -10,17 +10,37 @@ import ( ) // calloutAttr is the node attribute set on a blockquote recognized as a -// GitHub-style callout; its value is the Confluence macro name to emit. +// GitHub-style callout; its value is the lowercase alert type, which +// calloutTargets turns into the element to emit. const calloutAttr = "mfCallout" -// calloutMacro maps a GitHub alert type to its Confluence macro. Confluence has -// no separate "caution", so it reuses "warning". -var calloutMacro = map[string]string{ - "note": "info", - "tip": "tip", - "important": "note", - "warning": "warning", - "caution": "warning", +// calloutTarget is what a GitHub alert publishes as. Exactly one field is set: +// Confluence has a macro for four of the five colours GitHub draws alerts in, +// and purple exists only as an ADF panel, which has no macro and so is written +// as an (docs/confluence/storage-format.md). +type calloutTarget struct { + macro string // ac:name on an ac:structured-macro + panelType string // panel-type on an ac:adf-extension +} + +// calloutTargets maps a GitHub alert to the Confluence construct that renders in +// the same colour GitHub uses: NOTE blue, TIP green, IMPORTANT purple, WARNING +// orange, CAUTION red. +// +// The mapping is bijective -- every alert has its own target and every target +// its own alert -- which is what lets calloutMacroInverse recover the alert +// exactly. It used to be many-to-one, with CAUTION folded into "warning" and +// unrecoverable, because purple was assumed unreachable. +// +// Beware the vocabularies: a macro named "note" is yellow, where an ADF panel +// typed "note" is purple, and a macro named "warning" is red where an ADF panel +// typed "warning" is yellow. Only "info" means the same thing in both. +var calloutTargets = map[string]calloutTarget{ + "note": {macro: "info"}, // blue + "tip": {macro: "tip"}, // green + "important": {panelType: "note"}, // purple; no macro exists + "warning": {macro: "note"}, // orange + "caution": {macro: "warning"}, // red } // calloutMarkerRE matches a callout marker line such as "[!NOTE]". @@ -31,9 +51,9 @@ var calloutMarkerRE = regexp.MustCompile(`(?i)^\[!(NOTE|TIP|IMPORTANT|WARNING|CA // > [!NOTE] // > body... // -// into blockquotes tagged with the target macro, with the marker line stripped -// from the leading paragraph. A blockquote renderer turns the tag into an -// info/tip/note/warning macro. +// into blockquotes tagged with the alert type, with the marker line stripped +// from the leading paragraph. A blockquote renderer turns the tag into the +// callout construct that matches GitHub's own colour for it. type calloutTransformer struct{} func (calloutTransformer) Transform(doc *ast.Document, reader text.Reader, _ parser.Context) { @@ -50,7 +70,7 @@ func (calloutTransformer) Transform(doc *ast.Document, reader text.Reader, _ par if !ok { return ast.WalkContinue, nil } - macro, marker, ok := calloutMarker(para, source) + alert, marker, ok := calloutMarker(para, source) if !ok { return ast.WalkContinue, nil } @@ -69,16 +89,16 @@ func (calloutTransformer) Transform(doc *ast.Document, reader text.Reader, _ par if para.FirstChild() == nil { bq.RemoveChild(bq, para) } - bq.SetAttributeString(calloutAttr, macro) + bq.SetAttributeString(calloutAttr, alert) return ast.WalkSkipChildren, nil }) } // calloutMarker inspects a blockquote's leading paragraph. If its first line is a -// callout marker, it returns the target macro and the inline node that ends the -// marker line (the node bearing the line break, or the last node when the marker -// is the paragraph's only line). -func calloutMarker(para *ast.Paragraph, source []byte) (macro string, marker ast.Node, ok bool) { +// callout marker, it returns the lowercase alert type and the inline node that +// ends the marker line (the node bearing the line break, or the last node when +// the marker is the paragraph's only line). +func calloutMarker(para *ast.Paragraph, source []byte) (alert string, marker ast.Node, ok bool) { var line strings.Builder var end ast.Node for c := para.FirstChild(); c != nil; c = c.NextSibling() { @@ -96,5 +116,5 @@ func calloutMarker(para *ast.Paragraph, source []byte) (macro string, marker ast if m == nil { return "", nil, false } - return calloutMacro[strings.ToLower(m[1])], end, true + return strings.ToLower(m[1]), end, true } diff --git a/internal/convert/renderer.go b/internal/convert/renderer.go index 45daf4f..d63e9cf 100644 --- a/internal/convert/renderer.go +++ b/internal/convert/renderer.go @@ -167,15 +167,36 @@ func (r *storageRenderer) renderCodeBlock( } // renderBlockquote renders a blockquote. When the callout transformer has tagged -// it, it becomes an info/tip/note/warning macro; otherwise a plain
. +// it with an alert type, it becomes that alert's calloutTargets construct -- a +// callout macro, or an ADF extension for the purple panel that has no macro. +// Otherwise a plain
. func (r *storageRenderer) renderBlockquote( w util.BufWriter, _ []byte, node ast.Node, entering bool, ) (ast.WalkStatus, error) { - if macro, ok := node.AttributeString(calloutAttr); ok { - if entering { - _, _ = fmt.Fprintf(w, ``, macro) - } else { - _, _ = w.WriteString(``) + if attr, ok := node.AttributeString(calloutAttr); ok { + alert, _ := attr.(string) // set by calloutTransformer, always a string + target := calloutTargets[alert] + switch { + case target.panelType != "": + // A purple panel has no macro, so it is written the way Confluence + // itself writes one: an ADF extension. No ac:adf-fallback -- it is a + // cache Confluence regenerates on demand, and one written here would + // go stale the moment the body changed. + if entering { + _, _ = fmt.Fprintf(w, + ``+ + `%s`, + target.panelType) + } else { + _, _ = w.WriteString(``) + } + default: + if entering { + _, _ = fmt.Fprintf(w, + ``, target.macro) + } else { + _, _ = w.WriteString(``) + } } return ast.WalkContinue, nil } diff --git a/internal/convert/storage_to_md.go b/internal/convert/storage_to_md.go index 1f650de..e398d80 100644 --- a/internal/convert/storage_to_md.go +++ b/internal/convert/storage_to_md.go @@ -25,15 +25,41 @@ import ( "strings" ) -// calloutMacroInverse maps a Confluence callout macro back to a GitHub alert type. -// The forward map (calloutMacro) is many-to-one, so this is the canonical inverse: -// CAUTION is unrecoverable (it folded into "warning"), and "note" came from -// IMPORTANT while "info" came from NOTE. +// calloutMacroInverse maps a Confluence callout macro back to a GitHub alert +// type. calloutTargets is bijective, so this is a true inverse and nothing is +// unrecoverable -- CAUTION used to fold into "warning" and could not be read +// back at all. +// +// The fifth alert, IMPORTANT, has no entry because it has no macro: it is +// purple, which exists only as an ADF panel. adfPanelAlert covers it. +// +// Do not key this by an ADF panel type. The two vocabularies collide: the +// macro "note" is yellow where the panel type "note" is purple, and the macro +// "warning" is red where the panel type "warning" is yellow +// (docs/confluence/storage-format.md). var calloutMacroInverse = map[string]string{ "info": "NOTE", "tip": "TIP", - "note": "IMPORTANT", - "warning": "WARNING", + "note": "WARNING", + "warning": "CAUTION", +} + +// adfPanelAlert reports the GitHub alert an carries, or "" +// when it is not one markdown can spell. +// +// Only the purple panel qualifies, and only because MdToConfluence publishes +// IMPORTANT as one -- recovering it is what keeps that round trip whole. The +// four other panel types are not written as extensions by anything: Confluence +// serializes them as macros, so a branch for them would be unreachable. +func adfPanelAlert(n *snode) string { + node := findChild(n, "ac:adf-node") + if node == nil || node.attrs["type"] != "panel" { + return "" + } + if adfAttr(node, "panel-type") != "note" { + return "" + } + return "IMPORTANT" } // StorageToMarkdown converts a Confluence storage-format body to Markdown. @@ -215,6 +241,9 @@ func (r *mdRenderer) renderBlock(n *snode, listIndent string) string { case "ac:structured-macro": return r.renderMacro(n, true) case "ac:adf-extension": + if alert := adfPanelAlert(n); alert != "" { + return r.renderCallout(findChild(n, "ac:adf-node"), alert) + } return r.renderRawBlock(adfPassthrough(n)) case "ac:image", "ac:link", "a", "strong", "b", "em", "i", "code", "del", "s", "strike", "br": // An inline element sitting at block level (Confluence often emits a bare @@ -539,7 +568,7 @@ func (r *mdRenderer) renderMacro(n *snode, block bool) string { case name == "toc": return tocToken case calloutMacroInverse[name] != "": - return r.renderCallout(n, name) + return r.renderCallout(n, calloutMacroInverse[name]) case block: return r.renderRawBlock(n) default: @@ -561,10 +590,17 @@ func renderCodeMacro(n *snode) string { return "```" + lang + "\n" + code + "\n```" } -// renderCallout renders a callout macro as a GitHub alert blockquote. -func (r *mdRenderer) renderCallout(n *snode, macro string) string { - content := "[!" + calloutMacroInverse[macro] + "]" - if body := findChild(n, "ac:rich-text-body"); body != nil { +// renderCallout renders a callout as a GitHub alert blockquote. n is the macro +// or the ac:adf-node, whose bodies are spelled ac:rich-text-body and +// ac:adf-content respectively -- one code path, so the two spellings of the +// same callout cannot produce different markdown. +func (r *mdRenderer) renderCallout(n *snode, alert string) string { + content := "[!" + alert + "]" + body := findChild(n, "ac:rich-text-body") + if body == nil { + body = findChild(n, "ac:adf-content") + } + if body != nil { if inner := strings.Join(r.blockStrings(body.kids, ""), "\n\n"); inner != "" { content += "\n" + inner } @@ -1024,6 +1060,16 @@ func withoutADFAttr(node *snode, key string) *snode { return out } +// adfAttr returns the text of an ac:adf-node's child. +func adfAttr(node *snode, key string) string { + for _, k := range node.kids { + if k.name == "ac:adf-attribute" && k.attrs["key"] == key { + return textContent(k) + } + } + return "" +} + // hasElementChild reports whether n has any element (non-text) child. func hasElementChild(n *snode) bool { for _, k := range n.kids { diff --git a/internal/convert/storage_to_md_test.go b/internal/convert/storage_to_md_test.go index a4e900e..7b9f867 100644 --- a/internal/convert/storage_to_md_test.go +++ b/internal/convert/storage_to_md_test.go @@ -594,9 +594,12 @@ func TestStorageToMarkdownRendersADFExtensionOnce(t *testing.T) { // than editing the parsed tree, which matters because headingSlugs has already // walked it and a document may hold more than one extension. Two identical // extensions must render identically. +// +// Uses a non-panel extension deliberately: a purple panel converts to an alert, +// so it would not exercise the passthrough path at all. func TestStorageToMarkdownADFPassthroughDoesNotMutate(t *testing.T) { - const one = `` + - `note` + + const one = `` + + `Details` + `abc123` + `

same

` + `

same

` @@ -605,14 +608,14 @@ func TestStorageToMarkdownADFPassthroughDoesNotMutate(t *testing.T) { if err != nil { t.Fatalf("StorageToMarkdown: %v", err) } - if got := strings.Count(md, ``); got != 2 { + if got := strings.Count(md, ``); got != 2 { t.Errorf("rendered %d nodes, want 2:\n%s", got, md) } if strings.Contains(md, "local-id") { t.Errorf("server-generated local-id survived:\n%s", md) } - if got := strings.Count(md, "panel-type"); got != 2 { - t.Errorf("panel-type kept %d times, want 2:\n%s", got, md) + if got := strings.Count(md, `key="title"`); got != 2 { + t.Errorf("adf-attribute kept %d times, want 2:\n%s", got, md) } } @@ -652,3 +655,88 @@ func TestStorageToMarkdownRendersAnInlineADFExtensionOnce(t *testing.T) { t.Errorf("inline extension broke out of its paragraph:\n%s", md) } } + +// publishAlert converts a one-alert document and returns the storage it +// publishes. +func publishAlert(t *testing.T, alert string) string { + t.Helper() + md, err := frontmatter.Parse("main.md", "> [!"+alert+"]\n> The body.\n") + if err != nil { + t.Fatal(err) + } + root := testRoot(t, "") + page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG", "vtest") + if err != nil { + t.Fatalf("MdToConfluence: %v", err) + } + return page.HTML +} + +// TestCalloutsRoundTrip is the property the colour-faithful map buys: every +// GitHub alert publishes to a distinct Confluence construct and reads back as +// the same alert. The map used to be many-to-one -- WARNING and CAUTION both +// became the "warning" macro -- so CAUTION could not survive this at all. +// +// It also catches the failure mode a two-sided change invites: edit +// calloutTargets without editing calloutMacroInverse, or the reverse, and every +// golden still regenerates cleanly while alerts silently change colour. +func TestCalloutsRoundTrip(t *testing.T) { + for _, alert := range []string{"NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"} { + t.Run(alert, func(t *testing.T) { + storage := publishAlert(t, alert) + back, err := convert.StorageToMarkdown(storage, convert.StorageOptions{}) + if err != nil { + t.Fatalf("StorageToMarkdown: %v", err) + } + want := "> [!" + alert + "]\n> The body.\n" + if back != want { + t.Errorf("round trip\n got: %q\nwant: %q\nvia storage: %s", back, want, storage) + } + }) + } +} + +// TestCalloutTargetsAreDistinct pins the bijection itself. Two alerts sharing a +// target is exactly what made CAUTION unrecoverable, and it is an easy thing to +// reintroduce while editing the table. +func TestCalloutTargetsAreDistinct(t *testing.T) { + seen := map[string]string{} + for _, alert := range []string{"NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"} { + storage := publishAlert(t, alert) + if prev, dup := seen[storage]; dup { + t.Errorf("%s and %s publish identically: %s", prev, alert, storage) + } + seen[storage] = alert + } +} + +// TestCalloutVocabulariesDisagree guards the trap recorded in +// docs/confluence/storage-format.md: a macro name and an ADF panel type are +// different vocabularies that share strings. "note" as a macro is yellow +// (WARNING); "note" as a panel type is purple (IMPORTANT). Anything that merges +// the two lookups repaints panels. +func TestCalloutVocabulariesDisagree(t *testing.T) { + read := func(storage string) string { + t.Helper() + md, err := convert.StorageToMarkdown(storage, convert.StorageOptions{}) + if err != nil { + t.Fatalf("StorageToMarkdown: %v", err) + } + return md + } + macroNote := read(`` + + `

body

`) + panelNote := read(`` + + `note` + + `

body

`) + + if !strings.Contains(macroNote, "[!WARNING]") { + t.Errorf(`the "note" macro is yellow and must read back as WARNING, got: %s`, macroNote) + } + if !strings.Contains(panelNote, "[!IMPORTANT]") { + t.Errorf(`the "note" panel type is purple and must read back as IMPORTANT, got: %s`, panelNote) + } + if macroNote == panelNote { + t.Errorf("the two vocabularies' \"note\" must not converge: %s", macroNote) + } +} diff --git a/internal/convert/testdata/regression/callouts/test.output b/internal/convert/testdata/regression/callouts/test.output index 9de1269..93432fc 100644 --- a/internal/convert/testdata/regression/callouts/test.output +++ b/internal/convert/testdata/regression/callouts/test.output @@ -1,6 +1,6 @@ { "attachments": [], "broken": [], - "html": "

Callouts

\n

This is a note callout.

\n

This is a tip callout.

\n

This is an important callout.

\n

This is a warning callout.

\n

This is a caution callout.

\n
", + "html": "

Callouts

\n

This is a note callout.

\n

This is a tip callout.

\n
note

This is an important callout.

\n

This is a warning callout.

\n

This is a caution callout.

\n
", "warnings": [] } diff --git a/internal/convert/testdata/regression/kitchen-sink/test.output b/internal/convert/testdata/regression/kitchen-sink/test.output index 02ba1d0..89ff21b 100644 --- a/internal/convert/testdata/regression/kitchen-sink/test.output +++ b/internal/convert/testdata/regression/kitchen-sink/test.output @@ -7,6 +7,6 @@ } ], "broken": [], - "html": "

Release Notes

\n\n

See the upgrade guide before starting.

\n

Back up your data before upgrading.

\n

What's New

\n

A soft-wrapped paragraph describing the release across multiple source lines that collapse into one.

\n

\n

Configuration

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
SettingDefault
retries3
timeout30s
\npython

A pasted status macro: Stable

\n", + "html": "

Release Notes

\n\n

See the upgrade guide before starting.

\n

Back up your data before upgrading.

\n

What's New

\n

A soft-wrapped paragraph describing the release across multiple source lines that collapse into one.

\n

\n

Configuration

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
SettingDefault
retries3
timeout30s
\npython

A pasted status macro: Stable

\n", "warnings": [] } diff --git a/internal/convert/testdata/storage2md/adf-panel/input.storage b/internal/convert/testdata/storage2md/adf-panel/input.storage index 3888a7c..15c0ab7 100644 --- a/internal/convert/testdata/storage2md/adf-panel/input.storage +++ b/internal/convert/testdata/storage2md/adf-panel/input.storage @@ -1,3 +1,3 @@ -

An editor-authored purple Note panel renders once, from its node:

note54e36e4937ac

Notes / open questions

  • An emphasised item with code.

  • A second item.

+

A purple panel is IMPORTANT, read from its node -- its fallback holds the same content and must not render too:

note54e36e4937ac

Notes / open questions

  • An emphasised item with code.

  • A second item.

Notes / open questions

  • An emphasised item with code.

  • A second item.

-

An extension of some other type passes through the same way:

Details

Hidden body.

Hidden body.

An extension with a fallback and no node keeps the fallback, its only copy:

Only copy.

+

An extension of some other type has no markdown spelling, so it passes through -- node only:

Details

Hidden body.

Hidden body.

An extension with a fallback and no node keeps the fallback, its only copy:

Only copy.

diff --git a/internal/convert/testdata/storage2md/adf-panel/output.md b/internal/convert/testdata/storage2md/adf-panel/output.md index dfcc45b..f12c1ba 100644 --- a/internal/convert/testdata/storage2md/adf-panel/output.md +++ b/internal/convert/testdata/storage2md/adf-panel/output.md @@ -1,20 +1,12 @@ -An editor-authored purple Note panel renders once, from its node: +A purple panel is IMPORTANT, read from its node -- its fallback holds the same content and must not render too: - - -note - - -**Notes / open questions** - -- An *emphasised* item with `code`. -- A second item. - - - - +> [!IMPORTANT] +> **Notes / open questions** +> +> - An *emphasised* item with `code`. +> - A second item. -An extension of some other type passes through the same way: +An extension of some other type has no markdown spelling, so it passes through -- node only: diff --git a/internal/convert/testdata/storage2md/callouts/input.storage b/internal/convert/testdata/storage2md/callouts/input.storage index ae077fa..5693925 100644 --- a/internal/convert/testdata/storage2md/callouts/input.storage +++ b/internal/convert/testdata/storage2md/callouts/input.storage @@ -1 +1 @@ -

This is a note callout.

This is a tip callout.

This is an important callout.

This is a warning callout.

A multi-block callout: the blank quoted line between blocks has no trailing space.

  • alpha
  • beta
+

The info macro is blue, which is what GitHub draws NOTE in.

The tip macro is green, which is TIP.

The note macro is yellow -- not purple -- so it is WARNING, not IMPORTANT.

The warning macro is red, so it is CAUTION.

A multi-block callout: the blank quoted line between blocks has no trailing space.

  • alpha
  • beta
diff --git a/internal/convert/testdata/storage2md/callouts/output.md b/internal/convert/testdata/storage2md/callouts/output.md index a593388..110af5c 100644 --- a/internal/convert/testdata/storage2md/callouts/output.md +++ b/internal/convert/testdata/storage2md/callouts/output.md @@ -1,14 +1,14 @@ > [!NOTE] -> This is a note callout. +> The info macro is blue, which is what GitHub draws NOTE in. > [!TIP] -> This is a tip callout. - -> [!IMPORTANT] -> This is an important callout. +> The tip macro is green, which is TIP. > [!WARNING] -> This is a warning callout. +> The note macro is yellow -- not purple -- so it is WARNING, not IMPORTANT. + +> [!CAUTION] +> The warning macro is red, so it is CAUTION. > [!NOTE] > A multi-block callout: the blank quoted line between blocks has no trailing space. From 78006cf9f772b230aa2d3c86cef1060681c9cdb5 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Tue, 1 Sep 2026 16:56:33 -0400 Subject: [PATCH 5/6] docs: the callout colour map, and L5's single-page claim README's callout paragraph becomes the five-row colour table, and drops the statement that CAUTION cannot be recovered -- it can now that the map is bijective. The remaining lossy example is a table cell background outside the named swatches. guarantees.md's L5/L6 paragraph asserted that single-page export's round-trip already worked and pinned Partial entirely on #59. That was false: #125 was a plain single-page counterexample, and a measured one. The status does not move, since multi-page export is still missing, but the reason is corrected and the reason it went unnoticed is named -- L5 has no property test, which the same file says every Law should have. --- README.md | 18 ++++++++++++++---- docs/guarantees.md | 23 ++++++++++++++++++----- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 05ff22e..16b6957 100644 --- a/README.md +++ b/README.md @@ -434,9 +434,9 @@ composes with shell redirection. readable markdown, so they round-trip back through `create`/`update`. A page or space link converts back to a markdown link; a mention, an attachment link, and a blog-post link stay as raw storage, since a markdown link would republish to - something else or nothing at all. Some other transforms are lossy (e.g. - `CAUTION` alerts and table cell background colors cannot be recovered), so this - is a reading aid, not a guaranteed source round-trip. + something else or nothing at all. Some other transforms are lossy (e.g. a table + cell background color outside the named swatches comes back as a literal hex), + so this is a reading aid, not a guaranteed source round-trip. - `storage` — the page's raw storage-format XHTML, exactly as stored. ```sh @@ -1129,7 +1129,17 @@ can't do, so it isn't an option here. anything else. **GitHub alerts** — `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, -`[!CAUTION]` — become info/tip/note/warning panels. +`[!CAUTION]` — become Confluence panels in the colour GitHub draws them in: + +| alert | colour | published as | +|---|---|---| +| `NOTE` | blue | `info` macro | +| `TIP` | green | `tip` macro | +| `IMPORTANT` | purple | ADF panel (no macro exists for purple) | +| `WARNING` | orange | `note` macro | +| `CAUTION` | red | `warning` macro | + +The mapping is one-to-one, so `read`/`export` recover the original alert. [GFM alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) Example: diff --git a/docs/guarantees.md b/docs/guarantees.md index 016483f..97641f0 100644 --- a/docs/guarantees.md +++ b/docs/guarantees.md @@ -154,12 +154,25 @@ being able to reconstruct a tree on export. `images.go` records an attachment's `Source` relative to the root rather than to the referencing page, so identity follows the asset alone (`_plans/026` commit 4). -**L5** and **L6** stay Partial, deferred to #59 (multi-page export), even -though the mechanism that made them fail is already repaired: since -`_plans/026` commit 4 records an attachment's `Source` relative to the root, +**L5** and **L6** stay Partial, deferred to #59 (multi-page export). Two +separate things kept them there, and both are now repaired. Since `_plans/026` +commit 4 records an attachment's `Source` relative to the root, `attachfile.Resolve`'s `dest + source` join for a layout with an asset above -the page no longer escapes, and single-page export's round-trip already -works. What's still missing is multi-page export itself (Use case 8) — +the page no longer escapes. And `_plans/030` fixed a straightforward L5 +counterexample this file previously asserted did not exist: an +`` — the storage form of the editor's purple Note panel — +fell through `storage_to_md.go`'s transparent-wrapper default, so `export` +wrote its content twice and republishing that deleted the panel and left two +copies of its prose in the body (#125). It was measured, not hypothetical: +`check --show-html` on a real export reported zero `adf-extension` and two +copies of each panel's text. + +The lesson is worth keeping. The claim that single-page round-trip "already +works" survived here because nothing tests it — the table below says Laws are +verified by property tests, and L5 has none. Until it does, treat its status as +an assertion about known constructs rather than a property. + +What's still missing is multi-page export itself (Use case 8) — provenance-based attachment placement, directory mirroring — which is what these guarantees' own wording actually describes (a whole tree, either roundtrip direction). Calling them Holds now would be declaring a win on From 594032d5eebc994fb904d0ceb7f16171d12b3438 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Tue, 1 Sep 2026 17:06:52 -0400 Subject: [PATCH 6/6] test(convert): round-trip the ADF extension passthrough golden TestRoundTripPassthrough iterates a hardcoded list of the storage2md cases whose output is raw storage, and adf-panel was not in it. That test is the only guard on the property the passthrough exists for -- that output.md republishes through MdToConfluence and reads back identically -- so the expand extension and the fallback-only extension were checked for parseability and nothing else. Without it, a change to renderRawBlock or isContentContainer that reordered or dropped an ac:adf-attribute would leave every test green while export -> update silently mutated the page. --- internal/convert/storage_to_md_test.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/convert/storage_to_md_test.go b/internal/convert/storage_to_md_test.go index 7b9f867..a00e3e4 100644 --- a/internal/convert/storage_to_md_test.go +++ b/internal/convert/storage_to_md_test.go @@ -370,11 +370,15 @@ func TestStorageToMarkdownCoalescesSplitMarks(t *testing.T) { } // TestRoundTripPassthrough verifies that the raw-storage passthrough cases -// (column layouts and unknown macros) survive markdown -> storage -> markdown -// unchanged -- the whole point of emitting them in a form MdToConfluence -// re-publishes verbatim. +// (column layouts, unknown macros, ADF extensions) survive markdown -> storage +// -> markdown unchanged -- the whole point of emitting them in a form +// MdToConfluence re-publishes verbatim. +// +// The list is hardcoded rather than every storage2md case, because only these +// are passthrough: a case whose output is ordinary markdown is covered by its +// own golden. func TestRoundTripPassthrough(t *testing.T) { - for _, name := range []string{"layout", "unknown-macros", "excerpt", "aclink"} { + for _, name := range []string{"layout", "unknown-macros", "excerpt", "aclink", "adf-panel"} { t.Run(name, func(t *testing.T) { src, err := os.ReadFile(filepath.Join(storage2mdDir, name, "output.md")) if err != nil {