Upgrading
Upgrading to 0.4.2
Section titled “Upgrading to 0.4.2”Check output assertions for aligned blocks and nested lists:
- Aligned content has blank lines inside its
<div>so links and formatting render as Markdown. In an HTML table, alignment uses HTML content without those blank lines. - List continuation lines use the width of their parent marker: two spaces for
-and three for1.. Nested items receive indentation from their parent item.
If your extensions use these helpers, update them:
| API | Action |
|---|---|
HtmlBlockSafety |
Rename references to Markbridge::Renderers::Discourse::HtmlBlock. |
ListItemBuilder#build |
Remove the indent: keyword. The builder indents continuation lines using the marker width. |
HtmlBlock.safe?(output) checks HTML-mode output. HtmlBlock.island(markdown) wraps Markdown in blank lines, and HtmlBlock.opens?(line) checks whether a line opens a CommonMark HTML block.
render_children accepts a block with the output buffer and the child about to be appended. Use it when your custom tag needs to adjust spacing between children:
interface.render_children(element, context:) do |buffer, child| buffer.rstrip! if child.is_a?(Markbridge::AST::List)endUpgrading to 0.4.1
Section titled “Upgrading to 0.4.1”Lines containing only = characters are escaped, including lines at the start of a text node. This keeps a separator after a line break or inline element from becoming a heading. Update assertions that compare raw Markdown; the rendered equals signs remain visible.
Upgrading to 0.4.0
Section titled “Upgrading to 0.4.0”Review your AST subclasses
Section titled “Review your AST subclasses”Normalizer rules, tag dispatch, and interface.render_default match class ancestry. A subclass without its own registration inherits the behavior of its nearest matching base class.
If you want a subclass to render only its children, register Tag::PASSTHROUGH for it. Removing its tag with unregister: allows an ancestor tag to apply. See AST subclasses.
If you call Normalizer::RuleSet#resolve directly, pass a third argument: a Hash used as a cache for that tree walk. The public normalize, violations, and rule methods keep their signatures.
Check code block output
Section titled “Check code block output”BBCode [code] and [pre], HTML <pre>, TextFormatter <CODE>, and MediaWiki indented and <pre> blocks render as fenced blocks even for one line. Language hints stay on the fence.
require "markbridge/bbcode"
Markbridge.bbcode_to_markdown("[code=ruby]puts 1[/code]").markdown# => "```ruby\nputs 1\n```"When you build nodes yourself, set AST::Code.new(block: true) to force a block. Inline forms such as [tt] and <code> stay inline unless they contain a newline.
Check HTML and empty elements
Section titled “Check HTML and empty elements”- HTML
<h1>through<h6>render as headings. - HTML code language detection checks
language-*classes beforelang, then a single class name. See Code languages. - Empty code, details, and spoiler elements produce no output.
Use the shared HTML-mode example to check your custom tags.
Upgrading to 0.3.1
Section titled “Upgrading to 0.3.1”A new Markbridge::Normalizer pass runs between the parse-time yield hook and rendering, and it’s on by default for every *_to_markdown call, convert, and render. It rewrites nesting that Markdown can’t express, so you may see different output with no code change on your side:
- A link inside a link collapses to a single link (CommonMark forbids nested links).
- A block element inside an inline container — a quote, list, table, or a
Poll/Eventinside a link, bold, or a heading — is moved out, so the inline element doesn’t break. - A fenced or multi-line code block inside an inline container is moved out; a one-line code span stays.
- A formatting wrapper left empty by the above is removed (no stray
****).
Every change is reported under conversion.diagnostics[:normalization], next to unknown_tags. The default rules are legality only — Discourse policy like moving an image out of a link isn’t built in; add those rules yourself. To turn the pass off, pass normalize: false. See AST normalization for the full picture.
0.3.0 — breaking changes
Section titled “0.3.0 — breaking changes”AST::Quote attribution fields renamed and typed
Section titled “AST::Quote attribution fields renamed and typed”post and topic are gone. The fields now say what they hold, and every id is an Integer (it used to be a String):
# Beforequote.post # => "123" (called "post ID", but actually a post *number*)quote.topic # => "456"
# Afterquote.post_number # => 123 position within the topic (Discourse quotes)quote.topic_id # => 456quote.post_id # => 9001 database id (phpBB / XenForo-style sources)quote.user_id # => 12 new — id-based user attributionThe TextFormatter parser no longer funnels phpBB’s post_id into a rendered post:N attribution — a database id there links the wrong post. Id-attributed quotes now render name-only ([quote="alice"]) and carry post_id / user_id on the AST for you to remap, typically in the block yielded between parse and render.
Bare and relative URLs render differently
Section titled “Bare and relative URLs render differently”- A bare URL — link text equal to the href, or no text — renders as the plain href instead of
[url](url), so Discourse can autolink and onebox it.AST::Url#bare?exposes the same judgment. - Relative hrefs (
/t/5,#anchor, wiki page names) are kept as links instead of being dropped. Unknown schemes (javascript:and friends) are still removed. Destinations containing whitespace use the<…>CommonMark form. - A text-less link no longer renders as
[](url).
Custom tags must return a String
Section titled “Custom tags must return a String”A tag that returns nil (or anything other than a String) now raises a descriptive TypeError right away, instead of failing later inside string concatenation. To override only some nodes and keep the stock rendering for the rest, use the new fall-through:
Markbridge::Renderers::Discourse::Tag.new do |node, interface| next interface.render_default(node) unless node.href&.start_with?("/") "[internal|#{node.href}]"endinterface.render_default(node) renders the node with its stock Tag, bypassing your override — so a custom Tag can intercept just the cases it cares about and defer the rest.
0.2.0 — breaking changes
Section titled “0.2.0 — breaking changes”0.2.0 reshapes the top-level API around Conversion/Parse result types and a single renderer: kwarg for render-side customization. There is no backwards-compatibility shim — the changes are mechanical, but every call site needs to be updated.
Convenience methods now return a Conversion, not a String
Section titled “Convenience methods now return a Conversion, not a String”# Beforemarkdown = Markbridge.bbcode_to_markdown(input)markdown.gsub(/.../, "...") # String operation
# Afterresult = Markbridge.bbcode_to_markdown(input)result.markdown.gsub(/.../, "...") # explicit access
# Or, if you only need the string for puts/interpolation:puts result # to_s delegates to markdown"got #{result}" # worksConversion carries markdown, ast, format, unknown_tags, diagnostics, errors. It does not delegate other String methods — result.gsub(...) raises NoMethodError. Use result.markdown.gsub(...).
Singleton config and per-process default registries are gone
Section titled “Singleton config and per-process default registries are gone”Removed:
Markbridge.configurationMarkbridge.configure { |c| c.escape_hard_line_breaks = ... }Markbridge.reset_defaults!Markbridge.default_handlersMarkbridge.default_html_handlersMarkbridge.default_text_formatter_handlersMarkbridge.default_tag_libraryMarkbridge::Configuration(the class)
To customize rendering, build a Renderer once via the new factory and pass it through renderer::
# BeforeMarkbridge.configure { |c| c.escape_hard_line_breaks = true }Markbridge.default_tag_library.register(MyAst::Bold, MyTag.new)Markbridge.bbcode_to_markdown(input)
# AfterRENDERER = Markbridge.discourse_renderer( tags: { MyAst::Bold => MyTag.new }, escape_hard_line_breaks: true, )Markbridge.bbcode_to_markdown(input, renderer: RENDERER)Build the renderer once outside your migration loop and reuse it across thousands of posts; it holds no per-post state. See Customizing the renderer for the full set of factory kwargs.
tags:, tag_library:, escaper:, escape_hard_line_breaks: removed from per-call signature
Section titled “tags:, tag_library:, escaper:, escape_hard_line_breaks: removed from per-call signature”All four moved into Markbridge.discourse_renderer(...). The four *_to_markdown methods plus Markbridge.convert now accept only:
handlers:— parser handler registryrenderer:— pre-built Rendererraise_on_error:— boolean (defaulttrue)
MediaWiki kwarg renamed: inline_tag_registry: → handlers:
Section titled “MediaWiki kwarg renamed: inline_tag_registry: → handlers:”# BeforeMarkbridge.parse_mediawiki(input, inline_tag_registry: my_registry)Markbridge::Parsers::MediaWiki::Parser.new(inline_tag_registry: my_registry)
# AfterMarkbridge.parse_mediawiki(input, handlers: my_registry)Markbridge::Parsers::MediaWiki::Parser.new(handlers: my_registry)The accepted type is unchanged — still an InlineTagRegistry. Only the parameter name moves, for parity with the BBCode/HTML/TextFormatter parsers.
TextFormatter handlers must accept processor:
Section titled “TextFormatter handlers must accept processor:”Parsers::TextFormatter::Handlers::BaseHandler#process now has a three-arg signature:
# Beforedef process(element:, parent:)
# Afterdef process(element:, parent:, processor: nil)Update every custom subclass under your importer’s TextFormatter handler tree. The processor: argument is the parser instance and exposes process_children(xml_element, ast_node) for handlers that recurse into children manually.
Proc/lambda handlers are no longer accepted — a handler must be an object responding to #process(...). Wrap any one-off lambda in a small handler class with a #process method.
Tag side-data: read it back off the AST instead of mutating ctor-injected hashes
Section titled “Tag side-data: read it back off the AST instead of mutating ctor-injected hashes”Placeholder Tags used to record side data by mutating a hash injected through the constructor (and a short-lived draft used an interface.emit buffer). Both are gone. A Tag is now a pure function of its element — it returns a string and records nothing; the importer collects side data by walking conversion.ast afterwards:
# Beforeclass UrlTag < Markbridge::Renderers::Discourse::Tag def initialize(placeholders:) @placeholders = placeholders end
def render(element, _interface) link = build_link(element) @placeholders[:links] << link # mutates ctor-injected array link[:placeholder] endend# Importer pre-allocates @placeholders, passes to Tag, reads it after.
# Afterclass UrlTag < Markbridge::Renderers::Discourse::Tag def render(element, _interface) build_link(element)[:placeholder] # pure: returns a string, records nothing endend# Importer reads back: result.ast.descendants(InternalLink).each { |node| ... }The resolved value the importer needs lives on the custom AST node (pinned at parse time by the handler, or read off the source-side data the Tag already has). Pure lookup tables (uploads:, repository:) injected into Tag constructors are still fine — they’re read-only. Only mutation during render moves out, to a walk over conversion.ast.descendants(...). See Placeholders.
RawHandler no longer requires language: on the AST class
Section titled “RawHandler no longer requires language: on the AST class”Markbridge::Parsers::BBCode::Handlers::RawHandler used to call @element_class.new(language:) unconditionally. Custom AST classes reused with RawHandler had to declare a language: kwarg even when unused. The handler now introspects the AST class once and only passes language: when the class accepts it. No code action needed unless you’d previously added a dummy def initialize(language: nil); super(); end just to satisfy the handler — you can remove it.
Per-row failure isolation
Section titled “Per-row failure isolation”For migration loops, set raise_on_error: false to surface render exceptions on Conversion#errors instead of crashing the loop:
posts.each do |post| result = Markbridge.bbcode_to_markdown(post.body, renderer: RENDERER, raise_on_error: false) if result.errors.any? log_failure(post, result.errors) else write_markdown(post, result.markdown) endendThe default remains raise_on_error: true, preserving the prior behavior of letting exceptions propagate.
0.2.0 — new capabilities
Section titled “0.2.0 — new capabilities”0.2.0 also added a few things you’ll likely want during a migration.
Let some Markdown markers through with allow:
Section titled “Let some Markdown markers through with allow:”If your source posts already contain - item or 1. item lists you want to keep, you no longer need to subclass the escaper. Before, you’d write a whole class:
class ListPermissiveEscaper < Markbridge::Renderers::Discourse::MarkdownEscaper private
def escape_block_level(content, prev_was_paragraph) case content.getbyte(0) when 0x2D, 0x2A, 0x2B then return content, false if content.match?(/\A[-*+]\s/) when 0x30..0x39 then return content, false if content.match?(/\A\d+[.)]\s/) end super endendMarkbridge.discourse_renderer(escaper: ListPermissiveEscaper.new)Now it’s one keyword:
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 (===) are still escaped — allow: opens up specific markers, not the whole escaper.
Turn escaping off completely with escape: false
Section titled “Turn escaping off completely with escape: false”When the source is already trusted Markdown, skip escaping entirely:
no_escape = Markbridge.discourse_renderer(escape: false)Markbridge.bbcode_to_markdown(input, renderer: no_escape)This swaps in Markbridge::Renderers::Discourse::IdentityEscaper, a tiny class whose escape returns the 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. For a single node rather than the whole document, AST::MarkdownText already skips the escaper for that node only.
Change the AST between parse and render
Section titled “Change the AST between parse and render”You can edit the parsed tree before it’s rendered — handy for adding attachments that weren’t in the post body. Every *_to_markdown / convert method takes a block, or you can render a Parse by hand:
# Block form — edit the AST inlineMarkbridge.bbcode_to_markdown(input, renderer: RENDERER) do |ast| attachments.each { |a| ast << OrphanAttachment.new(source_id: a.id) }end
# Or render a Parse you've already changedparse = Markbridge.parse_bbcode(input)parse.ast << OrphanAttachment.new(source_id: 7)result = Markbridge.render(parse, renderer: RENDERER)Markbridge.render takes either a Parse (preferred — it keeps unknown_tags, diagnostics, and the source format) or a bare AST node (those fields default to empty, and format is nil since there was no source). A non-Document node is wrapped in one, so Conversion#ast is always a Document. Your changes stay in Conversion#ast.
Pass a Nokogiri tree to the HTML and TextFormatter parsers
Section titled “Pass a Nokogiri tree to the HTML and TextFormatter parsers”Both Nokogiri-backed parsers now accept a String or a parsed Nokogiri node. If your importer already runs its own DOM pass, hand the live tree straight over — no serialize-and-reparse in between:
fragment = Nokogiri::HTML.fragment(html)Markbridge.html_to_markdown(fragment)Same idea for TextFormatter XML:
xml_doc = Nokogiri.XML(xml)Markbridge.text_formatter_xml_to_markdown(xml_doc)A full Nokogiri::HTML::Document is unwrapped to its <body> children (so the synthetic <html>/<head>/<body> don’t show up in unknown_tags); a fragment or bare element iterates its own children. For TextFormatter, an XML Document is unwrapped via #root (the single <r>/<t> element). Skipping the round-trip also avoids a quirk where re-serializing percent-encodes non-ASCII bytes in URLs.
Walk and edit the tree with helpers on Element
Section titled “Walk and edit the tree with helpers on Element”Three methods replace the hand-rolled recursion importers used to write:
# Walk every descendant, depth-firstdoc.each_descendant { |node| node }
# Filter by class (uses is_a?, so abstract bases match subclasses)bold = doc.descendants(Markbridge::AST::Bold).first
# Swap a direct child in place, keeping its positiondoc.replace_child(bold, Markbridge::AST::Text.new("HI"))each_descendant snapshots each element’s children when it reaches them, so calling replace_child mid-walk is safe and nodes appended during the walk aren’t re-visited.
AST::Details for collapsible sections
Section titled “AST::Details for collapsible sections”Discourse’s collapsible [details=…]…[/details] block is now a built-in AST node with an auto-registered Tag, so you can drop any local DetailsBlock shim:
doc << Markbridge::AST::Details.new(title: "Signature").tap do |block| block << Markbridge::AST::Text.new("--\nAlex Doe")endMarkbridge.render(doc).markdowntitle is optional — leave it out for a bare [details] (and <summary>Summary</summary> in HTML mode). The title is HTML-escaped in the <summary>.
See also
Section titled “See also”- Customizing the renderer — full reference for the factory.
- Extending Markbridge — adding custom tags and handlers.