Skip to content

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 — maps AST::Node classes to Tag implementations.
  • RenderingInterface — what a Tag actually sees when rendering.
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.

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, "**")
end
end

For 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, "**")
end
end

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.

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.

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 (BoldTagAST::Bold, etc.) under Markbridge::Renderers::Discourse::Tags::*. Consumer-defined tag classes still need explicit registration.

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.

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.