Concepts: Source version: Markbridge 0.4.2. These docs are built from the repository and may include unreleased changes.
# Concepts
> How Markbridge works under the hood — the pipeline, the AST, parsers, renderers, and performance.
The deep dives, for when you want to understand or change how Markbridge works:
* **[Architecture](/concepts/architecture/)** — the parse → AST → render pipeline, and why it’s shaped that way.
* **[The AST](/concepts/ast/)** — node types, invariants, and how the tree is built and walked.
* **[Result objects](/concepts/result-objects/)** — what you get back: the `Conversion` and `Parse` value objects.
* **[Parsers](/concepts/parsers/)** — how each of the four parsers works, and their trade-offs.
* **[Renderers](/concepts/renderers/)** — the Discourse renderer, Tags, and the rendering interface.
* **[AST normalization](/concepts/normalization/)** — the pass that rewrites nesting Markdown can’t express before rendering.
* **[Performance](/concepts/performance/)** — where the pipeline is tuned, and how to measure your own workload.
# Architecture
> The three-phase pipeline that turns markup into Markdown.
Markbridge is built around a **Parse → AST → Render** pipeline. Each phase has a single responsibility and doesn’t know about the others. The parse and AST stages are renderer-agnostic; Discourse-flavored Markdown is what the shipped renderer produces.

## Phase 1 — parse
A format-specific parser consumes the input and produces an `AST::Document`. There are four parsers today:
* `Parsers::BBCode::Parser` — token scanner + handler registry (stateful handler API).
* `Parsers::HTML::Parser` — Nokogiri fragment walker (stateless handler API).
* `Parsers::TextFormatter::Parser` — Nokogiri XML walker for the s9e format.
* `Parsers::MediaWiki::Parser` — line-based wikitext parser with no handler registry.
All four produce the same AST node types.
## Phase 2 — the AST
The AST is a tree of `AST::Node` instances. It’s renderer-agnostic: nothing in the tree knows about Markdown.
```plaintext
Node (base)
├── Text (leaf)
├── LineBreak, HorizontalRule (leaf)
└── Element (container, has children)
├── Document (root)
├── Inline: Bold, Italic, Underline, Strikethrough, Superscript, Subscript
├── Block: Quote, List, ListItem, Code, Spoiler, Heading, HorizontalRule
└── Content: Url, Image, Attachment, Color, Size, Align, Table, TableRow, TableCell
```
Adjacent `Text` nodes auto-merge on insert, which keeps the tree small. `Element` validates that its children are `AST::Node` instances.
## Phase 3 — render
Before the renderer runs, the AST goes through a normalization pass (`parse → yield → normalize → render`). It rewrites nesting Markdown can’t express — a link inside a link, a block inside bold — so the renderer’s tags stay simple string emitters. It’s on by default; see [AST normalization](/concepts/normalization/).
`Renderers::Discourse::Renderer` walks the tree. For each node it looks up a `Tag` in the `TagLibrary` and calls `tag.render(element, interface)`. The interface carries a `RenderContext` — an immutable parent chain that lets tags ask “am I inside a list?” or “what’s my depth?” without passing state around manually.
`RenderContext` is a linked parent chain: each nested level adds one small context object, and `has_parent?` / `find_parent` walk the chain (nesting depth is shallow in practice).
## Design patterns in use
* **Composite** — `Element` contains children forming a tree.
* **Strategy** — BBCode uses pluggable closing strategies (Strict, Reordering).
* **Registry** — `HandlerRegistry` for parsers, `TagLibrary` for the renderer.
* **Visitor** — the renderer dispatches AST nodes to tag implementations.
* **Immutable context** — `RenderContext` creates new instances instead of mutating.
## Why this shape
* **Parsers don’t know about Markdown.** You can add a new output format without touching them.
* **The renderer doesn’t know about BBCode or HTML.** You can add a new input format without touching it.
* **Registries keep customization from forking the core.** Add a handler, add a tag — no subclassing required.
## Next
* [The AST](/concepts/ast/) — node types and invariants
* [Parsers](/concepts/parsers/) — how each parser works
* [Renderers](/concepts/renderers/) — how tags and the rendering interface fit together
* [Performance](/concepts/performance/) — where the pipeline is tuned
# The AST
> Node types, invariants, and how the tree gets built.
Every parser produces an abstract syntax tree (AST). The tree stores content and structure independently of the input syntax and renderer.
## Node hierarchy
```plaintext
AST::Node (base)
├── Leaves
│ ├── AST::Text — string content
│ ├── AST::MarkdownText — pre-rendered Markdown passthrough
│ ├── AST::LineBreak
│ └── AST::HorizontalRule
├── Discourse-specific leaves
│ ├── AST::Event — calendar event reference
│ ├── AST::Mention — @username reference
│ ├── AST::Poll — Discourse poll reference
│ └── AST::Upload — uploaded-file reference
└── AST::Element (container)
├── AST::Document — root node
├── Inline formatting
│ ├── AST::Bold
│ ├── AST::Italic
│ ├── AST::Underline
│ ├── AST::Strikethrough
│ ├── AST::Superscript
│ └── AST::Subscript
├── Block-level
│ ├── AST::Paragraph
│ ├── AST::Heading — level
│ ├── AST::Quote
│ ├── AST::Spoiler
│ └── AST::Details — collapsible [details] section
├── Content
│ ├── AST::Url — href attribute
│ ├── AST::Email — email address
│ ├── AST::Image — src, alt attributes
│ ├── AST::Attachment
│ ├── AST::Code — language and block flag
│ └── AST::Color, AST::Size, AST::Align
├── Lists
│ ├── AST::List — ordered / unordered
│ └── AST::ListItem
└── Tables
├── AST::Table
├── AST::TableRow
└── AST::TableCell
```
## Invariants
* **Children are always `AST::Node` instances.** `Element#<<` validates on insert.
* **Adjacent `Text` nodes auto-merge.** Inserting `Text("a")` then `Text("b")` results in a single `Text("ab")` child — not two.
* **Leaves have no children.** `LineBreak` and `HorizontalRule` extend `Node` directly, not `Element`, and will reject children.
* **Node attributes are read-only.** You can edit the tree with `<<`, `replace_child`, and `replace_children`.
## Building and inspecting
```ruby
doc = AST::Document.new
bold = AST::Bold.new
bold << AST::Text.new("Hello, ")
bold << AST::Text.new("world") # auto-merged into one Text("Hello, world")
doc << bold
doc.children.first.class # => AST::Bold
doc.children.first.children.length # => 1
```
## Walking the tree
Use `each_descendant` to visit nodes in depth-first order, or `descendants(klass)` to collect nodes of a given class. Subclasses match too.
```ruby
parse = Markbridge.parse_bbcode("[b]Hello[/b] [url=/about]About[/url]")
links = parse.ast.descendants(Markbridge::AST::Url)
links.map(&:href) # => ["/about"]
```
`replace_child(old_node, new_node)` replaces a direct child at the same position. During traversal, each element uses a copy of its child list. Replacing a child is supported, but the walk continues through the original node. Added children may not be visited during that walk.
## Code spans and blocks
`AST::Code.new(language: "ruby", block: true)` forces a fenced block, even for one line. Without `block: true`, single-line content renders as a code span and multiline content renders as a fenced block. Empty code nodes produce no output.
## Why a shared AST matters
The AST is what lets four parsers share one renderer. Any new input format — Markdown, AsciiDoc, some vendor-specific XML — only has to produce the same node types, and everything downstream works without changes. Similarly, a second renderer (say, plain text or HTML) only has to walk the existing AST.
# AST normalization
> The pass that rewrites the AST so the renderer only sees markup the target format can express.
Markup can nest elements in ways Markdown can’t express: a link inside a link, a block element inside an inline container (a link label, but also bold or a heading), a fenced code block inside emphasis. If the renderer printed these as-is, the Markdown would break — the inner link wins and the outer one turns into text, a block’s blank lines break out of the emphasis around it.
`Markbridge::Normalizer` walks the AST once, between the parse-time `yield` hook and rendering, and rewrites it so the renderer only gets markup the target format can express. It runs **by default**. The renderer’s tags stay simple string emitters; the rules about what may nest in what live here instead.
## Where it runs
```plaintext
parse → yield(ast) → normalize → render
```
Because it runs after the `yield` hook, changes you make to the AST in that block are normalized too. It runs for every source format and for `Markbridge.render`, because normalization is about the *target* format, not the source.
## The default rules
The default rules are CommonMark legality. Break one and the Markdown doesn’t parse back as the tree meant:
* **No link inside a link**, at any depth (§6.3). The inner link is unwrapped.
* **A block inside an inline container is moved out.** An inline container holds inline content only. This isn’t link-specific: emphasis (`Bold`, `Italic`, …) and headings are inline containers too, so a poll inside bold or a list inside a heading is handled the same way as a block inside a link. The block nodes cover `List`, `Table`, `Quote`, `Details`, `HorizontalRule`, `Align`, and the Discourse `Poll`/`Event` nodes.
* **A fenced or multi-line code block inside an inline container is moved out.** A one-line code span is fine.
Discourse-specific policy is **not** built in. Moving an image out of a link, for example, is a rule you add yourself (see below) — a linked image (`[](url)`) is valid CommonMark, so the default leaves it alone.
## Strategies
Each match resolves to one strategy:
| Strategy | Effect |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `:keep` | Allow it. Records a decision and keeps it out of the report. |
| `:hoist_after` | Move the node out and place it right after the outermost matching ancestor, keeping document order. An image in a bold that sits in a link moves after the whole link (out of both). The walker only moves a node out to a sibling; it never puts one into a wrapper it wasn’t already in. |
| `:unwrap` | Remove the element and put its children in its place. The built-in case is a link in a link: `[[text](inner)](outer)` becomes `[text](outer)` — the inner href is dropped, its text stays under the outer link. |
| `:textify` | Replace the subtree with its plain text (`@name` for a mention, the joined text otherwise). |
| `:drop` | Remove it. |
| callable | `->(boundary, node) { … }` returning a strategy symbol, an `Array` to put in its place, or `nil` to drop it. For anything the built-in strategies don’t cover. |
A formatting wrapper (bold, italic, color, …) left empty after a hoist or drop is removed, so no empty `**` `**` markers remain. A link is the exception — an empty link is kept, because it renders as a plain URL.
## Diagnostics
Every change is reported through the same channel as `unknown_tags`:
```ruby
conversion = Markbridge.convert(input, format: :bbcode)
conversion.diagnostics[:normalization]
# => [{ parent: "Url", child: "Url", strategy: :unwrap, count: 1 }]
```
For a migration this feeds per-post warnings and shows which sources produce broken trees. The key is absent when nothing changed.
## Opting out and customizing
`normalize:` takes `true` (default — the shared normalizer), `false` (skip), or a `Normalizer` instance:
```ruby
# Skip normalization
Markbridge.convert(input, format: :bbcode, normalize: false)
# Add your own rules on top of the defaults
normalizer = Markbridge::Normalizer.default
normalizer.rule(parent: Markbridge::AST::Url, child: Markbridge::AST::Image, strategy: :hoist_after)
Markbridge.convert(input, format: :bbcode, normalize: normalizer)
```
Build a customized normalizer once and reuse it. `#normalize` and `#violations` keep no state on the instance, so one frozen instance is safe for every conversion, also across threads — passing your own is as fast as the default path. A rule for a `(parent, child)` pair that already exists is replaced, so your `#rule` calls override the defaults. Rules match subclasses too, for both `parent:` and `child:`. A rule for a more specific class takes priority. See [AST subclasses](/customization/extending/#ast-subclasses).
`Markbridge::Normalizer.shared_default` is the default normalizer, built once and frozen; the `normalize: true` path uses it. Don’t mutate it — call `.default` for a fresh, customizable one.
## Validation
The same rules, without changing the tree:
```ruby
Markbridge::Normalizer.default.violations(ast)
# => [{ parent: "Url", child: "Url", strategy: :unwrap }]
```
Two uses: assert in your own suite that the trees your parsers and tag fixtures build have no violations, or run it as a lint over a corpus without changing any output. After a `normalize`, `violations` returns nothing — normalization is a single pass.
# Parsers
> How the four parsers work and where they differ.
Each parser targets one input format but produces the same AST. They share philosophy (graceful degradation, bounded recursion) but not implementation.
## BBCode parser
**Path:** `Markbridge::Parsers::BBCode::Parser`
A hand-written, two-stage parser:
1. **Scanner** — streams the input and produces `TextToken`, `TagStartToken`, `TagEndToken`. Byte-offset based (`byteslice` / `byteindex` / `getbyte`, not character indices, which are O(n) on multibyte input), no regex except for character classes, minimal allocations.
2. **Parser** — consumes tokens through a `HandlerRegistry`. Each handler implements `on_open` / `on_close`. A `ParserState` tracks the node stack and enforces the max-depth limit (100).
**Unique to BBCode:** closing strategies. Real-world BBCode often has mismatched tags (`[b][i]text[/b][/i]`). A `ClosingStrategy` decides how to recover:
* `Strict` — auto-close only.
* `Reordering` (default) — reconciles sequences of up to 5 mismatched closing tags by peeking ahead.
**Handler API:** stateful. Handlers push/pop elements on the parser state stack via `on_open` / `on_close` callbacks.
## HTML parser
**Path:** `Markbridge::Parsers::HTML::Parser`
Thin wrapper over `Nokogiri::HTML.fragment` + a handler registry. Walks the DOM and dispatches each element to a handler.
**Handler API:** stateless — an object responding to `#process(element:, parent:)`. It adds an AST node to `parent` and returns either the node to descend into, or `nil` to skip children.
```ruby
class AsideHandler < Markbridge::Parsers::HTML::Handlers::BaseHandler
# Descend the children straight into the parent — no AST node for