Skip to content

fix(convert): render an ac:adf-extension once, and publish alerts in GitHub's colours - #126

Merged
willkg merged 6 commits into
mainfrom
adf-extension-passthrough
Sep 1, 2026
Merged

fix(convert): render an ac:adf-extension once, and publish alerts in GitHub's colours#126
willkg merged 6 commits into
mainfrom
adf-extension-passthrough

Conversation

@willkg

@willkg willkg commented Sep 1, 2026

Copy link
Copy Markdown
Member

Closes #125.

Exporting a page containing the editor's purple Note panel emitted its content twice. The cause is one line: <ac:adf-extension> was unknown to storage_to_md.go's renderBlock, so it hit the transparent-wrapper default — "Unknown element: render its children as blocks" — and an extension's whole purpose is to carry its content twice, once as the authoritative ac:adf-node and once as a pre-rendered ac:adf-fallback. Both children are unknown, both are transparent, both rendered. inlineString had the same defect.

Fixing that turned out to unlock the second half of this PR, so it is worth reading in that order.

Why only the Note panel, and why it isn't a callout bug

renderCallout is never reached — this has nothing to do with callout handling. A purple panel simply isn't a macro. Publishing each callout macro, reading it back as atlas_doc_format, PUTting the ADF back (which is what the editor does on any save), and reading the storage gives:

macro published ADF panelType colour storage after an editor save
info info blue <ac:structured-macro ac:name="info"> — unchanged
tip success green <ac:structured-macro ac:name="tip"> — unchanged
note warning yellow <ac:structured-macro ac:name="note"> — unchanged
warning error red <ac:structured-macro ac:name="warning"> — unchanged
(unreachable) note purple <ac:adf-extension>

Purple note is the only ADF panel type with no macro, so it is the only callout that serializes as an extension. markfluence's own output round-trips as macros, which is why this only ever showed up on a panel a human inserted.

Watch the vocabularies. Macro names and ADF panel types share three strings and agree on exactly one. The note macro is yellow; the note panel type is purple. The warning macro is red; the warning panel type is yellow. Only info means the same thing in both. calloutMacroInverse and adfPanelAlert are deliberately separate lookups, and TestCalloutVocabulariesDisagree exists to stop someone merging them.

It was an L5 violation with content loss

Not just an ugly export. check --show-html on the QBR export reported zero adf-extension and two copies of each panel's text — so exportupdate would have deleted both purple panels and left two copies of their prose in the body. docs/guarantees.md claimed single-page round-trip already worked and pinned L5's Partial status entirely on #59; that sentence was false and is corrected. The status does not move, and the paragraph now names why it went unnoticed: L5 has no property test, which the same file says every Law should have.

The fix, and one deliberate exception

An ac:adf-extension renders its ac:adf-node and never its ac:adf-fallback — type-agnostic, no panel special case, because a fallback is by definition a second rendering of what the node already carries.

Dropping the fallback is safe because it is a cache, not a source of truth. A bare extension with no fallback is accepted on a storage PUT and stored byte-identical; Confluence never writes one back; and export_view — which PDF and Word export are built from — regenerates the styled div on demand, correct #EAE6FF background and #998DD9 border. So markfluence neither preserves nor synthesizes one. Preserving the original was rejected: 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.

The exception: an extension with no ac:adf-node keeps its fallback. Never observed and possibly nonexistent, but the failure mode without the condition is silent content deletion on export and then from the page — one if buys out the worst outcome in the change.

Also, ac:adf-content joins ac:rich-text-body/ac:layout-cell as a content container, so a panel's prose and bullets stay editable markdown instead of one raw element per line.

The colour remap

Once purple is reachable, the callout map can match GitHub. GitHub renders NOTE blue, TIP green, IMPORTANT purple, WARNING orange, CAUTION red. markfluence published IMPORTANT yellow and WARNING red, and folded CAUTION into the same macro as WARNING — so a published page didn't look like its preview, and CAUTION couldn't be read back at all.

alert GitHub publishes as ADF panel
NOTE blue <ac:structured-macro ac:name="info"> info
TIP green <ac:structured-macro ac:name="tip"> success
IMPORTANT purple <ac:adf-extension> panel-type=note note
WARNING orange <ac:structured-macro ac:name="note"> warning
CAUTION red <ac:structured-macro ac:name="warning"> error

The map is now bijective, so nothing is unrecoverable. Both directions move in one commit on purpose: half the change regenerates every golden cleanly while silently repainting alerts, which is what TestCalloutsRoundTrip (all five, through MdToConfluence and back) is there to catch.

⚠️ This changes how already-published pages look on their next update: IMPORTANT yellow → purple, WARNING red → orange.

Only purple is read back from an extension. Confluence serializes the other four as macros, so a branch for them would be unreachable code.

Things I checked and decided against

  • Converting purple to a GFM alert on read only, leaving publishing alone. Half a bijection; readupdate would have repainted the panel.
  • Emitting the ADF-extension spelling for all five. It is accepted and stored verbatim, and produces identical ADF — but an editor save rewrites those four back to macros, so the storage would flip form on every cycle with nobody editing content. ac:structured-macro is the canonical spelling Confluence's own serializer picks, not a deprecated one; "legacy" is the wrong word for it.
  • Publishing an alert's name as the macro's title parameter, which kovetskiy/mark does. ADF has no panel title, so Confluence flattens the parameter into a bold first body paragraph on the first save. For a publish-only tool that is fine; with an export direction it 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. Publishing the first line as the title is worse still: same demotion, plus it steals a line GitHub renders as body text.

All of the probing is written up in the rewritten docs/confluence/storage-format.md callout section.

Verification

make check is green.

Live, against mozilla-hub. The reported page (2496725010), before and after:

$ # before
1x adf-fallback   2x "Notes / open questions on these calculations"   2x "SREIN BigQuery tables build"
$ # after
0x adf-fallback   1x "Notes / open questions on these calculations"   1x "SREIN BigQuery tables build"

A scratch page published from all five alerts, read back as ADF:

info     <- Blue.
success  <- Green.
note     <- Purple.
warning  <- Orange.
error    <- Red.

Exported again — all five recovered, CAUTION included:

> [!NOTE]
> Blue.

> [!TIP]
> Green.

> [!IMPORTANT]
> Purple.

> [!WARNING]
> Orange.

> [!CAUTION]
> Red.

Then an editor save simulated by PUTting the page's own ADF back, and re-exported: identical. The purple panel does not churn. Scratch pages trashed.

New tests: TestStorageToMarkdownRendersADFExtensionOnce (counts occurrences rather than matching a golden, because a golden regenerated against the bug looks perfectly plausible), the inline equivalent, the fallback-only case, non-mutation across two extensions in one document, TestCalloutsRoundTrip, TestCalloutTargetsAreDistinct, TestCalloutVocabulariesDisagree, and new storage2md/regression adf-panel cases. adf-panel is also added to TestRoundTripPassthrough, which is the only guard on the property the passthrough exists for.

Design and evidence: _plans/030_adf-extension-passthrough.md.

…callout map

An editor-authored purple Note panel exports twice: <ac:adf-extension> 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.
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.
An <ac:adf-extension> holds its content twice: once as the authoritative
ac:adf-node, once as an ac:adf-fallback holding a pre-rendered <div>. 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.
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 <ac:adf-extension> 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.
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.
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.
@willkg
willkg merged commit d86bec4 into main Sep 1, 2026
1 check passed
@willkg
willkg deleted the adf-extension-passthrough branch September 1, 2026 21:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

export/read renders an editor-authored Note panel twice, and the callout colours are wrong

1 participant