The user manual is rendered with a swapped renderer — its page-numbered contents is
BookTocRendererreplacing the default[TOC]renderer.
A NodeRenderer decides how one kind of semantic node becomes a GraphCompose
document fragment. Override one to restyle a single element; bundle a set into a
RendererPack to ship a whole behaviour layer; register one against a ::: type to
render your own block.
@FunctionalInterface
public interface NodeRenderer<N extends MarkdownNode> {
void render(N node, SectionBuilder host, RenderContext ctx);
}node— the semantic node (a sealedMarkdownNodesubtype, never a Flexmark type).host— the GraphComposeSectionBuilderto emit into (addParagraph,addRich,addList,addTable,addLine,addImage,addSection, …).ctx— theRenderContext: everything you need to style and to render children.
A renderer emits builders; it never draws to a page or computes geometry. The engine handles measurement, line breaking and pagination.
| Method | Returns | Use |
|---|---|---|
styles() |
MarkdownStyles |
per-element component styles (derived from tokens) |
tokens() |
MarkdownTokens |
raw design tokens |
toRich(nodes, base) |
RichText |
convert inline runs → GraphCompose RichText |
paragraphInline() / headingInline(level) |
InlineStyle |
base inline style for body / a heading level |
renderBlock(node, host) |
void |
dispatch one child block through the registry |
renderBlocks(nodes, host) |
void |
dispatch a list of child blocks |
highlighter() |
SyntaxHighlighter |
tokenize code for highlighting |
images() |
ImageResolver |
resolve image sources |
withTextColor(color) |
RenderContext |
a context whose inline text defaults to color |
Always read styling from ctx, never hard-code values — that is what lets a token
change cascade across the whole theme.
Override CodeBlockNode to add a language label bar above the panel, reusing the
theme's code styling:
NodeRenderer<CodeBlockNode> labelled = (node, host, ctx) -> {
if (!node.language().isBlank()) {
host.addParagraph(p -> p.text(node.language().toUpperCase())
.textStyle(/* a small muted style from ctx.styles()/tokens() */));
}
// delegate the body to the built-in behaviour, or emit your own using
// ctx.highlighter() + ctx.styles().syntaxColor(type)
};
MarkdownTheme theme = MarkdownTheme.builder(DefaultMarkdownTheme.light())
.renderer(CodeBlockNode.class, labelled)
.build();Container nodes recurse through the context. A blockquote-like renderer:
NodeRenderer<QuoteNode> quote = (node, host, ctx) -> host.addSection(panel -> {
panel.accentLeft(/* bar color from ctx.tokens() */);
panel.padding(/* from ctx.styles() */);
ctx.renderBlocks(node.content(), panel); // ← child blocks dispatch back through the registry
});A RendererPack bundles renderers so a project can ship and compose its own set:
public interface RendererPack {
void registerInto(RendererRegistry registry);
}StandardPack registers every built-in renderer. Apply packs in order (later packs
override earlier bindings for the same node type), then override individuals:
MarkdownTheme theme = MarkdownTheme.builder(DefaultMarkdownTheme.light())
.pack(new MyAlertsPack()) // bundle from another source
.renderer(CodeBlockNode.class, labelled) // override one node type
.build();public final class MyAlertsPack implements RendererPack {
@Override public void registerInto(RendererRegistry registry) {
registry.register(QuoteNode.class, new FancyQuoteRenderer());
registry.registerCustomBlock("note", new NoteRenderer());
}
}A fenced custom block —
:::chart bar
revenue, 120
margin, 22
:::— is parsed into a CustomBlockNode(String type, String variant, List<MarkdownNode> content)
(here type = "chart", variant = "bar", and the body lines are the nested
content). Register a renderer for a specific type:
MarkdownTheme theme = MarkdownTheme.builder(DefaultMarkdownTheme.light())
.customBlock("chart", new ChartRenderer()) // type-specific
.build();.customBlock(type, renderer) (and RendererRegistry.registerCustomBlock(type, r))
dispatch by the block's type. Any unregistered ::: type falls back to the built-in
callout style, so :::callout warning works out of the box. Custom-block
extraction is a text-level pre-pass, so it runs only for render(String) (not for the
bring-your-own-AST entry points).
The semantic model is sealed, so a
:::block (CustomBlockNode) is the only way to introduce a block type of your own. You can override or replace the renderer for any existing node type, but you cannot add a newMarkdownNodesubtype — theNodeRenderer<N>/MarkdownTheme.Builder.renderer(Class<N>, …)generics bind to the existing node classes, not to types you define.
Code highlighting is a pluggable SPI:
@FunctionalInterface
public interface SyntaxHighlighter {
List<CodeToken> highlight(String code, String language);
}The default RegexSyntaxHighlighter covers ~15 common languages with no extra
dependency. To plug a grammar-based engine (TextMate, a real lexer, …), implement the
interface and set it on the theme:
MarkdownTheme theme = MarkdownTheme.builder(DefaultMarkdownTheme.light())
.highlighter(new MyTextMateHighlighter())
.build();Each returned CodeToken carries a CodeTokenType; the renderer colors it via
ctx.styles().syntaxColor(type), which resolves to the theme's SyntaxColors. The
concatenation of all token texts must equal the input verbatim (the renderer relies on
this to preserve whitespace and indentation).
See also: architecture.md · theming.md
Every built-in is replaceable per node type. The library ships one such alternative
itself: BookTocRenderer turns the [TOC] marker into a print-style contents page with
dot leaders and live page numbers, in place of the default clickable link list:
MarkdownTheme book = MarkdownTheme.builder(DefaultMarkdownTheme.light())
.renderer(TocNode.class, new BookTocRenderer("Contents"))
.build();The same seam accepts your own renderer for any node type — tables, code blocks, headings — while every other component is reused.
A heading or TOC renderer needs the anchors the built-ins plan up front (so its links stay
navigable). Those are on RenderContext: ctx.headingSlug(headingNode) returns the planned,
document-unique slug for a heading — use it rather than ctx.headingAnchor(title), which
allocates a fresh slug and would desync your anchor from what [TOC] and [text](#slug)
jump to — and ctx.tocEntries() returns the document's headings (level, text, slug) as public
TocEntry records for a custom NodeRenderer<TocNode> to lay out. BookTocRenderer is built on
exactly these.