S08: Publish-friendly Markdown
Purpose: Let mdd confluence create-page / update-page publish a plain documentation repository as-is — frontmatter title: names the page, relative .md links become Confluence page links, and ```mermaid fences render to SVG and publish as images — without a preprocessing step.
Status: Implemented (2026-09-07)
Introduction
Section titled “Introduction”A documentation repository — Markdown files with title: / description:
frontmatter, relative .md links between pages, and fenced ```mermaid
diagrams — is to be published one-way into Confluence with
mdd confluence create-page and mdd confluence update-page. Three things
stood in the way:
- The page title came from
--title, the first H1, or the file stem. Thetitle:key every such repository already carries was ignored, so pages landed under their heading or their filename rather than their declared title. - A relative link such as
[setup](../ops/setup.md)was emitted as a plain<a href="../ops/setup.md">, which is dead in Confluence: there is no file at that path, only a page whose title happens to be “Setup”. - A
```mermaidfence became a code macro that shows the diagram source. Confluence Cloud has no native Mermaid rendering.
Each of these forced a preprocessing pass over the tree before publishing,
which then had to be kept out of git or reconciled back. The three
behaviours below remove that step: the source .md files are the input,
and only the sibling <stem>-attachments/ directory gains files.
Requirements
Section titled “Requirements”Frontmatter title participates in title resolution
Section titled “Frontmatter title participates in title resolution”The page title for create-page and update-page is resolved in this
order:
- the
--titleflag (create-pageonly); - a top-level frontmatter
title:whose value is a non-empty string; - the first ATX H1 in the body;
- the file stem.
Only the top-level key counts. The confluence.title field inside the
confluence: block is the mirror of the remote title that export-page
wrote and goes stale on rename; it stays out of the rule, as before.
A title: that is not a string (title: 2026), or is blank, falls
through to the H1 rule rather than being stringified — YAML parses both
shapes silently and neither is an authored title.
The leading-H1 strip on update-page (confluence command,
“Title H1 on export and update”) keeps working against the resolved
title: when the title comes from frontmatter and the body’s first H1
equals it, the H1 is stripped as today; when it differs, the H1 is left
in the body.
Relative .md links resolve to Confluence page links
Section titled “Relative .md links resolve to Confluence page links”When rendering local Markdown for create-page / update-page, every
Link node whose href:
- has no URL scheme and no host, and does not start with
/; - is not a
confluence-*:synthetic URI (those are alreadyConfluenceLinknodes after parsing); - has a path component ending in
.md, with an optional#fragment;
is resolved against the directory of the source .md file.
- Target exists: the link becomes a
ConfluenceLinkofpagekind whose target is the target file’s page title, derived with the same rule as above (frontmattertitle:→ first H1 → stem). The link’s inline children are kept as the link body. A#fragmentis carried as theac:anchorattribute, so the storage writer emits<ac:link ac:anchor="…"><ri:page ri:content-title="…" /></ac:link>. - Target does not exist: the link is left untouched and one warning is logged naming the source file, the line when it can be located, and the href.
Links inside code spans and code blocks are not Link nodes, so they are
never touched. Absolute URLs, mailto: links, root-relative paths and
confluence-page: URIs pass through unchanged.
--no-resolve-links on create-page and update-page turns the pass
off. Export and sync pull are unaffected: this runs only on the
local → Confluence rendering leg.
```mermaid fences render to SVG and publish as images
Section titled “```mermaid fences render to SVG and publish as images”Before attachment sync in create-page / update-page, a preprocessing
step scans the body for fenced code blocks whose info string’s first word
is mermaid (so mermaid {title="x"} also matches). Fences that open
inside another fenced block are not candidates.
For each fence:
-
The content is hashed (
sha256, first 12 hex characters) and rendered to<stem>-attachments/mermaid-<sha>.svgnext to the source file. The content-addressed name is the cache: an existing file is reused without invoking the renderer, and two identical fences share one file. -
Rendering is configured in the same config family as
svg:(configs/mdd.yaml, then~/.config/mdd/config.yaml):mermaid:renderer: mermaidx # default: in-process; or an executable such as mmdcargs: ["-i", "{input}", "-o", "{output}", "-b", "transparent"]renderer: mermaidx(the default) renders in-process with the optionalmermaidxpackage, installed through themdd[mermaid]extra. It runs the real mermaid.js in an embedded JavaScript engine, so there is no Node and no browser to provision and the output is the same on every machine. Any other value names an external command looked up onPATH(mmdcfrom@mermaid-js/mermaid-cli): the fence content is written to a temporary.mmdinput file,{input}/{output}inargsare substituted, and the command runs withsubprocess.run. Either way the SVG is produced in a temporary directory and moved into the attachments directory only after success, so a failed render never poisons the cache. -
On success the fence is replaced in the body by
. The existing SVG publish path (SVG rasterization;attachments/svg_publish.py) then rasterizes the SVG to PNG and uploads both, exactly as for a hand-placed SVG. -
If the renderer is unavailable —
mermaidxnot installed, or the external executable not onPATH— one warning per page is logged and every fence stays a code block. The in-process message ismermaid renderer 'mermaidx' is not installed; N diagram(s) left as code blocks; install withuv add mdd[mermaid](orpip install mermaidx) or set mermaid.renderer to an external command; the external-command message names the executable and points at@mermaid-js/mermaid-cli. -
If
mermaidxraises on a diagram (a syntax error, typically), that fence stays a code block and a warning names the diagram and the error. -
If the renderer exits non-zero or produces no output, that fence stays a code block and a warning names the fence and the renderer’s stderr.
The rewritten body is what gets rendered and pushed. The source .md on
disk is never modified; only the attachments directory gains files. The
renderer is not run on export or sync pull.
Design Approach
Section titled “Design Approach”- One title rule, one module.
mdd.confluence.title.resolve_page_titlereplaces the two divergent copies (create.pyused a regex over the body,update.pyastartswith("# ")line scan) and is what the link resolver calls for its targets, so a page and every link pointing at it agree on the title by construction. - Links are an IR transform, not a text rewrite. The pass runs on the
parsed
Document, betweenparse_markdownandrender_confluence_storage(and beforereattachon update). Working onLinknodes means code spans, code blocks and reference-style links are handled by the parser rather than by a second regex grammar, and the existingConfluenceLink→<ac:link><ri:page/>writer is reused unchanged. The tree walk reuses the normaliser’stransform_text_blocks, which is promoted to the publicmdd.ir.normalizesurface for this. - Mermaid is a text rewrite, deliberately. The SVG publish path is
keyed on Markdown image syntax in the body (
scan_local_image_refs,rewrite_svg_refs_to_png), so producing an image reference in the body is the shortest route to “behaves exactly like a hand-placed SVG” — one upload path, one rasterizer, one manifest shape. A dedicated fence scanner is used rather than the IR because the rewrite must happen before attachment sync, which itself works on the body text. - Content addressing over side-cars. The SVG rasterizer keeps a
.meta.yamlside-car because the source SVG is a file that changes in place. A mermaid fence has no file; the hash of its content is the identity, so the output filename doubles as the cache key and stale renders simply stop being referenced. - In-process by default, subprocess as an option.
mermaidxis a ~5 MB Python package with a JavaScript engine and no system dependencies, which makes a CI publish step deterministic — the same mermaid.js version renders everywhere, nothing tonpm install, no headless Chromium. It is an optional extra rather than a hard dependency because that footprint is a choice a user should make. The external command path is kept for teams that already ship mermaid-cli, or want a renderer this project has not wrapped. - Missing renderer degrades, missing SVG renderer does not. An absent renderer leaves readable (if unrendered) code blocks on the page, so it warns and continues. That differs from the SVG rasterizer, which exits hard: an unrendered SVG image is a broken image, an unrendered mermaid fence is still the diagram source.
Implementation Notes
Section titled “Implementation Notes”src/mdd/confluence/title.py—first_h1,frontmatter_title,resolve_page_title. Consumed bycreate._resolve_inputs,update._build_local_specandpage_links.src/mdd/confluence/page_links.py—resolve_page_links(doc, md_path, body_md=…). Titles are cached per target path within one call. The warning’s line number is best effort: the decoded href is searched for in the body text; when it is not found the line is omitted.src/mdd/confluence/mermaid.py—render_mermaid_fences(body_md, md_path, config=…);MermaidConfig/MermaidWrapperand theMERMAIDX_RENDERERsentinel live next toSvgConfiginmdd.converters.models.import mermaidxis deferred into the renderer so the module loads without the extra; availability (importable, or onPATH) is checked once per page. Themermaidextra is declared inpyproject.toml(mermaidx>=0.9.5). The fence scanner follows CommonMark: backtick or tilde fences of three or more characters, up to three spaces of indent, closed by a fence of the same character at least as long; an unterminated fence is left alone.- Call order in
create._run_createandupdate._push_page:strip_export_header(andstrip_export_title_h1on update) →render_mermaid_fences→sync_attachments_for_update→parse_markdown→resolve_page_links→ (reattach) →render_confluence_storage. The body-safety guard on update runs before the mermaid rewrite so it judges the author’s body, not the rewritten one. sync-spacepush callsupdate_pageand therefore inherits both passes with their defaults.- The
--no-resolve-linksflag isdest="resolve_links", action="store_false"on both parsers and threads throughcreate_page(resolve_links=…)/update_page(resolve_links=…). - Tests inject a fake
mermaidxmodule intosys.modulesfor the in-process path (andNoneto simulate it being absent), and mockshutil.which/subprocess.runfor the external-command path, whose fake renderer writes a minimal<svg/>to the{output}path. No test needsmermaidxormmdc.
Related upstream specs
Section titled “Related upstream specs”- 000-specs — shared conventions
- confluence command —
create-page/update-page, the title-H1 strip on update, and the attachment sync these features extend - SVG sibling rasterization — the SVG → PNG path a rendered mermaid diagram rides on publish
- markdown IR conversion — the
Link/ConfluenceLinknodes and theconfluence-page:URI form the link resolver targets - typed frontmatter layer —
ConfluenceFrontmatterallows extra top-level keys, which is what letstitle:sit besideconfluence:;MermaidConfigfollows the sameFrontmatterModelpattern asSvgConfig
Open questions
Section titled “Open questions”- Should
sync-spacepush expose its own--no-resolve-links, or is theupdate_pagedefault enough? Today it always resolves. - A
mermaid.rendererthat is an HTTP service (Kroki) instead of a local package or binary; theargstemplate shape does not fit that. Worth a second renderer kind if the demand shows up. mermaid-rs(a Rust port with Python bindings) was evaluated as the in-process default and rejected: on a 55-line flowchart with subgraphs and HTML entities it emitted invalid XML — unescaped quotes inside afont-familyattribute and double-escaped entities — which the SVG rasterizer then refuses. Revisit when it emits valid SVG; it would drop the JavaScript engine from the extra.- Should a link to a
.mdfile that exists but is not (yet) published be resolved anyway? Today it is — the page title is derived from the file, and Confluence renders a link to a non-existent page as a create link.
Out of scope
Section titled “Out of scope”- The export / sync pull direction: page links stay
confluence-page:URIs and Confluence-side diagrams stay whatever macro they were. - Editing the source
.mdfiles. Neither the rewritten links nor the image references are written back; only<stem>-attachments/changes. - Diagram languages other than mermaid (PlantUML, D2, Graphviz). The fence scanner and renderer contract would fit them, but each needs its own tool and defaults.
- Resolving links to non-
.mdfiles (a relative.pdfor.pnglink). Those already ride the attachment path when written asconfluence-attachment:links.
Site built 2026-09-14.