Renderers
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— mapsAST::Nodeclasses toTagimplementations.RenderingInterface— what aTagactually sees when rendering.
The rendering loop
Section titled “The rendering loop”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 matchesThe renderer holds no state beyond the running output and the context. All decisions about markup (**, _, backticks, etc.) live in the individual Tag classes.
A Tag is any class (or block) that responds to render(element, interface):
class BoldTag < Markbridge::Renderers::Discourse::Tag def render(element, interface) context = interface.with_parent(element) content = interface.render_children(element, context:) return "<strong>#{content}</strong>" if interface.html_mode?
interface.wrap_inline(content, "**") endendFor simple cases, the block constructor is often enough:
Markbridge::Renderers::Discourse::Tag.new do |element, interface| context = interface.with_parent(element) content = interface.render_children(element, context:) if interface.html_mode? "<strong>#{content}</strong>" else interface.wrap_inline(content, "**") endendThe rendering interface
Section titled “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
Section titled “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
Section titled “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:
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.
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
Section titled “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
Section titled “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.