Page concepts renderers: Source version: Markbridge 0.4.2. These docs are built from the repository and may include unreleased changes. Source page: https://markbridge.dev/concepts/renderers/
# Renderers
> How the Discourse Markdown renderer walks the AST.
The renderer takes an `AST::Document` and produces a Markdown string. It lives under `Markbridge::Renderers::Discourse` and has three collaborators:
* **`Renderer`** — walks the tree.
* **`TagLibrary`** — maps `AST::Node` classes to `Tag` implementations.
* **`RenderingInterface`** — what a `Tag` actually sees when rendering.
## The rendering loop
```plaintext
renderer.render(document)
for each child node:
tag = tag_library[child.class] || tag_library.resolve(child.class)
render with the tag, or render the children if no tag matches
```
The renderer holds no state beyond the running output and the context. All decisions about markup (`**`, `_`, backticks, etc.) live in the individual `Tag` classes.
## Tags
A `Tag` is any class (or block) that responds to `render(element, interface)`:
```ruby
class BoldTag < Markbridge::Renderers::Discourse::Tag
def render(element, interface)
context = interface.with_parent(element)
content = interface.render_children(element, context:)
return "#{content}" if interface.html_mode?
interface.wrap_inline(content, "**")
end
end
```
For simple cases, the block constructor is often enough:
```ruby
Markbridge::Renderers::Discourse::Tag.new do |element, interface|
context = interface.with_parent(element)
content = interface.render_children(element, context:)
if interface.html_mode?
"#{content}"
else
interface.wrap_inline(content, "**")
end
end
```
## The rendering interface
`Tag` implementations never see the renderer directly. They receive a `RenderingInterface` that exposes only what a tag should need:
| Method | Use |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `render_children(element)` | Recurse into children, concatenate output |
| `with_parent(element)` | Return a new context that treats `element` as parent |
| `find_parent(klass)` | Walk ancestors for a specific class |
| `has_parent?(klass)` | Boolean ancestor check |
| `count_parents(klass)` | How deep a specific ancestor is (nested lists, quotes) |
| `wrap_inline(content, markers)` | Wrap inline content with collapsing markers |
| `block_context?(element)` | Block vs. inline position |
| `html_mode?` | True inside a CommonMark HTML block — Tag must emit raw HTML or wrap output as a Markdown island (`\n\n…\n\n`) |
The interface decouples tags from the renderer: you could write a second renderer (HTML, plain text, JSON) and reuse every tag by providing a compatible interface.
## RenderContext
Behind the interface is a `RenderContext` — an immutable, linked parent chain. Creating a child context (via `with_parent`) links a new instance to the current one; the old one is untouched. `has_parent?` / `find_parent` walk that chain, so each nested level is a single fixed-size allocation rather than a copied parent array, and nesting depth stays shallow in practice.
A child context does not change the context used by its siblings.
## TagLibrary
The Discourse renderer ships with a default library mapping each built-in AST class to its Tag. `TagLibrary.default` returns a fresh copy each call (mutating it doesn’t affect other callers).
For most customization, prefer the `Markbridge.discourse_renderer(tags:, unregister:)` factory over hand-mutating a library — it gives you a complete, reusable Renderer:
```ruby
RENDERER = Markbridge.discourse_renderer(
tags: { Markbridge::AST::Url => MyUrlTag.new }, # override
unregister: [Markbridge::AST::Color], # render children only
)
```
The renderer checks the node’s class, then its nearest registered ancestor class. If neither has a tag, it renders only the children. To render only the children of a subclass with an inherited tag, register `Tag::PASSTHROUGH`. See [AST subclasses](/customization/extending/#ast-subclasses).
For lower-level use, `TagLibrary.new.auto_register!` discovers convention-paired classes (`BoldTag` → `AST::Bold`, etc.) under `Markbridge::Renderers::Discourse::Tags::*`. Consumer-defined tag classes still need explicit registration.
## Output cleanup
After the tree walk, the renderer’s `Postprocessor` runs a small cleanup pass on the joined output:
* Collapse runs of 3+ newlines to 2.
* Strip whitespace-only lines.
* Trim leading/trailing whitespace.
The default postprocessor is `Markbridge::Renderers::Discourse::Postprocessor::DEFAULT`. Pass a custom one (or a subclass) via `Markbridge.discourse_renderer(postprocessor:)` to change or extend the cleanup. Calling `Renderer#render` directly returns the *un*-postprocessed string.
## Writing a new renderer
Because the AST is renderer-agnostic, writing (say) a plain-text renderer is a matter of implementing a new `Renderer` that walks the AST and emits whatever you want. Re-using the `Tag` / `TagLibrary` pattern is recommended — it gives you the same extension points for free.