diff --git a/lib/markbridge/ast.rb b/lib/markbridge/ast.rb index a0a3cb1..42a7a49 100644 --- a/lib/markbridge/ast.rb +++ b/lib/markbridge/ast.rb @@ -25,6 +25,7 @@ require_relative "ast/strikethrough" require_relative "ast/subscript" require_relative "ast/superscript" +require_relative "ast/table" require_relative "ast/text" require_relative "ast/markdown_text" require_relative "ast/underline" diff --git a/lib/markbridge/ast/table.rb b/lib/markbridge/ast/table.rb new file mode 100644 index 0000000..2ea3587 --- /dev/null +++ b/lib/markbridge/ast/table.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Markbridge + module AST + class Table < Element + end + + class TableRow < Element + end + + class TableCell < Element + end + end +end diff --git a/lib/markbridge/parsers/bbcode.rb b/lib/markbridge/parsers/bbcode.rb index 7216687..14b9b71 100644 --- a/lib/markbridge/parsers/bbcode.rb +++ b/lib/markbridge/parsers/bbcode.rb @@ -28,6 +28,7 @@ require_relative "bbcode/handlers/color_handler" require_relative "bbcode/handlers/email_handler" require_relative "bbcode/handlers/image_handler" +require_relative "bbcode/handlers/img2_handler" require_relative "bbcode/handlers/list_handler" require_relative "bbcode/handlers/list_item_handler" require_relative "bbcode/handlers/quote_handler" diff --git a/lib/markbridge/parsers/bbcode/handler_registry.rb b/lib/markbridge/parsers/bbcode/handler_registry.rb index 56e0c25..29c5502 100644 --- a/lib/markbridge/parsers/bbcode/handler_registry.rb +++ b/lib/markbridge/parsers/bbcode/handler_registry.rb @@ -100,6 +100,9 @@ def self.default(closing_strategy: nil) # Image handler registry.register("img", Handlers::ImageHandler.new) + # IMG2 handler (vBulletin 5 enhanced image) + registry.register("img2", Handlers::Img2Handler.new) + # Attachment handler registry.register(%w[attach attachment], Handlers::AttachmentHandler.new) @@ -132,6 +135,14 @@ def self.default(closing_strategy: nil) registry.register(%w[list ul ol ulist olist], Handlers::ListHandler.new) registry.register(%w[* li .], Handlers::ListItemHandler.new) + # Table handlers (passthrough: strip wrapper tags, preserve cell content) + registry.register("table", Handlers::SimpleHandler.new(AST::Table, auto_closeable: true)) + registry.register("tr", Handlers::SimpleHandler.new(AST::TableRow, auto_closeable: true)) + registry.register( + %w[td th], + Handlers::SimpleHandler.new(AST::TableCell, auto_closeable: true), + ) + # Set the closing strategy registry.closing_strategy = closing_strategy || default_closing_strategy(registry) diff --git a/lib/markbridge/parsers/bbcode/handlers/img2_handler.rb b/lib/markbridge/parsers/bbcode/handlers/img2_handler.rb new file mode 100644 index 0000000..9857be1 --- /dev/null +++ b/lib/markbridge/parsers/bbcode/handlers/img2_handler.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Markbridge + module Parsers + module BBCode + module Handlers + # Handler for vBulletin 5 [IMG2] tags. + # + # Supports: + # - [IMG2=JSON]{"src":"http://example.com/image.png",...}[/IMG2] + # - [IMG2]http://example.com/image.png[/IMG2] + class Img2Handler < BaseHandler + def initialize(collector: RawContentCollector.new) + @collector = collector + @element_class = AST::Image + end + + def on_open(token:, context:, registry:, tokens: nil) + content = collect_content(token:, tokens:) + return unless content + + src = extract_src(token, content.strip) + return if src.nil? || src.empty? + + context.add_child(AST::Image.new(src:)) + end + + def on_close(token:, context:, registry:, tokens: nil) + context.add_child(AST::Text.new(token.source)) + end + + attr_reader :element_class + + private + + def collect_content(token:, tokens:) + return unless tokens + return unless closing_tag_ahead?(token.tag, tokens) + + @collector.collect(token.tag, tokens).content + end + + def closing_tag_ahead?(tag, tokens) + tokens.peek_ahead(100).any? { |t| t.is_a?(TagEndToken) && t.tag == tag } + end + + def extract_src(token, content) + if token.attrs[:option]&.downcase == "json" && content.start_with?("{") + $1 if content =~ /"src"\s*:\s*"([^"]+)"/ + else + content + end + end + end + end + end + end +end diff --git a/lib/markbridge/renderers/discourse/tag_library.rb b/lib/markbridge/renderers/discourse/tag_library.rb index f716eaf..61da6df 100644 --- a/lib/markbridge/renderers/discourse/tag_library.rb +++ b/lib/markbridge/renderers/discourse/tag_library.rb @@ -59,6 +59,15 @@ def self.default library.register AST::LineBreak, Tags::LineBreakTag.new library.register AST::HorizontalRule, Tags::HorizontalRuleTag.new + # Table tags: passthrough — strip wrapper tags, preserve cell content + passthrough = + Tag.new do |element, interface| + interface.render_children(element, context: interface.with_parent(element)) + end + library.register AST::Table, passthrough + library.register AST::TableRow, passthrough + library.register AST::TableCell, passthrough + library end end diff --git a/lib/markbridge/renderers/discourse/tags/quote_tag.rb b/lib/markbridge/renderers/discourse/tags/quote_tag.rb index de1c568..b490ddc 100644 --- a/lib/markbridge/renderers/discourse/tags/quote_tag.rb +++ b/lib/markbridge/renderers/discourse/tags/quote_tag.rb @@ -15,13 +15,12 @@ def render(element, interface) # Format: [quote="username, post:123, topic:456"]content[/quote] if element.post && element.topic && element.username # Full Discourse quote with context - "[quote=\"#{element.username}, post:#{element.post}, topic:#{element.topic}\"]\n#{content}\n[/quote]" + "[quote=\"#{element.username}, post:#{element.post}, topic:#{element.topic}\"]\n#{content}\n[/quote]\n\n" elsif element.author # Quote with author attribution only - "[quote=\"#{element.author}\"]\n#{content}\n[/quote]" + "[quote=\"#{element.author}\"]\n#{content}\n[/quote]\n\n" else - # Plain quote - could use Markdown blockquote or BBCode - # Using Markdown blockquote for plain quotes + # Plain Markdown blockquote — no trailing \n\n needed; surrounding paragraph context handles spacing content.split("\n").map { |line| "> #{line}" }.join("\n") end end diff --git a/playground/ast_presenter.rb b/playground/ast_presenter.rb index 06c69a7..184353a 100644 --- a/playground/ast_presenter.rb +++ b/playground/ast_presenter.rb @@ -30,6 +30,9 @@ class ASTPresenter "Subscript" => "formatting", "Superscript" => "formatting", "Text" => "text", + "Table" => "block", + "TableRow" => "block", + "TableCell" => "block", "Underline" => "formatting", "Upload" => "media", "Url" => "link", @@ -62,6 +65,9 @@ class ASTPresenter "Subscript" => "subscript", "Superscript" => "superscript", "Text" => "textCursor", + "Table" => "table", + "TableRow" => "tableRow", + "TableCell" => "tableCell", "Underline" => "underline", "Upload" => "upload", "Url" => "link", diff --git a/spec/system/bbcode_to_markdown_spec.rb b/spec/system/bbcode_to_markdown_spec.rb index 1bf6f88..1f8251b 100644 --- a/spec/system/bbcode_to_markdown_spec.rb +++ b/spec/system/bbcode_to_markdown_spec.rb @@ -508,4 +508,36 @@ expect(result).to eq(expected) end end + + describe "table tags" do + it "strips [TABLE][TR][TD] wrapper tags and preserves cell content" do + bbcode = "[TABLE][TR][TD]cell content[/TD][/TR][/TABLE]" + expect(Markbridge.bbcode_to_markdown(bbcode)).to eq("cell content") + end + + it "preserves content from multiple cells" do + bbcode = "[TABLE][TR][TD]first[/TD][TD]second[/TD][/TR][/TABLE]" + expect(Markbridge.bbcode_to_markdown(bbcode)).to eq("firstsecond") + end + + it "strips [TH] header cells and preserves content" do + bbcode = "[TABLE][TR][TH]Header[/TH][/TR][TR][TD]value[/TD][/TR][/TABLE]" + expect(Markbridge.bbcode_to_markdown(bbcode)).to eq("Headervalue") + end + + it "preserves formatted content inside table cells" do + bbcode = "[TABLE][TR][TD][B]bold cell[/B][/TD][/TR][/TABLE]" + expect(Markbridge.bbcode_to_markdown(bbcode)).to eq("**bold cell**") + end + + it "handles empty cells" do + bbcode = "[TABLE][TR][TD][/TD][/TR][/TABLE]" + expect(Markbridge.bbcode_to_markdown(bbcode)).to eq("") + end + + it "handles whitespace-only cells" do + bbcode = "[TABLE][TR][TD] [/TD][/TR][/TABLE]" + expect(Markbridge.bbcode_to_markdown(bbcode)).to eq("") + end + end end diff --git a/spec/unit/markbridge/ast/table_spec.rb b/spec/unit/markbridge/ast/table_spec.rb new file mode 100644 index 0000000..b1bf686 --- /dev/null +++ b/spec/unit/markbridge/ast/table_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe Markbridge::AST::Table do + it "is an Element" do + expect(described_class.new).to be_a(Markbridge::AST::Element) + end + + it "can have children" do + table = described_class.new + table << Markbridge::AST::TableRow.new + expect(table.children.size).to eq(1) + end +end + +RSpec.describe Markbridge::AST::TableRow do + it "is an Element" do + expect(described_class.new).to be_a(Markbridge::AST::Element) + end + + it "can have children" do + row = described_class.new + row << Markbridge::AST::TableCell.new + expect(row.children.size).to eq(1) + end +end + +RSpec.describe Markbridge::AST::TableCell do + it "is an Element" do + expect(described_class.new).to be_a(Markbridge::AST::Element) + end + + it "can have children" do + cell = described_class.new + cell << Markbridge::AST::Text.new("content") + expect(cell.children.size).to eq(1) + end +end diff --git a/spec/unit/markbridge/parsers/bbcode/handlers/img2_handler_spec.rb b/spec/unit/markbridge/parsers/bbcode/handlers/img2_handler_spec.rb new file mode 100644 index 0000000..872c79c --- /dev/null +++ b/spec/unit/markbridge/parsers/bbcode/handlers/img2_handler_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +RSpec.describe Markbridge::Parsers::BBCode::Handlers::Img2Handler do + let(:handler) { described_class.new } + let(:document) { Markbridge::AST::Document.new } + let(:registry) { Markbridge::Parsers::BBCode::HandlerRegistry.new } + let(:context) { Markbridge::Parsers::BBCode::ParserState.new(document) } + + class MockScanner + def initialize(tokens) + @tokens = tokens + @index = 0 + end + + def next_token + return nil if @index >= @tokens.length + + token = @tokens[@index] + @index += 1 + token + end + end + + def make_scanner(tokens) + Markbridge::Parsers::BBCode::PeekableEnumerator.new(MockScanner.new(tokens)) + end + + def text_token(text, pos: 0) + Markbridge::Parsers::BBCode::TextToken.new(text:, pos:) + end + + def start_token(tag: "img2", attrs: {}, source: "[IMG2]", pos: 0) + Markbridge::Parsers::BBCode::TagStartToken.new(tag:, attrs:, pos:, source:) + end + + def end_token(tag: "img2", source: "[/IMG2]", pos: 0) + Markbridge::Parsers::BBCode::TagEndToken.new(tag:, pos:, source:) + end + + describe "#on_open" do + it "extracts src from JSON body with HTTP URL" do + token = start_token(attrs: { option: "JSON" }, source: "[IMG2=JSON]") + json = '{"data-align":"none","data-size":"full","src":"http://example.com/image.png"}' + scanner = make_scanner([text_token(json), end_token]) + + handler.on_open(token:, context:, registry:, tokens: scanner) + + image = document.children.first + expect(image).to be_a(Markbridge::AST::Image) + expect(image.src).to eq("http://example.com/image.png") + end + + it "extracts src from JSON body with HTTPS URL" do + token = start_token(attrs: { option: "JSON" }, source: "[IMG2=JSON]") + json = '{"data-align":"none","src":"https://forums.example.com/core/image.jpg"}' + scanner = make_scanner([text_token(json), end_token]) + + handler.on_open(token:, context:, registry:, tokens: scanner) + + image = document.children.first + expect(image).to be_a(Markbridge::AST::Image) + expect(image.src).to eq("https://forums.example.com/core/image.jpg") + end + + it "produces nothing when JSON body has no src field" do + token = start_token(attrs: { option: "JSON" }, source: "[IMG2=JSON]") + json = '{"data-align":"none","data-size":"full"}' + scanner = make_scanner([text_token(json), end_token]) + + handler.on_open(token:, context:, registry:, tokens: scanner) + + expect(document.children).to be_empty + end + + it "produces nothing when JSON src is empty" do + token = start_token(attrs: { option: "JSON" }, source: "[IMG2=JSON]") + json = '{"src":""}' + scanner = make_scanner([text_token(json), end_token]) + + handler.on_open(token:, context:, registry:, tokens: scanner) + + expect(document.children).to be_empty + end + + it "handles bare [IMG2]url[/IMG2] without JSON option" do + token = start_token(attrs: {}, source: "[IMG2]") + scanner = make_scanner([text_token("https://example.com/photo.jpg"), end_token]) + + handler.on_open(token:, context:, registry:, tokens: scanner) + + image = document.children.first + expect(image).to be_a(Markbridge::AST::Image) + expect(image.src).to eq("https://example.com/photo.jpg") + end + + it "handles case-insensitive JSON option" do + token = start_token(attrs: { option: "json" }, source: "[IMG2=json]") + json = '{"src":"http://example.com/img.png"}' + scanner = make_scanner([text_token(json), end_token]) + + handler.on_open(token:, context:, registry:, tokens: scanner) + + image = document.children.first + expect(image).to be_a(Markbridge::AST::Image) + expect(image.src).to eq("http://example.com/img.png") + end + + it "produces nothing when there is no closing tag" do + token = start_token(attrs: { option: "JSON" }, source: "[IMG2=JSON]") + scanner = make_scanner([text_token("some content")]) + + handler.on_open(token:, context:, registry:, tokens: scanner) + + expect(document.children).to be_empty + end + + it "handles JSON with escaped slashes in src" do + token = start_token(attrs: { option: "JSON" }, source: "[IMG2=JSON]") + json = '{"src":"http:\/\/example.com\/path\/image.png"}' + scanner = make_scanner([text_token(json), end_token]) + + handler.on_open(token:, context:, registry:, tokens: scanner) + + image = document.children.first + expect(image).to be_a(Markbridge::AST::Image) + expect(image.src).to eq("http:\\/\\/example.com\\/path\\/image.png") + end + end + + describe "#on_close" do + it "emits orphaned close tag as text" do + token = end_token(source: "[/IMG2]") + + handler.on_close(token:, context:, registry:) + + text = document.children.first + expect(text).to be_a(Markbridge::AST::Text) + expect(text.text).to eq("[/IMG2]") + end + end + + describe "end-to-end via Markbridge.bbcode_to_markdown" do + it "converts [IMG2=JSON] with HTTP src to image markdown" do + input = + '[IMG2=JSON]{"data-align":"none","data-size":"full","src":"http://example.com/image.png"}[/IMG2]' + result = Markbridge.bbcode_to_markdown(input) + expect(result).to eq("![](http://example.com/image.png)") + end + + it "converts bare [IMG2] to image markdown" do + input = "[IMG2]http://example.com/photo.jpg[/IMG2]" + result = Markbridge.bbcode_to_markdown(input) + expect(result).to eq("![](http://example.com/photo.jpg)") + end + + it "strips [IMG2=JSON] with no src" do + input = 'Hello [IMG2=JSON]{"data-align":"none"}[/IMG2] world' + result = Markbridge.bbcode_to_markdown(input) + expect(result).to eq("Hello world") + end + + it "converts [IMG2=JSON] inline with surrounding text" do + input = 'Before [IMG2=JSON]{"src":"http://example.com/img.png"}[/IMG2] after' + result = Markbridge.bbcode_to_markdown(input) + expect(result).to eq("Before ![](http://example.com/img.png) after") + end + end +end diff --git a/spec/unit/markbridge/renderers/discourse/tag_library_spec.rb b/spec/unit/markbridge/renderers/discourse/tag_library_spec.rb index 8e0f743..f815871 100644 --- a/spec/unit/markbridge/renderers/discourse/tag_library_spec.rb +++ b/spec/unit/markbridge/renderers/discourse/tag_library_spec.rb @@ -81,6 +81,18 @@ expect(default_library[Markbridge::AST::HorizontalRule]).not_to be_nil end + it "registers Table tag" do + expect(default_library[Markbridge::AST::Table]).not_to be_nil + end + + it "registers TableRow tag" do + expect(default_library[Markbridge::AST::TableRow]).not_to be_nil + end + + it "registers TableCell tag" do + expect(default_library[Markbridge::AST::TableCell]).not_to be_nil + end + it "renders bold correctly" do bold = Markbridge::AST::Bold.new bold << Markbridge::AST::Text.new("text") diff --git a/spec/unit/markbridge/renderers/discourse/tags/quote_tag_spec.rb b/spec/unit/markbridge/renderers/discourse/tags/quote_tag_spec.rb index 4c9806a..92a104a 100644 --- a/spec/unit/markbridge/renderers/discourse/tags/quote_tag_spec.rb +++ b/spec/unit/markbridge/renderers/discourse/tags/quote_tag_spec.rb @@ -20,7 +20,7 @@ element << Markbridge::AST::Text.new("This is a quote") result = tag.render(element, interface) - expect(result).to eq("[quote=\"John\"]\nThis is a quote\n[/quote]") + expect(result).to eq("[quote=\"John\"]\nThis is a quote\n[/quote]\n\n") end it "renders quote with full Discourse context" do @@ -28,7 +28,7 @@ element << Markbridge::AST::Text.new("This is a quote") result = tag.render(element, interface) - expect(result).to eq("[quote=\"john, post:123, topic:456\"]\nThis is a quote\n[/quote]") + expect(result).to eq("[quote=\"john, post:123, topic:456\"]\nThis is a quote\n[/quote]\n\n") end it "renders multi-line plain quote with blockquote syntax" do