Architecture
This document describes adf4j internals for contributors and advanced integrators. For first use, see Getting started. For integration recipes, see the Guide. For option, mapping, diagnostic, and CLI tables, see the Reference.
Design goals
- Correct GFM output: Prefer valid GitHub-Flavored Markdown. Use HTML, placeholders, or diagnostics when GFM cannot express the ADF construct.
- Visible loss: Record diagnostics when content is changed, dropped, or cannot be parsed.
- Safe defaults: Treat ADF as untrusted input. Bound parser depth, sanitize URL schemes, and isolate callback failures.
- Small public API: Export a focused JPMS surface and keep implementation packages internal.
- Reusable lifecycle: Build one immutable converter and pass per-call options for page-specific state.
System context
adf4j is an embeddable transformation library. It accepts an ADF JSON string or parsed AdfDocument, then returns Markdown plus metadata and diagnostics. It does not perform network, filesystem, or database I/O.
flowchart LR
src["ADF JSON<br/>(Confluence / Jira)"] --> api["AdfToMarkdown"]
api --> engine["Conversion engine"]
engine --> md["GFM Markdown"]
engine --> meta["ContentMetadata"]
engine --> diag["Diagnostics"]
engine -->|resolver callbacks| host["Host application"]
host -->|URLs, page trees, excerpts| engine
cli["adf4j-cli"] --> api
The host application owns Confluence access, CDN URLs, page hierarchy, and macro-specific data. adf4j owns parsing, analysis, Markdown rendering, escaping, and diagnostics.
Module and package structure
| Artifact | Module | Role |
|---|---|---|
dev.nthings:adf4j |
adf4j-lib |
JPMS library module dev.nthings.adf4j. |
dev.nthings:adf4j-cli |
adf4j-cli |
Command-line wrapper over the library. |
adf4j-wasm |
adf4j-wasm |
Optional WebAssembly packaging under the wasm profile. |
Exported packages:
| Package | Contains |
|---|---|
dev.nthings.adf4j |
AdfToMarkdown. |
dev.nthings.adf4j.ast |
Public AST records. |
dev.nthings.adf4j.options |
MarkdownOptions, resolver hooks, renderer hooks, enums. |
dev.nthings.adf4j.result |
MarkdownResult, ParseResult, Diagnostic. |
dev.nthings.adf4j.metadata |
ContentMetadata and reference records. |
dev.nthings.adf4j.confluence |
ConfluenceRenderContext and ConfluenceMetadata. |
Everything under internal.* is implementation detail. JPMS enforces that boundary. Integrators should use AdfToMarkdown plus the public option, result, metadata, Confluence, and AST types.
The conversion pipeline
AdfPipeline owns one parser, analyzer, and renderer. It is assembled once and is stateless after construction.
flowchart LR
json["ADF JSON"] --> parse["Parse<br/>AdfParsingService"]
parse --> ast["AdfDocument + parse diagnostics"]
ast --> analyze["Analyze<br/>AdfDocumentAnalyzer"]
analyze --> outline["Heading outline"]
analyze --> metadata["ContentMetadata"]
outline --> render["Render<br/>AdfRenderer"]
ast --> render
render --> body["Markdown body"]
parse --> merge["Merge diagnostics"]
analyze --> merge
render --> merge
merge --> result["MarkdownResult"]
metadata --> result
body --> result
Phases:
| Phase | Component | Output |
|---|---|---|
| Parse | AdfParsingService |
AdfDocument plus parse diagnostics. |
| Analyze | AdfDocumentAnalyzer |
Heading outline, ContentMetadata, lossiness diagnostics. |
| Render | AdfRenderer |
Markdown body, unresolved references, macro diagnostics. |
Analysis precedes rendering because rendering can depend on global document facts. A table-of-contents macro needs all headings, and heading anchors must be unique before the first rendered line.
Public methods expose the pipeline at different depths:
parse()runs parse only.analyze()runs parse and analyze.convert()andtoMarkdown()run all three phases.
convert(ParseResult, options) lets callers parse once and render many times while preserving parse diagnostics in every render result.
Phase 1: Parsing
The parser uses Jackson in tree mode and a hand-written recursive descent parser. It validates the document envelope before building the AST.
flowchart TB
raw["Raw JSON"] --> read["Jackson readTree<br/>depth capped at 100"]
read --> validate["RootValidator"]
validate --> ast["AdfAstParser"]
ast --> known["Known type -> typed record"]
ast --> unknown["Unknown type -> Unknown* record with raw JSON"]
known --> result["ParseResult"]
unknown --> result
Robustness checks:
- Blank or invalid JSON yields an empty parse result with diagnostics.
- Nesting is capped at depth 100.
- Root validation reports missing document, invalid root type, invalid root node, invalid version, invalid content, and unsupported version.
- Unsupported version is a warning; parsing continues best effort.
AST type model
The AST is a sealed hierarchy of immutable records:
AdfNodepermitsAdfDocument,AdfBlock, andAdfInline.AdfBlockcovers paragraphs, headings, lists, tables, media, cards, layout, extensions, andUnknownBlock.AdfInlinecovers text-adjacent inline constructs andUnknownInline.AdfMarkis a separate hierarchy for marks that decorate text and media, includingUnknownMark.
Records defensively copy collections and normalize missing values to safe defaults. Attributes and MacroParams preserve product-specific data without tying the AST to a single Atlassian schema.
Unknown node handling
Unknown block and inline nodes are preserved as raw JSON in Unknown* records. Rendering behavior is deferred to UnknownNodePolicy. Unknown marks have no standalone rendering form, so they are always dropped with a warning.
Phase 2: Analysis
Analysis extracts information known before rendering:
- Heading outline and unique anchors.
- References to pages, external links, mentions, media, attachments, page-tree macros, and excerpt includes.
- Excerpt definitions.
- Unknown-node and unknown-mark diagnostics.
AdfDocumentAnalyzer performs one pre-order walk with independent visitors:
| Visitor | Purpose |
|---|---|
AdfHeadingCollector |
Builds heading text, anchors, and TOC level ranges. |
AdfContentMetadataExtractor |
Builds ContentMetadata in document order. |
AdfLossinessCollector |
Converts unknown-node and unknown-mark counts into diagnostics. |
ContentMetadata is a public deliverable. It lets callers build link graphs, search indexes, navigation sidebars, and fetch plans without rendering.
Confluence layer
Confluence-specific knowledge is isolated:
ConfluenceRenderContextstores the caller-supplied attachment inventory and resolves attachment titles.ConfluenceMetadataexposes typed data from Confluence link metadata.internal.ConfluenceSupportcontains Confluence heuristics such as page ID extraction, macro namespace checks, and anchor parsing.
The rest of the engine asks these helpers whether a node is a page reference, attachment macro, or known Confluence macro.
Phase 3: Rendering
The renderer walks the AST with the precomputed heading outline and active MarkdownOptions.
Key state:
RenderContextholds options, resolver hooks, heading outline,MacroDiagnostics, andUnresolvedTracker.RendererStateis an immutable cursor for list depth, table-cell context, and heading context.
Focused renderers handle tables, lists, cards, macros, media, and marks. They recurse through BlockRecursion when nested content needs normal block or inline rendering.
GFM fallback strategy
The renderer prefers native GFM and falls back only when needed:
- Complex tables use HTML.
- Visual marks drop by default or become inline HTML with
htmlVisualMarks. - Missing media and attachment URLs become inert placeholders.
- Unsupported macros become placeholders with diagnostics.
- Hard breaks adapt to heading, table-cell, or body context.
The complete behavior list is in Lossy and by-design behaviours.
Text and URL safety
MarkdownText centralizes Markdown escaping, block-marker neutralization, and code fence sizing. MarkdownInputCleaner normalizes text before Markdown fragments are re-parsed for HTML table cells.
URL destinations are scheme-sanitized before output. ExtensionRenderer and ExcerptResolver are exceptions because their Markdown is inserted verbatim.
Extensibility model
MarkdownOptions exposes functional hooks for caller-owned data:
| Hook | Caller supplies |
|---|---|
MediaResolver |
Media URLs. |
AttachmentResolver |
Attachment URLs. |
PageLinkResolver |
Page URLs. |
PageTreeResolver |
Page hierarchy entries. |
ExcerptResolver |
Rendered excerpt Markdown. |
ExtensionRenderer |
Custom extension Markdown. |
All hooks are guarded. Returning null, returning a blank URL, or throwing declines the lookup and falls back to default behavior. Non-null answers are authoritative.
Error handling and diagnostics
adf4j reports most problems as data:
| Type | Carries |
|---|---|
ParseResult |
Parsed document, parse diagnostics, and validAdfRoot. |
MarkdownResult |
Body, metadata, diagnostics, and unresolved references. |
Diagnostic |
Code, message, optional cause, and severity. |
Severity meanings:
INFO: non-lossy note.WARNING: content changed or dropped.ERROR: conversion failed or produced an empty body.
MarkdownResult.wasLossy() is true for WARNING or ERROR. Expected option-driven output is not counted as lossy. The only hard-failure rendering mode is UnknownNodePolicy.FAIL, which throws IllegalStateException for unknown nodes.
Concurrency and lifecycle
AdfToMarkdown is immutable and thread-safe. AdfPipeline, parser, analyzer, and renderer are stateless after construction. Per-render mutable state is created fresh and never shared.
Recommended lifecycle:
- Create one converter at startup with
AdfToMarkdown.create()orAdfToMarkdown.with(options). - Reuse it across documents and threads.
- Pass per-call
MarkdownOptionswhen resolver state changes by page.
Dependencies and packaging
The library targets JDK 25 and uses:
| Dependency | Purpose |
|---|---|
| Jackson | JSON tree reading. |
| CommonMark plus GFM extensions | Markdown parsing and HTML conversion for table fallback. |
| jsoup | HTML table construction and serialization. |
| JSpecify | Nullness annotations. |
| SLF4J | Logging, especially callback failures. |
adf4j-cli is a thin wrapper with a hand-written argument parser. It is reflection-free and uses Jackson in tree mode, so native-image metadata is unnecessary. Release artifacts are native executables plus the optional WASM build. The CLI jar is not a fat jar.
Key design decisions
| Decision | Rationale | Trade-off |
|---|---|---|
| Parse, analyze, render pipeline | Rendering needs global heading and metadata facts. | One extra tree traversal. |
| Sealed AST records | Compile-time exhaustiveness and immutable data. | Adding a node type touches exhaustive switches. |
| Unknown nodes preserved | Forward compatibility with newer ADF. | Larger public AST surface. |
| One analysis walk with visitors | Metadata, outline, and lossiness share traversal cost. | Visitors must stay independent. |
| Diagnostics as data | Callers can inspect quality without catching exceptions. | Callers must check wasLossy() or severity. |
| Guarded resolver callbacks | I/O and environment state stay in the host application. | Callers must implement resolvers for real URLs and page trees. |
| JPMS encapsulation | Stable public API over changeable internals. | Internals are unavailable to integrations. |
| Immutable converter with per-call options | Thread-safe reuse. | Option state must be passed explicitly. |