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. ![Three-phase pipeline: Input (BBCode / HTML / MediaWiki / XML) → AST (Document tree) → Discourse Markdown](/diagrams/architecture.svg) ## 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 (`[![alt](src)](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