Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
418 changes: 418 additions & 0 deletions _plans/030_adf-extension-passthrough.md

Large diffs are not rendered by default.

140 changes: 135 additions & 5 deletions docs/confluence/storage-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<ac:adf-extension>
<ac:adf-node type="panel">
<ac:adf-attribute key="panel-type">note</ac:adf-attribute>
<ac:adf-attribute key="local-id">54e36e4937ac</ac:adf-attribute>
<ac:adf-content>…the content…</ac:adf-content>
</ac:adf-node>
<ac:adf-fallback>
<div class="panel …"><div class="panelContent" …>…the same content…</div></div>
</ac:adf-fallback>
</ac:adf-extension>
```

`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 `<ac:adf-extension>` 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 `<ac:parameter ac:name="title">Heads up</ac:parameter>`
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 | `<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 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.
23 changes: 18 additions & 5 deletions docs/guarantees.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<ac:adf-extension>` — 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
Expand Down
58 changes: 39 additions & 19 deletions internal/convert/callouts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ac:adf-extension> (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]".
Expand All @@ -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) {
Expand All @@ -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
}
Expand All @@ -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() {
Expand All @@ -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
}
33 changes: 27 additions & 6 deletions internal/convert/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <blockquote>.
// 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 <blockquote>.
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, `<ac:structured-macro ac:name="%s" ac:schema-version="1"><ac:rich-text-body>`, macro)
} else {
_, _ = w.WriteString(`</ac:rich-text-body></ac:structured-macro>`)
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,
`<ac:adf-extension><ac:adf-node type="panel">`+
`<ac:adf-attribute key="panel-type">%s</ac:adf-attribute><ac:adf-content>`,
target.panelType)
} else {
_, _ = w.WriteString(`</ac:adf-content></ac:adf-node></ac:adf-extension>`)
}
default:
if entering {
_, _ = fmt.Fprintf(w,
`<ac:structured-macro ac:name="%s" ac:schema-version="1"><ac:rich-text-body>`, target.macro)
} else {
_, _ = w.WriteString(`</ac:rich-text-body></ac:structured-macro>`)
}
}
return ast.WalkContinue, nil
}
Expand Down
Loading