fix(convert): render an ac:adf-extension once, and publish alerts in GitHub's colours - #126
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 tostorage_to_md.go'srenderBlock, 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 authoritativeac:adf-nodeand once as a pre-renderedac:adf-fallback. Both children are unknown, both are transparent, both rendered.inlineStringhad 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
renderCalloutis 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 asatlas_doc_format, PUTting the ADF back (which is what the editor does on any save), and reading the storage gives:panelTypeinfoinfo<ac:structured-macro ac:name="info">— unchangedtipsuccess<ac:structured-macro ac:name="tip">— unchangednotewarning<ac:structured-macro ac:name="note">— unchangedwarningerror<ac:structured-macro ac:name="warning">— unchangednote<ac:adf-extension>Purple
noteis 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
notemacro is yellow; thenotepanel type is purple. Thewarningmacro is red; thewarningpanel type is yellow. Onlyinfomeans the same thing in both.calloutMacroInverseandadfPanelAlertare deliberately separate lookups, andTestCalloutVocabulariesDisagreeexists to stop someone merging them.It was an L5 violation with content loss
Not just an ugly export.
check --show-htmlon the QBR export reported zeroadf-extensionand two copies of each panel's text — soexport→updatewould have deleted both purple panels and left two copies of their prose in the body.docs/guarantees.mdclaimed 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-extensionrenders itsac:adf-nodeand never itsac: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#EAE6FFbackground and#998DD9border. 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-nodekeeps 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 — oneifbuys out the worst outcome in the change.Also,
ac:adf-contentjoinsac:rich-text-body/ac:layout-cellas 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.
<ac:structured-macro ac:name="info">info<ac:structured-macro ac:name="tip">success<ac:adf-extension>panel-type=notenote<ac:structured-macro ac:name="note">warning<ac:structured-macro ac:name="warning">errorThe 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, throughMdToConfluenceand back) is there to catch.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
read→updatewould have repainted the panel.ac:structured-macrois the canonical spelling Confluence's own serializer picks, not a deprecated one; "legacy" is the wrong word for it.titleparameter, whichkovetskiy/markdoes. 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 — publishtitle="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.mdcallout section.Verification
make checkis green.Live, against
mozilla-hub. The reported page (2496725010), before and after:A scratch page published from all five alerts, read back as ADF:
Exported again — all five recovered, CAUTION included:
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 newstorage2md/regressionadf-panelcases.adf-panelis also added toTestRoundTripPassthrough, which is the only guard on the property the passthrough exists for.Design and evidence:
_plans/030_adf-extension-passthrough.md.