")
end
it "drops the spurious wrapper when a single paragraph fills a cell" do
@@ -341,8 +341,8 @@
result = Markbridge.html_to_markdown(html)
- expect(result).to include("
line one line two | ")
- expect(result).not_to include("")
+ expect(result.markdown).to include(" | line one line two | ")
+ expect(result.markdown).not_to include("")
end
end
end
diff --git a/spec/system/mediawiki_to_markdown_spec.rb b/spec/system/mediawiki_to_markdown_spec.rb
index dcc1614..e17a6e2 100644
--- a/spec/system/mediawiki_to_markdown_spec.rb
+++ b/spec/system/mediawiki_to_markdown_spec.rb
@@ -4,91 +4,91 @@
describe "inline formatting" do
it "converts bold text" do
result = Markbridge.mediawiki_to_markdown("'''bold text'''")
- expect(result).to eq("**bold text**")
+ expect(result.markdown).to eq("**bold text**")
end
it "converts italic text" do
result = Markbridge.mediawiki_to_markdown("''italic text''")
- expect(result).to eq("*italic text*")
+ expect(result.markdown).to eq("*italic text*")
end
it "converts bold italic text" do
result = Markbridge.mediawiki_to_markdown("'''''bold italic'''''")
- expect(result).to eq("***bold italic***")
+ expect(result.markdown).to eq("***bold italic***")
end
it "converts strikethrough with " do
result = Markbridge.mediawiki_to_markdown("deleted")
- expect(result).to eq("~~deleted~~")
+ expect(result.markdown).to eq("~~deleted~~")
end
it "converts strikethrough with " do
result = Markbridge.mediawiki_to_markdown("deleted")
- expect(result).to eq("~~deleted~~")
+ expect(result.markdown).to eq("~~deleted~~")
end
it "converts underline with " do
result = Markbridge.mediawiki_to_markdown("underlined")
- expect(result).to eq("[u]underlined[/u]")
+ expect(result.markdown).to eq("[u]underlined[/u]")
end
it "converts underline with " do
result = Markbridge.mediawiki_to_markdown("inserted")
- expect(result).to eq("[u]inserted[/u]")
+ expect(result.markdown).to eq("[u]inserted[/u]")
end
it "converts superscript" do
result = Markbridge.mediawiki_to_markdown("x2")
- expect(result).to eq("x2")
+ expect(result.markdown).to eq("x2")
end
it "converts subscript" do
result = Markbridge.mediawiki_to_markdown("H2O")
- expect(result).to eq("H2O")
+ expect(result.markdown).to eq("H2O")
end
it "converts inline code" do
result = Markbridge.mediawiki_to_markdown("var x = 1")
- expect(result).to eq("`var x = 1`")
+ expect(result.markdown).to eq("`var x = 1`")
end
it "converts line break" do
result = Markbridge.mediawiki_to_markdown("Line 1 Line 2")
- expect(result).to eq("Line 1\nLine 2")
+ expect(result.markdown).to eq("Line 1\nLine 2")
end
it "converts self-closing line break" do
result = Markbridge.mediawiki_to_markdown("Line 1 Line 2")
- expect(result).to eq("Line 1\nLine 2")
+ expect(result.markdown).to eq("Line 1\nLine 2")
end
end
describe "nowiki" do
it "preserves wiki markup as literal text" do
result = Markbridge.mediawiki_to_markdown("'''not bold'''")
- expect(result).to eq("'''not bold'''")
+ expect(result.markdown).to eq("'''not bold'''")
end
end
describe "links" do
it "converts external link with display text" do
result = Markbridge.mediawiki_to_markdown("[https://example.com Click here]")
- expect(result).to eq("[Click here](https://example.com)")
+ expect(result.markdown).to eq("[Click here](https://example.com)")
end
it "converts external link without display text" do
result = Markbridge.mediawiki_to_markdown("[https://example.com]")
- expect(result).to eq("[https://example.com](https://example.com)")
+ expect(result.markdown).to eq("[https://example.com](https://example.com)")
end
it "converts internal link" do
result = Markbridge.mediawiki_to_markdown("[[Page Name]]")
- expect(result).to eq("Page Name")
+ expect(result.markdown).to eq("Page Name")
end
it "converts internal link with display text" do
result = Markbridge.mediawiki_to_markdown("[[Page Name|display text]]")
- expect(result).to eq("display text")
+ expect(result.markdown).to eq("display text")
end
end
@@ -97,51 +97,51 @@
wiki = "* Item 1\n* Item 2\n* Item 3"
result = Markbridge.mediawiki_to_markdown(wiki)
- expect(result).to eq("- Item 1\n- Item 2\n- Item 3")
+ expect(result.markdown).to eq("- Item 1\n- Item 2\n- Item 3")
end
it "converts ordered list" do
wiki = "# First\n# Second\n# Third"
result = Markbridge.mediawiki_to_markdown(wiki)
- expect(result).to eq("1. First\n1. Second\n1. Third")
+ expect(result.markdown).to eq("1. First\n1. Second\n1. Third")
end
it "converts nested unordered list" do
wiki = "* Item 1\n** Subitem 1.1\n** Subitem 1.2\n* Item 2"
result = Markbridge.mediawiki_to_markdown(wiki)
- expect(result).to include("- Item 1")
- expect(result).to include("- Subitem 1.1")
- expect(result).to include("- Item 2")
+ expect(result.markdown).to include("- Item 1")
+ expect(result.markdown).to include("- Subitem 1.1")
+ expect(result.markdown).to include("- Item 2")
end
it "converts nested ordered list" do
wiki = "# Item 1\n## Subitem 1.1\n## Subitem 1.2\n# Item 2"
result = Markbridge.mediawiki_to_markdown(wiki)
- expect(result).to include("1. Item 1")
- expect(result).to include("1. Subitem 1.1")
- expect(result).to include("1. Item 2")
+ expect(result.markdown).to include("1. Item 1")
+ expect(result.markdown).to include("1. Subitem 1.1")
+ expect(result.markdown).to include("1. Item 2")
end
it "converts list items with formatting" do
wiki = "* '''Important''' item\n* Normal item"
result = Markbridge.mediawiki_to_markdown(wiki)
- expect(result).to eq("- **Important** item\n- Normal item")
+ expect(result.markdown).to eq("- **Important** item\n- Normal item")
end
end
describe "horizontal rules" do
it "converts ---- to horizontal rule" do
result = Markbridge.mediawiki_to_markdown("----")
- expect(result).to eq("---")
+ expect(result.markdown).to eq("---")
end
it "converts longer dashes to horizontal rule" do
result = Markbridge.mediawiki_to_markdown("------")
- expect(result).to eq("---")
+ expect(result.markdown).to eq("---")
end
end
@@ -150,48 +150,48 @@
wiki = " preformatted line 1\n preformatted line 2"
result = Markbridge.mediawiki_to_markdown(wiki)
- expect(result).to eq("```\npreformatted line 1\npreformatted line 2\n```")
+ expect(result.markdown).to eq("```\npreformatted line 1\npreformatted line 2\n```")
end
it "converts block to code block" do
wiki = "code block\nline 2 "
result = Markbridge.mediawiki_to_markdown(wiki)
- expect(result).to eq("```\ncode block\nline 2\n```")
+ expect(result.markdown).to eq("```\ncode block\nline 2\n```")
end
end
describe "headings" do
it "converts level 1 heading" do
result = Markbridge.mediawiki_to_markdown("= Heading 1 =")
- expect(result).to eq("# Heading 1")
+ expect(result.markdown).to eq("# Heading 1")
end
it "converts level 2 heading" do
result = Markbridge.mediawiki_to_markdown("== Heading 2 ==")
- expect(result).to eq("## Heading 2")
+ expect(result.markdown).to eq("## Heading 2")
end
it "converts level 3 heading" do
result = Markbridge.mediawiki_to_markdown("=== Heading 3 ===")
- expect(result).to eq("### Heading 3")
+ expect(result.markdown).to eq("### Heading 3")
end
it "converts heading with inline formatting" do
result = Markbridge.mediawiki_to_markdown("== '''Bold''' heading ==")
- expect(result).to eq("## **Bold** heading")
+ expect(result.markdown).to eq("## **Bold** heading")
end
end
describe "edge cases" do
it "handles empty input" do
result = Markbridge.mediawiki_to_markdown("")
- expect(result).to eq("")
+ expect(result.markdown).to eq("")
end
it "preserves plain text" do
result = Markbridge.mediawiki_to_markdown("Just plain text")
- expect(result).to eq("Just plain text")
+ expect(result.markdown).to eq("Just plain text")
end
end
@@ -207,7 +207,7 @@
result = Markbridge.mediawiki_to_markdown(wiki)
- expect(result).to eq("| Name | Age |\n| --- | --- |\n| Alice | 30 |")
+ expect(result.markdown).to eq("| Name | Age |\n| --- | --- |\n| Alice | 30 |")
end
it "handles header and data rows" do
@@ -224,7 +224,7 @@
result = Markbridge.mediawiki_to_markdown(wiki)
- expect(result).to eq("| A | B |\n| --- | --- |\n| 1 | 2 |")
+ expect(result.markdown).to eq("| A | B |\n| --- | --- |\n| 1 | 2 |")
end
it "handles inline formatting in cells" do
@@ -238,7 +238,7 @@
result = Markbridge.mediawiki_to_markdown(wiki)
- expect(result).to include("| **Alice** |")
+ expect(result.markdown).to include("| **Alice** |")
end
it "preserves pipes inside internal links within cells" do
@@ -252,7 +252,7 @@
result = Markbridge.mediawiki_to_markdown(wiki)
- expect(result).to eq("| Page | Status |\n| --- | --- |\n| Home | Ready |")
+ expect(result.markdown).to eq("| Page | Status |\n| --- | --- |\n| Home | Ready |")
end
it "preserves pipes inside internal links on per-line cells" do
@@ -265,7 +265,7 @@
result = Markbridge.mediawiki_to_markdown(wiki)
- expect(result).to eq("| Home |\n| --- |")
+ expect(result.markdown).to eq("| Home |\n| --- |")
end
end
end
diff --git a/spec/system/text_formatter_xml_to_markdown_spec.rb b/spec/system/text_formatter_xml_to_markdown_spec.rb
index bf67c94..0b203d9 100644
--- a/spec/system/text_formatter_xml_to_markdown_spec.rb
+++ b/spec/system/text_formatter_xml_to_markdown_spec.rb
@@ -7,7 +7,7 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq("Hello **world**!\n[example](https://example.com)")
+ expect(result.markdown).to eq("Hello **world**!\n[example](https://example.com)")
end
it "renders Discourse quote markup when attribution is present" do
@@ -15,7 +15,7 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq("[quote=\"alice, post:123, topic:456\"]\nQuoted text\n[/quote]")
+ expect(result.markdown).to eq("[quote=\"alice, post:123, topic:456\"]\nQuoted text\n[/quote]")
end
it "renders ordered lists with proper spacing" do
@@ -23,7 +23,7 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq("1. First\n1. Second")
+ expect(result.markdown).to eq("1. First\n1. Second")
end
it "renders multi-line code blocks with fences" do
@@ -31,22 +31,22 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq("```ruby\nputs 'hello'\nputs 'world'\n```")
+ expect(result.markdown).to eq("```ruby\nputs 'hello'\nputs 'world'\n```")
end
it "converts italic text" do
result = Markbridge.text_formatter_xml_to_markdown("italic")
- expect(result).to eq("*italic*")
+ expect(result.markdown).to eq("*italic*")
end
it "converts underline text" do
result = Markbridge.text_formatter_xml_to_markdown("underlined")
- expect(result).to eq("[u]underlined[/u]")
+ expect(result.markdown).to eq("[u]underlined[/u]")
end
it "converts strikethrough text" do
result = Markbridge.text_formatter_xml_to_markdown("deleted")
- expect(result).to eq("~~deleted~~")
+ expect(result.markdown).to eq("~~deleted~~")
end
it "converts unordered lists" do
@@ -54,7 +54,7 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq("- One\n- Two")
+ expect(result.markdown).to eq("- One\n- Two")
end
it "converts images" do
@@ -62,7 +62,7 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq("")
+ expect(result.markdown).to eq("")
end
it "converts email links" do
@@ -70,7 +70,7 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq("[Contact us](mailto:user@example.com)")
+ expect(result.markdown).to eq("[Contact us](mailto:user@example.com)")
end
it "converts inline code" do
@@ -78,7 +78,7 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq("`var x = 1`")
+ expect(result.markdown).to eq("`var x = 1`")
end
it "converts nested formatting" do
@@ -86,17 +86,17 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq("***bold italic***")
+ expect(result.markdown).to eq("***bold italic***")
end
it "handles plain text without XML wrapper" do
result = Markbridge.text_formatter_xml_to_markdown("Just plain text")
- expect(result).to eq("Just plain text")
+ expect(result.markdown).to eq("Just plain text")
end
it "handles empty input" do
result = Markbridge.text_formatter_xml_to_markdown("")
- expect(result).to eq("")
+ expect(result.markdown).to eq("")
end
it "converts spoiler tags" do
@@ -104,7 +104,7 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq("[spoiler=Reveal]hidden content[/spoiler]")
+ expect(result.markdown).to eq("[spoiler=Reveal]hidden content[/spoiler]")
end
it "converts color tags" do
@@ -112,7 +112,7 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq('red text')
+ expect(result.markdown).to eq('red text')
end
it "converts size tags" do
@@ -120,7 +120,7 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq('big text')
+ expect(result.markdown).to eq('big text')
end
end
@@ -131,7 +131,7 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to eq("| Name | Age |\n| --- | --- |\n| Alice | 30 |")
+ expect(result.markdown).to eq("| Name | Age |\n| --- | --- |\n| Alice | 30 |")
end
it "falls back to HTML for uneven rows" do
@@ -139,7 +139,7 @@
result = Markbridge.text_formatter_xml_to_markdown(xml)
- expect(result).to include("")
+ expect(result.markdown).to include("")
end
end
end
diff --git a/spec/unit/markbridge/configuration_spec.rb b/spec/unit/markbridge/configuration_spec.rb
deleted file mode 100644
index 8a408cc..0000000
--- a/spec/unit/markbridge/configuration_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Markbridge::Configuration do
- subject(:configuration) { described_class.new }
-
- describe "#escape_hard_line_breaks" do
- it "defaults to false" do
- expect(configuration.escape_hard_line_breaks).to be false
- end
-
- it "can be set to true" do
- configuration.escape_hard_line_breaks = true
- expect(configuration.escape_hard_line_breaks).to be true
- end
- end
-end
diff --git a/spec/unit/markbridge/parsers/bbcode/handler_registry_spec.rb b/spec/unit/markbridge/parsers/bbcode/handler_registry_spec.rb
index e05dcb0..33a1236 100644
--- a/spec/unit/markbridge/parsers/bbcode/handler_registry_spec.rb
+++ b/spec/unit/markbridge/parsers/bbcode/handler_registry_spec.rb
@@ -460,4 +460,66 @@ def fake_handler(element_class: Markbridge::AST::Bold, auto_closeable: false)
expect(reconciler.instance_variable_get(:@registry)).to eq(registry)
end
end
+
+ describe "#overlay" do
+ let(:registry) { described_class.default }
+
+ it "yields the previously bound handler so a wrapper can delegate to it" do
+ seen = nil
+ registry.overlay("url") do |previous|
+ seen = previous
+ previous
+ end
+
+ expect(seen).to be_a(Markbridge::Parsers::BBCode::Handlers::UrlHandler)
+ end
+
+ it "yields nil when no handler was previously bound" do
+ seen = :unset
+ registry.overlay("brand-new-tag") do |previous|
+ seen = previous
+ Markbridge::Parsers::BBCode::Handlers::SimpleHandler.new(Markbridge::AST::Bold)
+ end
+
+ expect(seen).to be_nil
+ end
+
+ it "registers whatever the block returns" do
+ replacement =
+ Markbridge::Parsers::BBCode::Handlers::SimpleHandler.new(Markbridge::AST::Italic)
+
+ registry.overlay("url") { |_| replacement }
+
+ expect(registry["url"]).to be(replacement)
+ end
+
+ it "applies to every tag name in the array" do
+ replacement =
+ Markbridge::Parsers::BBCode::Handlers::SimpleHandler.new(Markbridge::AST::Italic)
+
+ registry.overlay(%w[url link iurl]) { |_| replacement }
+
+ expect(registry["url"]).to be(replacement)
+ expect(registry["link"]).to be(replacement)
+ expect(registry["iurl"]).to be(replacement)
+ end
+
+ it "yields each name's previously-bound handler when called with an Array" do
+ yielded = []
+ registry.overlay(%w[url link iurl]) do |previous|
+ yielded << previous
+ previous
+ end
+
+ expect(yielded.size).to eq(3)
+ yielded.each do |handler|
+ expect(handler).to be_a(Markbridge::Parsers::BBCode::Handlers::UrlHandler)
+ end
+ end
+
+ it "returns self for chaining" do
+ result = registry.overlay("url") { |p| p }
+ expect(result).to be(registry)
+ end
+ end
end
diff --git a/spec/unit/markbridge/parsers/bbcode/handlers/raw_handler_spec.rb b/spec/unit/markbridge/parsers/bbcode/handlers/raw_handler_spec.rb
index 4011942..3086340 100644
--- a/spec/unit/markbridge/parsers/bbcode/handlers/raw_handler_spec.rb
+++ b/spec/unit/markbridge/parsers/bbcode/handlers/raw_handler_spec.rb
@@ -250,4 +250,79 @@ def next_token
expect { handler.on_close(token:, context:, registry:) }.not_to raise_error
end
end
+
+ describe "with an AST class that does not accept language:" do
+ let(:bare_class) do
+ Class.new(Markbridge::AST::Element) do
+ def self.name
+ "BareElement"
+ end
+ end
+ end
+
+ let(:bare_handler) { described_class.new(bare_class) }
+
+ it "instantiates the AST class without passing language:" do
+ document = Markbridge::AST::Document.new
+ context = Markbridge::Parsers::BBCode::ParserState.new(document)
+ registry = Markbridge::Parsers::BBCode::HandlerRegistry.new
+
+ open_token =
+ Markbridge::Parsers::BBCode::TagStartToken.new(
+ tag: "bare",
+ # lang: "ruby" attr exists but the AST class doesn't accept
+ # language:, so the handler must not forward it.
+ attrs: {
+ lang: "ruby",
+ },
+ pos: 0,
+ source: "[bare lang=ruby]",
+ )
+ close_token =
+ Markbridge::Parsers::BBCode::TagEndToken.new(tag: "bare", pos: 6, source: "[/bare]")
+ scanner = MockScanner.new([close_token])
+
+ expect {
+ bare_handler.on_open(token: open_token, context:, registry:, tokens: scanner)
+ }.not_to raise_error
+
+ expect(document.children.first).to be_an_instance_of(bare_class)
+ end
+ end
+
+ describe "with an AST class that takes a non-:language kwarg" do
+ let(:other_class) do
+ Class.new(Markbridge::AST::Element) do
+ def initialize(other: nil)
+ super()
+ @other = other
+ end
+ end
+ end
+
+ it "does not pass the lang attr through (the AST class would raise on unknown :language)" do
+ handler = described_class.new(other_class)
+ document = Markbridge::AST::Document.new
+ context = Markbridge::Parsers::BBCode::ParserState.new(document)
+ registry = Markbridge::Parsers::BBCode::HandlerRegistry.new
+
+ open_token =
+ Markbridge::Parsers::BBCode::TagStartToken.new(
+ tag: "x",
+ attrs: {
+ lang: "ruby",
+ },
+ pos: 0,
+ source: "[x lang=ruby]",
+ )
+ close_token = Markbridge::Parsers::BBCode::TagEndToken.new(tag: "x", pos: 0, source: "[/x]")
+ scanner = MockScanner.new([close_token])
+
+ expect {
+ handler.on_open(token: open_token, context:, registry:, tokens: scanner)
+ }.not_to raise_error
+
+ expect(document.children.first).to be_an_instance_of(other_class)
+ end
+ end
end
diff --git a/spec/unit/markbridge/parsers/html/handler_registry_spec.rb b/spec/unit/markbridge/parsers/html/handler_registry_spec.rb
index 15a23bc..f7a322c 100644
--- a/spec/unit/markbridge/parsers/html/handler_registry_spec.rb
+++ b/spec/unit/markbridge/parsers/html/handler_registry_spec.rb
@@ -124,25 +124,21 @@
expect(registered).to be_a(Markbridge::Parsers::HTML::Handlers::SpanHandler)
end
- # br and hr are inline lambdas, not handler instances
- it "registers a lambda for that emits a LineBreak and returns nil" do
+ it "registers a SelfClosingHandler for that emits a LineBreak and returns nil" do
parent = Markbridge::AST::Paragraph.new
- result = default_registry["br"].call(element: nil, parent:)
+ result = default_registry["br"].process(element: nil, parent:)
- # Assert exactly one child of the right type — `all(be_a(...))`
- # passes vacuously on empty arrays, so mutations that drop the
- # `parent << AST::LineBreak.new` would slip through.
expect(parent.children.size).to eq(1)
expect(parent.children.first).to be_a(Markbridge::AST::LineBreak)
- # Not a HorizontalRule — kills cross-lambda `.new` swaps.
+ # Not a HorizontalRule — kills cross-handler element_class swaps.
expect(parent.children.first).not_to be_a(Markbridge::AST::HorizontalRule)
# Returns nil so the parser does NOT descend into children.
expect(result).to be_nil
end
- it "registers a lambda for that emits a HorizontalRule and returns nil" do
+ it "registers a SelfClosingHandler for that emits a HorizontalRule and returns nil" do
parent = Markbridge::AST::Paragraph.new
- result = default_registry["hr"].call(element: nil, parent:)
+ result = default_registry["hr"].process(element: nil, parent:)
expect(parent.children.size).to eq(1)
expect(parent.children.first).to be_a(Markbridge::AST::HorizontalRule)
@@ -173,4 +169,52 @@
expect(registry["b"]).to be_a(Markbridge::Parsers::HTML::Handlers::SimpleHandler)
end
end
+
+ describe "#overlay" do
+ let(:registry) { described_class.default }
+
+ it "yields the previously bound handler" do
+ seen = nil
+ registry.overlay("a") { |p| seen = p }
+ expect(seen).to be_a(Markbridge::Parsers::HTML::Handlers::UrlHandler)
+ end
+
+ it "yields nil for unbound names" do
+ seen = :unset
+ registry.overlay("never-seen") do |p|
+ seen = p
+ Markbridge::Parsers::HTML::Handlers::SimpleHandler.new(Markbridge::AST::Bold)
+ end
+ expect(seen).to be_nil
+ end
+
+ it "registers whatever the block returns" do
+ replacement = Markbridge::Parsers::HTML::Handlers::SimpleHandler.new(Markbridge::AST::Italic)
+ registry.overlay("a") { |_| replacement }
+ expect(registry["a"]).to be(replacement)
+ end
+
+ it "iterates over an Array of names" do
+ replacement = Markbridge::Parsers::HTML::Handlers::SimpleHandler.new(Markbridge::AST::Italic)
+ registry.overlay(%w[a b]) { |_| replacement }
+ expect(registry["a"]).to be(replacement)
+ expect(registry["b"]).to be(replacement)
+ end
+
+ it "yields each name's previously-bound handler when called with an Array" do
+ yielded = []
+ registry.overlay(%w[a img]) do |previous|
+ yielded << previous
+ previous
+ end
+
+ expect(yielded.size).to eq(2)
+ expect(yielded.first).to be_a(Markbridge::Parsers::HTML::Handlers::UrlHandler)
+ expect(yielded.last).to be_a(Markbridge::Parsers::HTML::Handlers::ImageHandler)
+ end
+
+ it "returns self for chaining" do
+ expect(registry.overlay("a") { |p| p }).to be(registry)
+ end
+ end
end
diff --git a/spec/unit/markbridge/parsers/html/handlers/self_closing_handler_spec.rb b/spec/unit/markbridge/parsers/html/handlers/self_closing_handler_spec.rb
new file mode 100644
index 0000000..e110bde
--- /dev/null
+++ b/spec/unit/markbridge/parsers/html/handlers/self_closing_handler_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+RSpec.describe Markbridge::Parsers::HTML::Handlers::SelfClosingHandler do
+ let(:parent) { Markbridge::AST::Paragraph.new }
+
+ describe "#initialize" do
+ it "exposes the element_class via reader" do
+ expect(described_class.new(Markbridge::AST::LineBreak).element_class).to eq(
+ Markbridge::AST::LineBreak,
+ )
+ end
+ end
+
+ describe "#process" do
+ it "appends a fresh instance of element_class to parent" do
+ handler = described_class.new(Markbridge::AST::LineBreak)
+
+ handler.process(element: nil, parent:)
+
+ expect(parent.children.size).to eq(1)
+ expect(parent.children.first).to be_a(Markbridge::AST::LineBreak)
+ end
+
+ it "returns nil so the parser does not recurse into children" do
+ handler = described_class.new(Markbridge::AST::LineBreak)
+
+ expect(handler.process(element: nil, parent:)).to be_nil
+ end
+
+ it "produces a fresh instance on every call (not a shared object)" do
+ handler = described_class.new(Markbridge::AST::HorizontalRule)
+
+ handler.process(element: nil, parent:)
+ handler.process(element: nil, parent:)
+
+ expect(parent.children.size).to eq(2)
+ expect(parent.children[0]).not_to equal(parent.children[1])
+ end
+
+ it "respects the configured element_class (HorizontalRule, not LineBreak)" do
+ handler = described_class.new(Markbridge::AST::HorizontalRule)
+
+ handler.process(element: nil, parent:)
+
+ expect(parent.children.first).to be_a(Markbridge::AST::HorizontalRule)
+ expect(parent.children.first).not_to be_a(Markbridge::AST::LineBreak)
+ end
+ end
+end
diff --git a/spec/unit/markbridge/parsers/html/handlers/span_handler_spec.rb b/spec/unit/markbridge/parsers/html/handlers/span_handler_spec.rb
index 50545ff..a139b49 100644
--- a/spec/unit/markbridge/parsers/html/handlers/span_handler_spec.rb
+++ b/spec/unit/markbridge/parsers/html/handlers/span_handler_spec.rb
@@ -229,7 +229,7 @@ def fragment(html)
'X',
)
- expect(result).to eq("[u]**X**[/u]")
+ expect(result.markdown).to eq("[u]**X**[/u]")
end
end
end
diff --git a/spec/unit/markbridge/parsers/media_wiki/inline_parser_spec.rb b/spec/unit/markbridge/parsers/media_wiki/inline_parser_spec.rb
index 0701c0e..b852661 100644
--- a/spec/unit/markbridge/parsers/media_wiki/inline_parser_spec.rb
+++ b/spec/unit/markbridge/parsers/media_wiki/inline_parser_spec.rb
@@ -469,7 +469,7 @@ def parse(text)
r.register("mark", :formatting, Markbridge::AST::Bold)
end
end
- let(:parser) { described_class.new(inline_tag_registry: registry) }
+ let(:parser) { described_class.new(handlers: registry) }
it "handles custom registered tags" do
doc = parse("highlighted")
@@ -556,7 +556,7 @@ def parse(text)
Markbridge::Parsers::MediaWiki::InlineTagRegistry.build_from_default do |r|
r.register("highlight", :formatting, Markbridge::AST::Bold)
end
- parser = described_class.new(inline_tag_registry: registry)
+ parser = described_class.new(handlers: registry)
parent = Markbridge::AST::Document.new
# Outer ''…'' wraps the content in Italic and recurses via
# parse_inner_content; the inner tag must still resolve
diff --git a/spec/unit/markbridge/parsers/media_wiki/parser_spec.rb b/spec/unit/markbridge/parsers/media_wiki/parser_spec.rb
index 2013b19..210d821 100644
--- a/spec/unit/markbridge/parsers/media_wiki/parser_spec.rb
+++ b/spec/unit/markbridge/parsers/media_wiki/parser_spec.rb
@@ -746,12 +746,12 @@ def parse_table(wikitext)
end
describe "constructor customization" do
- it "accepts a custom inline_tag_registry" do
+ it "accepts a custom handlers registry" do
registry =
Markbridge::Parsers::MediaWiki::InlineTagRegistry.build_from_default do |r|
r.register("mark", :formatting, Markbridge::AST::Bold)
end
- parser = described_class.new(inline_tag_registry: registry)
+ parser = described_class.new(handlers: registry)
doc = parser.parse("highlighted")
paragraph = doc.children.first
@@ -766,4 +766,43 @@ def parse_table(wikitext)
expect(paragraph.children.first).to be_a(Markbridge::AST::Bold)
end
end
+
+ describe "#parse" do
+ it "clears unknown_tags from the previous parse so a fresh call has a fresh tally" do
+ parser = described_class.new
+ parser.parse("x")
+ expect(parser.unknown_tags).to eq("neverknown" => 1)
+
+ parser.parse("hello")
+
+ expect(parser.unknown_tags).to eq({})
+ end
+
+ it "forwards the configured handler registry into the inline parser" do
+ registry =
+ Markbridge::Parsers::MediaWiki::InlineTagRegistry.build_from_default do |r|
+ r.register("highlight", :formatting, Markbridge::AST::Bold)
+ end
+
+ doc = described_class.new(handlers: registry).parse("x")
+ paragraph = doc.children.first
+
+ # The default registry doesn't know ; the custom one
+ # maps it to Bold. If parse dropped the handlers: kwarg the
+ # InlineParser would fall back to .default and the tag would
+ # survive as literal text.
+ expect(paragraph.children.first).to be_a(Markbridge::AST::Bold)
+ end
+
+ it "normalizes line endings before splitting into lines" do
+ # Use the Unicode line separator (U+2028). split("\n") does NOT
+ # split on it, so without the normalize step the input would
+ # collapse into a single line with a literal separator inside —
+ # producing one paragraph instead of three headings.
+ doc = described_class.new.parse("== H1 ==
== H2 ==
== H3 ==")
+
+ headings = doc.children.select { |c| c.is_a?(Markbridge::AST::Heading) }
+ expect(headings.size).to eq(3)
+ end
+ end
end
diff --git a/spec/unit/markbridge/parsers/text_formatter/handler_registry_spec.rb b/spec/unit/markbridge/parsers/text_formatter/handler_registry_spec.rb
index 4fe53ac..a29d15a 100644
--- a/spec/unit/markbridge/parsers/text_formatter/handler_registry_spec.rb
+++ b/spec/unit/markbridge/parsers/text_formatter/handler_registry_spec.rb
@@ -5,10 +5,13 @@
RSpec.describe Markbridge::Parsers::TextFormatter::HandlerRegistry do
let(:registry) { described_class.default }
let(:parent) { Markbridge::AST::Document.new }
+ let(:processor) do
+ instance_double(Markbridge::Parsers::TextFormatter::Parser, process_children: nil)
+ end
def process_and_get_node(xml_string)
xml = Nokogiri.XML(xml_string).root
- registry.process_element(xml, parent)
+ registry.process_element(xml, parent, processor)
parent.children.last
end
@@ -18,15 +21,17 @@ def process_and_get_node(xml_string)
xml = Nokogiri.XML("").root
fake_node = Markbridge::AST::Text.new("x")
registry.register("custom", handler)
- allow(handler).to receive(:process).with(element: xml, parent:).and_return(fake_node)
+ allow(handler).to receive(:process).with(element: xml, parent:, processor:).and_return(
+ fake_node,
+ )
- expect(registry.process_element(xml, parent)).to eq(fake_node)
+ expect(registry.process_element(xml, parent, processor)).to eq(fake_node)
end
it "returns nil when no handler is registered for the element name" do
xml = Nokogiri.XML("").root
- expect(registry.process_element(xml, parent)).to be_nil
+ expect(registry.process_element(xml, parent, processor)).to be_nil
end
context "with default handlers" do
@@ -88,7 +93,7 @@ def process_and_get_node(xml_string)
it "dispatches the asterisk element (non-XML name, registered directly) to a ListItem handler" do
element = instance_double(Nokogiri::XML::Element, name: "*")
- result = registry.process_element(element, parent)
+ result = registry.process_element(element, parent, processor)
expect(result).to be_a(Markbridge::AST::ListItem)
expect(parent.children.last).to eq(result)
@@ -173,9 +178,11 @@ def process_and_get_node(xml_string)
xml = Nokogiri.XML("").root
replacement = Markbridge::AST::Text.new("replaced")
registry.register("B", new_handler)
- allow(new_handler).to receive(:process).with(element: xml, parent:).and_return(replacement)
+ allow(new_handler).to receive(:process).with(element: xml, parent:, processor:).and_return(
+ replacement,
+ )
- expect(registry.process_element(xml, parent)).to eq(replacement)
+ expect(registry.process_element(xml, parent, processor)).to eq(replacement)
end
end
@@ -306,4 +313,58 @@ def handler_for(name)
expect(registry.has_handler?("B")).to be true
end
end
+
+ describe "#[]" do
+ it "returns the handler bound to an element name (case-insensitive)" do
+ registry = described_class.default
+ expect(registry["b"]).to be_a(Markbridge::Parsers::TextFormatter::Handlers::SimpleHandler)
+ expect(registry["B"]).to be(registry["b"])
+ end
+
+ it "returns nil when no handler is bound" do
+ expect(described_class.new["never-seen"]).to be_nil
+ end
+ end
+
+ describe "#overlay" do
+ let(:registry) { described_class.default }
+
+ it "yields the previously bound handler" do
+ seen = nil
+ registry.overlay("URL") { |p| seen = p }
+ expect(seen).to be_a(Markbridge::Parsers::TextFormatter::Handlers::UrlHandler)
+ end
+
+ it "yields nil for unbound names" do
+ seen = :unset
+ registry.overlay("NEVER-SEEN") do |p|
+ seen = p
+ Markbridge::Parsers::TextFormatter::Handlers::SimpleHandler.new(Markbridge::AST::Bold)
+ end
+ expect(seen).to be_nil
+ end
+
+ it "registers whatever the block returns" do
+ replacement =
+ Markbridge::Parsers::TextFormatter::Handlers::SimpleHandler.new(Markbridge::AST::Italic)
+
+ registry.overlay("URL") { |_| replacement }
+
+ expect(registry["URL"]).to be(replacement)
+ end
+
+ it "iterates over an Array of names" do
+ replacement =
+ Markbridge::Parsers::TextFormatter::Handlers::SimpleHandler.new(Markbridge::AST::Italic)
+
+ registry.overlay(%w[URL EMAIL]) { |_| replacement }
+
+ expect(registry["URL"]).to be(replacement)
+ expect(registry["EMAIL"]).to be(replacement)
+ end
+
+ it "returns self for chaining" do
+ expect(registry.overlay("URL") { |p| p }).to be(registry)
+ end
+ end
end
diff --git a/spec/unit/markbridge/parsers/text_formatter/parser_spec.rb b/spec/unit/markbridge/parsers/text_formatter/parser_spec.rb
index 6e6aa4d..f893f70 100644
--- a/spec/unit/markbridge/parsers/text_formatter/parser_spec.rb
+++ b/spec/unit/markbridge/parsers/text_formatter/parser_spec.rb
@@ -113,7 +113,7 @@
it "does not track a registered handler as unknown even when it returns nil" do
void_handler =
Class.new(Markbridge::Parsers::TextFormatter::Handlers::BaseHandler) do
- def process(element:, parent:)
+ def process(element:, parent:, processor: nil)
nil
end
end
@@ -166,4 +166,31 @@ def process(element:, parent:)
expect(parent.children.map(&:class)).to eq([Markbridge::AST::Text, Markbridge::AST::Bold])
end
end
+
+ describe "custom handlers that recurse manually" do
+ it "passes element:, parent:, processor: and lets a handler recurse via processor.process_children" do
+ wrap_handler =
+ Class.new(Markbridge::Parsers::TextFormatter::Handlers::BaseHandler) do
+ def initialize
+ @element_class = Markbridge::AST::Bold
+ end
+ attr_reader :element_class
+
+ def process(element:, parent:, processor:)
+ wrapper = Markbridge::AST::Bold.new
+ parent << wrapper
+ processor.process_children(element, wrapper)
+ nil # we recursed manually; don't double-process
+ end
+ end
+
+ parser = described_class.new { |r| r.register("WRAP", wrap_handler.new) }
+
+ doc = parser.parse("x")
+ wrap = doc.children.first
+
+ expect(wrap).to be_a(Markbridge::AST::Bold)
+ expect(wrap.children.first).to be_a(Markbridge::AST::Italic)
+ end
+ end
end
diff --git a/spec/unit/markbridge/renderers/discourse/identity_escaper_spec.rb b/spec/unit/markbridge/renderers/discourse/identity_escaper_spec.rb
new file mode 100644
index 0000000..6d25a32
--- /dev/null
+++ b/spec/unit/markbridge/renderers/discourse/identity_escaper_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+RSpec.describe Markbridge::Renderers::Discourse::IdentityEscaper do
+ let(:escaper) { described_class.new }
+
+ describe "#escape" do
+ it "returns the input unchanged" do
+ expect(escaper.escape("**hi** *star* `code` ")).to eq("**hi** *star* `code` ")
+ end
+
+ it "preserves whitespace and newlines verbatim" do
+ input = " leading\n middle\n trailing "
+ expect(escaper.escape(input)).to eq(input)
+ end
+
+ it "returns the same object when given a String (no allocation)" do
+ input = +"plain"
+ expect(escaper.escape(input)).to be(input)
+ end
+
+ it "returns an empty string for nil (parity with MarkdownEscaper#escape)" do
+ expect(escaper.escape(nil)).to eq("")
+ end
+ end
+
+ describe "as plumbed through Markbridge.discourse_renderer(escape: false)" do
+ let(:renderer) { Markbridge.discourse_renderer(escape: false) }
+
+ it "leaves Markdown-special characters in Text nodes untouched" do
+ result = renderer.render(Markbridge::AST::Text.new("a*b_c [d](e)"))
+
+ expect(result).to eq("a*b_c [d](e)")
+ end
+
+ it "leaves block-level constructs untouched (lists, headings, quotes)" do
+ result = renderer.render(Markbridge::AST::Text.new("# Heading\n- item\n1. ordered\n> quoted"))
+
+ expect(result).to eq("# Heading\n- item\n1. ordered\n> quoted")
+ end
+
+ it "is end-to-end usable through bbcode_to_markdown" do
+ result = Markbridge.bbcode_to_markdown("[b]hi[/b] *raw* `untouched`", renderer:)
+
+ # The Bold tag still wraps; the surrounding text is *not* escaped.
+ expect(result.markdown).to eq("**hi** *raw* `untouched`")
+ end
+ end
+
+ describe "discourse_renderer mutual-exclusion" do
+ it "raises when escape: false is combined with escape_hard_line_breaks: true" do
+ expect {
+ Markbridge.discourse_renderer(escape: false, escape_hard_line_breaks: true)
+ }.to raise_error(ArgumentError, /mutually exclusive/)
+ end
+
+ it "raises when escape: false is combined with allow:" do
+ expect { Markbridge.discourse_renderer(escape: false, allow: :lists) }.to raise_error(
+ ArgumentError,
+ /mutually exclusive/,
+ )
+ end
+
+ it "lets an explicit escaper: win even when escape: false is given" do
+ explicit = Markbridge::Renderers::Discourse::MarkdownEscaper.new
+ renderer = Markbridge.discourse_renderer(escaper: explicit, escape: false)
+
+ # The MarkdownEscaper still escapes, so `*` becomes `\*`.
+ result = renderer.render(Markbridge::AST::Text.new("a*b"))
+ expect(result).to eq('a\*b')
+ end
+ end
+end
diff --git a/spec/unit/markbridge/renderers/discourse/markdown_escaper/allow_spec.rb b/spec/unit/markbridge/renderers/discourse/markdown_escaper/allow_spec.rb
new file mode 100644
index 0000000..ace7d5a
--- /dev/null
+++ b/spec/unit/markbridge/renderers/discourse/markdown_escaper/allow_spec.rb
@@ -0,0 +1,234 @@
+# frozen_string_literal: true
+
+RSpec.describe Markbridge::Renderers::Discourse::MarkdownEscaper, "#initialize" do
+ describe "escape_hard_line_breaks:" do
+ it "defaults to false (preserves trailing-space hard breaks)" do
+ expect(described_class.new.escape("foo \nbar")).to eq("foo \nbar")
+ end
+
+ it "when true, strips trailing spaces before newlines" do
+ expect(described_class.new(escape_hard_line_breaks: true).escape("foo \nbar")).to eq(
+ "foo\nbar",
+ )
+ end
+ end
+ describe "default behavior (allow: nil)" do
+ let(:escaper) { described_class.new }
+
+ it "escapes the leading dash of a bullet list" do
+ expect(escaper.escape("- item")).to eq("\\- item")
+ end
+
+ it "escapes the leading plus of a bullet list" do
+ expect(escaper.escape("+ item")).to eq("\\+ item")
+ end
+
+ it "escapes the leading star of a bullet list" do
+ expect(escaper.escape("* item")).to eq("\\* item")
+ end
+
+ it "escapes the period of an ordered list" do
+ expect(escaper.escape("1. item")).to eq("1\\. item")
+ end
+
+ it "escapes the close-paren of an ordered list" do
+ expect(escaper.escape("1) item")).to eq("1\\) item")
+ end
+ end
+
+ describe "allow: :bullet_list" do
+ let(:escaper) { described_class.new(allow: :bullet_list) }
+
+ it "passes a `- item` line through unescaped" do
+ expect(escaper.escape("- item")).to eq("- item")
+ end
+
+ it "passes a `+ item` line through unescaped" do
+ expect(escaper.escape("+ item")).to eq("+ item")
+ end
+
+ it "passes a `* item` line through unescaped" do
+ expect(escaper.escape("* item")).to eq("* item")
+ end
+
+ it "still escapes ordered lists (only bullets allowed)" do
+ expect(escaper.escape("1. item")).to eq("1\\. item")
+ end
+
+ it "still escapes a thematic break of dashes" do
+ expect(escaper.escape("---")).to eq("\\-\\-\\-")
+ end
+
+ it "still escapes a thematic break of stars" do
+ expect(escaper.escape("***")).to eq("\\*\\*\\*")
+ end
+
+ it "still escapes a setext underline of dashes after a paragraph" do
+ expect(escaper.escape("paragraph\n---")).to eq("paragraph\n\\-\\-\\-")
+ end
+
+ it "still inline-escapes content after the bullet marker" do
+ # The leading "- " passes through, but inline `*emphasis*` markers
+ # inside the line still get escaped.
+ expect(escaper.escape("- a *star* mark")).to eq("- a \\*star\\* mark")
+ end
+ end
+
+ describe "allow: :ordered_list" do
+ let(:escaper) { described_class.new(allow: :ordered_list) }
+
+ it "passes a `1. item` line through unescaped" do
+ expect(escaper.escape("1. item")).to eq("1. item")
+ end
+
+ it "passes a `1) item` line through unescaped" do
+ expect(escaper.escape("1) item")).to eq("1) item")
+ end
+
+ it "passes large ordered numbers through unescaped" do
+ expect(escaper.escape("99. item")).to eq("99. item")
+ end
+
+ it "still escapes bullet lists (only ordered allowed)" do
+ expect(escaper.escape("- item")).to eq("\\- item")
+ end
+
+ it "still inline-escapes content after the marker" do
+ # `1.` passes through; an inline `*emphasis*` in the rest
+ # is still escaped.
+ expect(escaper.escape("1. a *star* mark")).to eq("1. a \\*star\\* mark")
+ end
+ end
+
+ describe "allow: :atx_heading" do
+ let(:escaper) { described_class.new(allow: :atx_heading) }
+
+ it "passes an h1 through unescaped" do
+ expect(escaper.escape("# Heading")).to eq("# Heading")
+ end
+
+ it "passes an h6 through unescaped" do
+ expect(escaper.escape("###### Heading")).to eq("###### Heading")
+ end
+
+ it "passes a 7-hash run through (CommonMark rejects 7+ hashes as a heading; not the kwarg's concern)" do
+ # ATX_HEADING is `\#{1,6}(?=[ \t]|$)` — 7 hashes do not match,
+ # so this never enters the allow-checked branch; behaviour is
+ # identical to the default escaper.
+ expect(escaper.escape("####### Heading")).to eq("####### Heading")
+ end
+
+ it "still inline-escapes content after the heading marker" do
+ expect(escaper.escape("## a *star* h2")).to eq("## a \\*star\\* h2")
+ end
+
+ it "passes a `# ` empty heading through unescaped" do
+ # Edge case from the plan: `# ` matches ATX_HEADING with empty
+ # content. With :atx_heading allowed, the marker passes verbatim;
+ # Discourse renders this as an empty .
+ expect(escaper.escape("# ")).to eq("# ")
+ end
+ end
+
+ describe "allow: :block_quote" do
+ let(:escaper) { described_class.new(allow: :block_quote) }
+
+ it "passes a `> quoted` line through unescaped" do
+ expect(escaper.escape("> quoted")).to eq("> quoted")
+ end
+
+ it "still inline-escapes content after the `>`" do
+ expect(escaper.escape("> a *star*")).to eq("> a \\*star\\*")
+ end
+ end
+
+ describe "allow: :lists (alias for both)" do
+ let(:escaper) { described_class.new(allow: :lists) }
+
+ it "passes bullet lists through unescaped" do
+ expect(escaper.escape("- item")).to eq("- item")
+ end
+
+ it "passes ordered lists through unescaped" do
+ expect(escaper.escape("1. item")).to eq("1. item")
+ end
+
+ it "still escapes thematic breaks" do
+ expect(escaper.escape("---")).to eq("\\-\\-\\-")
+ end
+ end
+
+ describe "allow: as an Array" do
+ it "accepts an Array of granular keys" do
+ escaper = described_class.new(allow: %i[bullet_list ordered_list])
+
+ expect(escaper.escape("- item")).to eq("- item")
+ expect(escaper.escape("1. item")).to eq("1. item")
+ end
+
+ it "accepts an Array containing aliases (expanded)" do
+ escaper = described_class.new(allow: [:lists])
+
+ expect(escaper.escape("- item")).to eq("- item")
+ expect(escaper.escape("1. item")).to eq("1. item")
+ end
+ end
+
+ describe "allow: with unknown keys" do
+ it "raises ArgumentError naming the unknown key and the recognised set" do
+ expect { described_class.new(allow: :headings) }.to raise_error(ArgumentError) do |error|
+ expect(error.message).to include("headings")
+ expect(error.message).to include("bullet_list")
+ expect(error.message).to include("ordered_list")
+ expect(error.message).to include("atx_heading")
+ expect(error.message).to include("block_quote")
+ expect(error.message).to include("lists")
+ end
+ end
+
+ it "raises when one element of an Array is unknown (others ignored)" do
+ expect { described_class.new(allow: %i[bullet_list typos]) }.to raise_error(
+ ArgumentError,
+ /typos/,
+ )
+ end
+ end
+
+ describe "interaction with thematic breaks and setext underlines" do
+ let(:escaper) { described_class.new(allow: :lists) }
+
+ it "still escapes a thematic break of dashes even with :bullet_list allowed" do
+ expect(escaper.escape("---")).to eq("\\-\\-\\-")
+ end
+
+ it "still escapes a setext-dash underline after a paragraph" do
+ expect(escaper.escape("paragraph\n---")).to eq("paragraph\n\\-\\-\\-")
+ end
+
+ it "still escapes a thematic break of stars" do
+ expect(escaper.escape("***")).to eq("\\*\\*\\*")
+ end
+ end
+
+ describe "as plumbed through Markbridge.discourse_renderer" do
+ it "forwards :lists to the constructed escaper" do
+ renderer = Markbridge.discourse_renderer(allow: :lists)
+ input = "- item"
+
+ # The default postprocessor strips trailing whitespace; the
+ # bullet line itself passes through unescaped.
+ result = renderer.render(Markbridge::AST::Text.new(input))
+ expect(result).to eq("- item")
+ end
+
+ it "is ignored when an explicit escaper: is supplied" do
+ explicit = described_class.new # no allow
+ renderer = Markbridge.discourse_renderer(escaper: explicit, allow: :lists)
+
+ # The factory must not override an explicit escaper — the user's
+ # instance wins.
+ result = renderer.render(Markbridge::AST::Text.new("- item"))
+ expect(result).to eq("\\- item")
+ end
+ end
+end
diff --git a/spec/unit/markbridge/renderers/discourse/postprocessor_spec.rb b/spec/unit/markbridge/renderers/discourse/postprocessor_spec.rb
new file mode 100644
index 0000000..446334a
--- /dev/null
+++ b/spec/unit/markbridge/renderers/discourse/postprocessor_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+RSpec.describe Markbridge::Renderers::Discourse::Postprocessor do
+ let(:postprocessor) { described_class.new }
+
+ describe "#call" do
+ it "collapses runs of three or more newlines to exactly two" do
+ expect(postprocessor.call("a\n\n\n\nb")).to eq("a\n\nb")
+ end
+
+ it "collapses every run of 3+ newlines, not just the first" do
+ # Two distinct runs — `sub` would only catch the first.
+ expect(postprocessor.call("a\n\n\nb\n\n\nc")).to eq("a\n\nb\n\nc")
+ end
+
+ it "removes whitespace-only lines (preserving multiple of them)" do
+ expect(postprocessor.call("a\n \nb\n\t\nc")).to eq("a\n\nb\n\nc")
+ end
+
+ it "strips leading and trailing whitespace from the document" do
+ expect(postprocessor.call(" hi ")).to eq("hi")
+ end
+
+ it "leaves a single blank line between paragraphs alone" do
+ expect(postprocessor.call("a\n\nb")).to eq("a\n\nb")
+ end
+ end
+
+ describe "DEFAULT" do
+ it "is a Postprocessor instance" do
+ expect(described_class::DEFAULT).to be_a(described_class)
+ end
+
+ it "behaves like a fresh instance" do
+ expect(described_class::DEFAULT.call("a\n\n\nb")).to eq("a\n\nb")
+ end
+ end
+
+ describe "as a Renderer dependency" do
+ it "is invoked by Markbridge.bbcode_to_markdown via the renderer" do
+ custom =
+ Class.new(described_class) do
+ def call(text)
+ "PROCESSED:#{text.strip}"
+ end
+ end
+
+ renderer = Markbridge.discourse_renderer(postprocessor: custom.new)
+
+ expect(Markbridge.bbcode_to_markdown("[b]hi[/b]", renderer:).markdown).to eq(
+ "PROCESSED:**hi**",
+ )
+ end
+ end
+end
diff --git a/spec/unit/markbridge/renderers/discourse/renderer_spec.rb b/spec/unit/markbridge/renderers/discourse/renderer_spec.rb
index 3fb5d93..04c3610 100644
--- a/spec/unit/markbridge/renderers/discourse/renderer_spec.rb
+++ b/spec/unit/markbridge/renderers/discourse/renderer_spec.rb
@@ -42,6 +42,18 @@
expect(result).to eq('a\*b')
end
+ it "falls back to Postprocessor::DEFAULT when no postprocessor is provided" do
+ expect(described_class.new.postprocessor).to be(
+ Markbridge::Renderers::Discourse::Postprocessor::DEFAULT,
+ )
+ end
+
+ it "uses an explicit postprocessor when one is provided" do
+ custom = Markbridge::Renderers::Discourse::Postprocessor.new
+
+ expect(described_class.new(postprocessor: custom).postprocessor).to be(custom)
+ end
+
it "uses an explicit html_escaper when one is provided" do
html_escaper = class_double(Markbridge::Renderers::Discourse::HtmlEscaper)
allow(html_escaper).to receive(:escape).and_return("HTML-ESCAPED")
diff --git a/spec/unit/markbridge/renderers/discourse/tag_library_spec.rb b/spec/unit/markbridge/renderers/discourse/tag_library_spec.rb
index 747311a..a3bfecb 100644
--- a/spec/unit/markbridge/renderers/discourse/tag_library_spec.rb
+++ b/spec/unit/markbridge/renderers/discourse/tag_library_spec.rb
@@ -171,4 +171,57 @@
end
end
end
+
+ describe "#unregister" do
+ it "removes a previously registered binding" do
+ tag = Markbridge::Renderers::Discourse::Tag.new { |_, _| "x" }
+ library.register(Markbridge::AST::Bold, tag)
+
+ library.unregister(Markbridge::AST::Bold)
+
+ expect(library[Markbridge::AST::Bold]).to be_nil
+ end
+
+ it "is a no-op when the class was never registered" do
+ expect { library.unregister(Markbridge::AST::Bold) }.not_to raise_error
+ end
+
+ it "returns self for chaining" do
+ expect(library.unregister(Markbridge::AST::Bold)).to be(library)
+ end
+ end
+
+ describe "#merge" do
+ let(:bold_tag) { Markbridge::Renderers::Discourse::Tag.new { |_, _| "b" } }
+ let(:italic_tag) { Markbridge::Renderers::Discourse::Tag.new { |_, _| "i" } }
+
+ it "registers each non-nil mapping" do
+ library.merge(Markbridge::AST::Bold => bold_tag, Markbridge::AST::Italic => italic_tag)
+
+ expect(library[Markbridge::AST::Bold]).to be(bold_tag)
+ expect(library[Markbridge::AST::Italic]).to be(italic_tag)
+ end
+
+ it "unregisters classes with a nil value" do
+ library.register(Markbridge::AST::Bold, bold_tag)
+
+ library.merge(Markbridge::AST::Bold => nil)
+
+ expect(library[Markbridge::AST::Bold]).to be_nil
+ end
+
+ it "removes the class from iteration when given a nil value (vs. registering nil)" do
+ library.register(Markbridge::AST::Bold, bold_tag)
+
+ library.merge(Markbridge::AST::Bold => nil)
+
+ # Iteration must reflect deletion — registering `nil` would leave the
+ # class as a key with a nil value.
+ expect(library.map { |klass, _| klass }).not_to include(Markbridge::AST::Bold)
+ end
+
+ it "returns self for chaining" do
+ expect(library.merge({})).to be(library)
+ end
+ end
end
diff --git a/spec/unit/markbridge/renderers/discourse/tags/table_tag_spec.rb b/spec/unit/markbridge/renderers/discourse/tags/table_tag_spec.rb
index d1b1417..8488aa3 100644
--- a/spec/unit/markbridge/renderers/discourse/tags/table_tag_spec.rb
+++ b/spec/unit/markbridge/renderers/discourse/tags/table_tag_spec.rb
@@ -645,4 +645,17 @@ def build_uneven_table_with(child_in_first_cell)
expect(result).to include("| x | ")
end
end
+
+ describe "with rows-less children" do
+ it "returns the empty string for a table whose children are all non-Row" do
+ # The render method's empty_table? predicate must check that no
+ # child is an AST::TableRow specifically — not just that the
+ # children list is non-empty. A bare Text child is not a row,
+ # so the table is effectively empty.
+ table = Markbridge::AST::Table.new
+ table << Markbridge::AST::Text.new("stray")
+
+ expect(tag.render(table, interface)).to eq("")
+ end
+ end
end
|