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/_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. 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. 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 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 c2a7b06..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. @@ -214,6 +240,11 @@ 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": + 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 // not wrapped in

) is rendered as its own paragraph. @@ -537,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: @@ -559,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 } @@ -758,6 +796,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 +979,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 +996,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 +1005,71 @@ 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 +} + +// 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 3805f72..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 { @@ -560,3 +564,183 @@ 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. +// +// 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 = `` + + `Details` + + `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, `key="title"`); got != 2 { + t.Errorf("adf-attribute 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) + } +} + +// 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/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/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 new file mode 100644 index 0000000..15c0ab7 --- /dev/null +++ b/internal/convert/testdata/storage2md/adf-panel/input.storage @@ -0,0 +1,3 @@ +

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 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 new file mode 100644 index 0000000..f12c1ba --- /dev/null +++ b/internal/convert/testdata/storage2md/adf-panel/output.md @@ -0,0 +1,30 @@ +A purple panel is IMPORTANT, read from its node -- its fallback holds the same content and must not render too: + +> [!IMPORTANT] +> **Notes / open questions** +> +> - An *emphasised* item with `code`. +> - A second item. + +An extension of some other type has no markdown spelling, so it passes through -- node only: + + + +Details + + +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/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.