Customization: Source version: Markbridge 0.4.2. These docs are built from the repository and may include unreleased changes.
# Customization
> Shape Markbridge's output without forking — custom renderers, tags, and handlers.
Markbridge is built to be customized without monkey-patching or forking. There are two layers:
* **[Customizing the renderer](/customization/customizing-renderer/)** — build a reusable `Renderer`: override or drop tags, swap the escaper, post-process the output.
* **[Extending Markbridge](/customization/extending/)** — teach a parser tags it doesn’t know, and render them with your own Tags.
Most jobs need only the first; reach for the second when your source has tags the defaults don’t cover.
# Customizing the renderer
> Build a reusable Discourse renderer with Markbridge.discourse_renderer — override tags, drop tags, swap escapers, post-process output.
The Discourse renderer is configurable through a single factory: `Markbridge.discourse_renderer`. Build a `Renderer` once with the customizations you need, then pass it to as many `*_to_markdown` calls as you like via the `renderer:` kwarg.
```ruby
RENDERER = Markbridge.discourse_renderer(
tags: { Markbridge::AST::Url => MyPlaceholderUrlTag.new },
unregister: [Markbridge::AST::Color, Markbridge::AST::Size],
escape_hard_line_breaks: true,
)
posts.each do |post|
result = Markbridge.bbcode_to_markdown(post.body, renderer: RENDERER)
write_markdown(post, result.markdown)
end
```
The renderer is safe to reuse across thousands of posts. It holds no per-post state, so nothing leaks between posts — you collect any side data per call by walking `result.ast`.
## The factory
```ruby
Markbridge.discourse_renderer(
tags: nil, # Hash{Class => Tag, nil}
tag_library: nil, # starting library
unregister: nil, # Array to drop
escaper: nil, # custom MarkdownEscaper
escape_hard_line_breaks: false, # sugar for the default escaper
allow: nil, # Symbol/Array — markers to leave unescaped
escape: true, # false swaps in IdentityEscaper (no escaping)
postprocessor: nil, # custom Postprocessor instance
)
```
Every kwarg is optional. The defaults give you the standard Discourse renderer.
### `tags:` — override or add Tags
`tags:` is a hash of AST class → `Tag` instance. Mappings merge on top of the default `TagLibrary`, so unmapped classes keep their default rendering.
```ruby
Markbridge.discourse_renderer(
tags: {
Markbridge::AST::Bold => MyBoldTag.new,
Markbridge::AST::Url => MyPlaceholderUrlTag.new,
}
)
```
Map a class to `nil` to unregister it (same as listing it under `unregister:`).
### `unregister:` — drop AST classes
Removing a built-in tag keeps its children, including their formatting. For a subclass, removing its own tag allows the nearest registered ancestor tag to apply. Use `Tag::PASSTHROUGH` to render only the children when an ancestor has a tag.
```ruby
Markbridge.discourse_renderer(
unregister: [Markbridge::AST::Color, Markbridge::AST::Size, Markbridge::AST::Underline]
)
```
### `escape_hard_line_breaks:` — strip trailing-space line breaks
In Markdown, a line ending in two or more trailing spaces becomes a hard break (` `). When source content happens to carry that whitespace, the result can surprise readers.
```ruby
Markbridge.discourse_renderer(escape_hard_line_breaks: true)
# Strips " \n" → "\n" before escaping; no .
```
The default (`false`) preserves trailing spaces and lets the downstream Markdown renderer decide.
### `escaper:` — full escaper replacement
For control beyond the hard-line-breaks toggle, pass your own `MarkdownEscaper` (or subclass). Mutually exclusive with `escape_hard_line_breaks:` — if you supply an escaper, the boolean is ignored.
```ruby
class ListPermissiveEscaper < Markbridge::Renderers::Discourse::MarkdownEscaper
# Allow leading "- " through unescaped so importer-supplied lists survive.
def escape(text, context: nil)
return text if text.match?(/\A- /)
super
end
end
Markbridge.discourse_renderer(escaper: ListPermissiveEscaper.new)
```
### `allow:` — let specific Markdown markers through
By default the escaper escapes Markdown found in source text, so a literal `- `or `1.` from a forum post doesn’t accidentally turn into a list. If the source *does* use real Markdown lists you want to keep, allow those markers instead of subclassing the escaper:
```ruby
Markbridge.discourse_renderer(allow: :lists)
```
The keys are `:bullet_list`, `:ordered_list`, `:atx_heading`, and `:block_quote`, plus the alias `:lists` (bullet + ordered). An unknown key raises `ArgumentError`. Thematic breaks (`---`, `***`) and setext underlines (`===`) stay escaped — `allow:` opens up specific markers, not the whole escaper. It builds on the default escaper, so don’t combine it with a custom `escaper:`.
### `escape:` — turn escaping off entirely
When the source is already trusted Markdown, skip escaping altogether:
```ruby
Markbridge.discourse_renderer(escape: false)
```
This swaps in `Markbridge::Renderers::Discourse::IdentityEscaper`, which returns text unchanged. `escape: false` can’t be combined with `escape_hard_line_breaks:` or `allow:` (those configure the normal escaper, which `escape: false` replaces); an explicit `escaper:` always wins. To skip escaping for a single node rather than the whole document, use `AST::MarkdownText`.
### `postprocessor:` — clean up the final string
After all Tags have rendered, the output runs through a `Postprocessor` that collapses multi-blank-line runs, strips whitespace-only lines, and trims document edges. Subclass `Markbridge::Renderers::Discourse::Postprocessor` and override `#call` to change that.
```ruby
class StripDoubleSpaces < Markbridge::Renderers::Discourse::Postprocessor
def call(text)
super.gsub(/(?<=\S) +(?=\S)/, " ")
end
end
Markbridge.discourse_renderer(postprocessor: StripDoubleSpaces.new)
```
Pass the bare base class (`Postprocessor.new`) to keep the default cleanup; pass `->(text) { text }` if you want output without cleanup.
## Build once, reuse everywhere
The renderer carries no per-post state: every top-level `*_to_markdown` call produces its own `Conversion`. Constructing a renderer is cheap; constructing thousands is wasteful. The build-once pattern is the recommended shape:
```ruby
class ForumImporter
RENDERER = Markbridge.discourse_renderer(
tags: {}, # your custom Tags
unregister: [], # AST classes to drop
escape_hard_line_breaks: true,
)
def import(post)
result = Markbridge.bbcode_to_markdown(post.body, renderer: RENDERER)
persist(post, result.markdown)
end
end
```
There’s no shared default `Renderer` instance — each bare call wraps a fresh (cheap) `Renderer` around the shared default tag library. Pass `renderer:` to reuse one instance across calls and carry your customizations.
## See also
* [Migrating to Discourse → Overview](/migrating/overview/) — when this page’s customizations show up in a real importer.
* [Extending Markbridge](/customization/extending/) — how to write the custom Tags and handlers you’d register here.
* [Reference → Upgrading](/reference/upgrading/) — the full break list from the previous API.
# Extending Markbridge
> Add support for new tags, customize rendering, and swap behavior without patching core.
Markbridge has two places to plug in: **handlers** teach a parser to recognize new tags, and **renderer tags** turn AST nodes into Markdown. Both live in registries, so you add your own on top of the defaults without forking the gem.
## Connect parsing and rendering
```plaintext
custom BBCode tag → custom handler → custom AST node → custom renderer tag → Markdown
```
To support a new tag, register a handler that creates an AST node. Then choose how that node should render. You can register a renderer tag or inherit one from a built-in AST class.
## Adding a custom BBCode tag
### 1. Define the AST node
```ruby
module Markbridge
module AST
class Callout < Element
attr_reader :variant
def initialize(variant: "info")
super()
@variant = variant
end
end
end
end
```
### 2. Write the handler
```ruby
module Markbridge
module Parsers
module BBCode
module Handlers
class CalloutHandler < BaseHandler
def initialize
@element_class = AST::Callout
end
attr_reader :element_class
def on_open(token:, context:, registry:, tokens: nil)
variant = token.attrs[:option] || "info"
context.push(AST::Callout.new(variant:))
end
end
end
end
end
end
```
### 3. Register the handler
```ruby
handlers =
Markbridge::Parsers::BBCode::HandlerRegistry.build_from_default do |registry|
registry.register("callout", Markbridge::Parsers::BBCode::Handlers::CalloutHandler.new)
end
```
### 4. Write the renderer tag
The block form is the quickest path:
```ruby
callout_tag =
Markbridge::Renderers::Discourse::Tag.new do |element, interface|
context = interface.with_parent(element)
inner = interface.render_children(element, context:)
if interface.html_mode?
""
else
"> [!#{element.variant.upcase}]\n> #{inner.gsub("\n", "\n> ")}\n"
end
end
```
### 5. Build a renderer with the tag
```ruby
renderer = Markbridge.discourse_renderer(
tags: { Markbridge::AST::Callout => callout_tag },
)
```
`tags:` merges on top of the default library, so every other AST class keeps its built-in rendering. See [Customizing the renderer](/customization/customizing-renderer/) for the full set of factory options.
### 6. Use it
```ruby
result = Markbridge.bbcode_to_markdown(
"[callout=warning]Check this setting.[/callout]",
handlers:,
renderer:,
)
result.markdown
# => "> [!WARNING]\n> Check this setting."
```
## The rendering interface
Custom tags receive `(element, interface)`. The interface exposes context-aware helpers:
| Method | Purpose |
| ------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `render_children(element)` | Render child nodes and concatenate their output |
| `render_default(node)` | Render `node` with its stock Tag, bypassing your override — intercept only some nodes and defer the rest |
| `with_parent(element)` | Return a new context that treats `element` as a parent |
| `find_parent(klass)` | Walk up the ancestor chain for a specific AST class |
| `has_parent?(klass)` | Boolean parent check |
| `count_parents(klass)` | Depth of a specific ancestor type (useful for nested lists) |
| `wrap_inline(content, markers)` | Wrap inline content, collapsing adjacent markers cleanly |
| `block_context?(element)` | True if the current position is a block context |
| `html_mode?` | True inside a CommonMark HTML block — the Tag must emit raw HTML or wrap output as a Markdown island |
Use `find_parent` / `has_parent?` to render differently inside specific ancestors (e.g. a code span inside a table cell).
A Tag must return a String — returning `nil` (or anything else) raises a `TypeError`. To handle only some nodes, defer the rest with `render_default(node)` instead of falling through to `nil`.
## Rendering inside HTML blocks
Tables with uneven rows, multiline cells, or nested tables use HTML output. Inside these tables, `interface.html_mode?` is `true`. Each custom tag must return either:
* An HTML equivalent, with user-controlled text and attributes escaped using `Markbridge::Renderers::Discourse::HtmlEscaper`.
* Its Markdown wrapped with `Markbridge::Renderers::Discourse::HtmlBlock.island(markdown)`. This adds blank lines so CommonMark can parse the Markdown. It also adds paragraph spacing, so prefer HTML when a suitable element exists.
The callout example above uses `