From c98ca3776014adb674856d0c838f2952bf3a0f57 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Sat, 25 Jul 2026 21:53:29 +0200 Subject: [PATCH 1/3] FEATURE: Add HTML heading parsing and the shared html_mode example The default HTML handler registry had no handler for h1-h6, so headings from HTML input lost their structure and only their text survived. The new HeadingHandler stores the level in the existing AST::Heading node; the renderer side already worked through the auto-registered HeadingTag. The html_mode contract check also ships with the gem now: require "markbridge/rspec" provides the shared example "an html_mode safe tag", backed by the new Renderers::Discourse::HtmlBlockSafety module. Our own contract spec runs through the shared example, so the shipped check is exercised by the same suite that guards the built-in tags. --- AGENTS.md | 5 +- docs/extending.md | 33 ++++++++++ docs/parsers/html.md | 1 + lib/markbridge/parsers/html.rb | 1 + .../parsers/html/handler_registry.rb | 1 + .../parsers/html/handlers/heading_handler.rb | 29 +++++++++ lib/markbridge/renderers/discourse.rb | 1 + .../renderers/discourse/html_block_safety.rb | 31 ++++++++++ lib/markbridge/rspec.rb | 46 ++++++++++++++ .../discourse/html_mode_contract_spec.rb | 31 +++------- spec/system/html_to_markdown_spec.rb | 24 ++++++++ .../parsers/html/handler_registry_spec.rb | 6 ++ .../html/handlers/heading_handler_spec.rb | 50 +++++++++++++++ .../discourse/html_block_safety_spec.rb | 61 +++++++++++++++++++ 14 files changed, 298 insertions(+), 22 deletions(-) create mode 100644 lib/markbridge/parsers/html/handlers/heading_handler.rb create mode 100644 lib/markbridge/renderers/discourse/html_block_safety.rb create mode 100644 lib/markbridge/rspec.rb create mode 100644 spec/unit/markbridge/parsers/html/handlers/heading_handler_spec.rb create mode 100644 spec/unit/markbridge/renderers/discourse/html_block_safety_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 2fa39207..879969b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,7 +143,10 @@ except across blank lines. Every tag must pick one of two forms when `spec/integration/markbridge/renderers/discourse/html_mode_contract_spec.rb` enforces this structurally: every registered tag is rendered in -`html_mode` and the output is checked for raw Markdown sigils. +`html_mode` and the output is checked for raw Markdown sigils. The +check ships as a shared RSpec example (`require "markbridge/rspec"`, +then `it_behaves_like "an html_mode safe tag"`), so consumer projects +can run it against their own tags. **See `examples/` for complete examples.** diff --git a/docs/extending.md b/docs/extending.md index 010c3de4..5af2b255 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -466,6 +466,39 @@ library.register(AST::Spoiler) do |element, interface| end ``` +### Testing Custom Tags Against the html_mode Contract + +Every tag must also render sensibly when `interface.html_mode?` is +true — inside a CommonMark HTML block (for example the table HTML +fallback), raw Markdown output would show up as literal text. A tag +has two valid forms there: a raw HTML fragment, or its normal Markdown +wrapped in `\n\n…\n\n` (a Markdown island; the blank lines let +CommonMark parse the inner content). + +Markbridge ships this check as a shared RSpec example. Require it from +your spec setup and give it your tag and a sample element: + +```ruby +require "markbridge/rspec" + +RSpec.describe MyQuoteTag do + it_behaves_like "an html_mode safe tag" do + let(:tag) { described_class.new } + let(:element) do + element = MyQuote.new + element << Markbridge::AST::Text.new("body *with* sigils") + element + end + end +end +``` + +Give the element children whose text contains Markdown sigils — a tag +that passes them through unprotected is then caught. Markbridge runs +its own tags through the same example. When the tag needs a customized +renderer to resolve its children, override `markbridge_renderer` with +your configured renderer inside the block. + ## Plugin Pattern Create reusable plugins that bundle parser and renderer extensions: diff --git a/docs/parsers/html.md b/docs/parsers/html.md index 5e752b29..4a7b3098 100644 --- a/docs/parsers/html.md +++ b/docs/parsers/html.md @@ -89,6 +89,7 @@ end | HTML Tag | AST Node | Notes | |----------|----------|-------| | `

` | Paragraph handling | Adds spacing between paragraphs | +| `

` – `

` | `AST::Heading` | Heading with matching level | | `
` | `AST::LineBreak` | Line break | | `
` | `AST::HorizontalRule` | Horizontal rule | diff --git a/lib/markbridge/parsers/html.rb b/lib/markbridge/parsers/html.rb index 27394ba7..040d8f3a 100644 --- a/lib/markbridge/parsers/html.rb +++ b/lib/markbridge/parsers/html.rb @@ -17,6 +17,7 @@ require_relative "html/handlers/list_handler" require_relative "html/handlers/list_item_handler" require_relative "html/handlers/quote_handler" +require_relative "html/handlers/heading_handler" require_relative "html/handlers/paragraph_handler" require_relative "html/handlers/table_handler" require_relative "html/handlers/table_row_handler" diff --git a/lib/markbridge/parsers/html/handler_registry.rb b/lib/markbridge/parsers/html/handler_registry.rb index dbcc26a1..23c505a8 100644 --- a/lib/markbridge/parsers/html/handler_registry.rb +++ b/lib/markbridge/parsers/html/handler_registry.rb @@ -132,6 +132,7 @@ def self.default registry.register("a", Handlers::UrlHandler.new) registry.register("img", Handlers::ImageHandler.new) registry.register("blockquote", Handlers::QuoteHandler.new) + registry.register(%w[h1 h2 h3 h4 h5 h6], Handlers::HeadingHandler.new) registry.register("br", Handlers::SelfClosingHandler.new(AST::LineBreak)) registry.register("hr", Handlers::SelfClosingHandler.new(AST::HorizontalRule)) registry.register(%w[ul ol], Handlers::ListHandler.new) diff --git a/lib/markbridge/parsers/html/handlers/heading_handler.rb b/lib/markbridge/parsers/html/handlers/heading_handler.rb new file mode 100644 index 00000000..f13384e3 --- /dev/null +++ b/lib/markbridge/parsers/html/handlers/heading_handler.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Markbridge + module Parsers + module HTML + module Handlers + # Handles

through

by creating a Heading element with the + # matching level and processing children into it. + class HeadingHandler < BaseHandler + # @param element [Nokogiri::XML::Element] the heading element + # @param parent [AST::Element] the parent AST node + # @return [AST::Heading] the created heading, so children get processed into it + def process(element:, parent:) + level = element.name.delete_prefix("h").to_i.clamp(1, 6) + heading = AST::Heading.new(level:) + parent << heading + + heading + end + + # @return [Class] AST::Heading + def element_class + AST::Heading + end + end + end + end + end +end diff --git a/lib/markbridge/renderers/discourse.rb b/lib/markbridge/renderers/discourse.rb index 7d3b4bce..f2d3055c 100644 --- a/lib/markbridge/renderers/discourse.rb +++ b/lib/markbridge/renderers/discourse.rb @@ -7,6 +7,7 @@ require_relative "discourse/markdown_escaper" require_relative "discourse/identity_escaper" require_relative "discourse/html_escaper" +require_relative "discourse/html_block_safety" require_relative "discourse/postprocessor" # Builders diff --git a/lib/markbridge/renderers/discourse/html_block_safety.rb b/lib/markbridge/renderers/discourse/html_block_safety.rb new file mode 100644 index 00000000..7415ba96 --- /dev/null +++ b/lib/markbridge/renderers/discourse/html_block_safety.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Markbridge + module Renderers + module Discourse + # Decides whether a rendered fragment is safe to splice into a + # CommonMark HTML block (spec §4.6). Inside such a block the + # content passes through as raw HTML; Markdown is only parsed + # again across blank lines. Safe output is therefore: a raw HTML + # or plain-text fragment without Markdown sigils, or a + # +\n\n…\n\n+ wrap — a deliberate Markdown island. + # + # Used by the html_mode contract check that ships in + # +markbridge/rspec+ and by this repo's own contract spec. + module HtmlBlockSafety + # Markdown sigils that would surface as literal text inside an + # HTML block: emphasis (`*`, `_`, `~`) and link middles (`](`). + MARKDOWN_SIGILS = /[*_~]|\]\(/ + private_constant :MARKDOWN_SIGILS + + # @param output [String] a tag's html_mode render result + # @return [Boolean] + def self.safe?(output) + return true if output.start_with?("\n\n") && output.end_with?("\n\n") + + !output.match?(MARKDOWN_SIGILS) + end + end + end + end +end diff --git a/lib/markbridge/rspec.rb b/lib/markbridge/rspec.rb new file mode 100644 index 00000000..2f9f9c42 --- /dev/null +++ b/lib/markbridge/rspec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "../markbridge" + +# Shared RSpec examples for consumers who write their own renderer +# tags. Require this file from your spec setup (RSpec itself must +# already be loaded): +# +# require "markbridge/rspec" +# +# and put a custom tag under the html_mode contract: +# +# RSpec.describe MyQuoteTag do +# it_behaves_like "an html_mode safe tag" do +# let(:tag) { described_class.new } +# let(:element) do +# element = MyQuote.new +# element << Markbridge::AST::Text.new("body *with* sigils") +# element +# end +# end +# end +# +# The example renders +element+ with +tag+ in html_mode and fails when +# the output would break inside a CommonMark HTML block — the same +# check Markbridge runs against its own tags. Give +element+ children +# whose text contains Markdown sigils, so a tag that passes them +# through unprotected is caught. When the tag needs a customized +# renderer to resolve its children (for example a custom tag library), +# override +markbridge_renderer+ with your configured renderer. +RSpec.shared_examples "an html_mode safe tag" do + let(:markbridge_renderer) { Markbridge::Renderers::Discourse::Renderer.new } + + let(:markbridge_html_mode_interface) do + context = Markbridge::Renderers::Discourse::RenderContext.new([], html_mode: true) + Markbridge::Renderers::Discourse::RenderingInterface.new(markbridge_renderer, context) + end + + it "renders html_mode output that is safe inside an HTML block" do + output = tag.render(element, markbridge_html_mode_interface) + + expect(Markbridge::Renderers::Discourse::HtmlBlockSafety.safe?(output)).to be(true), + "Expected #{tag.class} to render raw HTML or a \\n\\n-wrapped " \ + "Markdown island in html_mode, got: #{output.inspect}" + end +end diff --git a/spec/integration/markbridge/renderers/discourse/html_mode_contract_spec.rb b/spec/integration/markbridge/renderers/discourse/html_mode_contract_spec.rb index daad2e8e..b1d978a8 100644 --- a/spec/integration/markbridge/renderers/discourse/html_mode_contract_spec.rb +++ b/spec/integration/markbridge/renderers/discourse/html_mode_contract_spec.rb @@ -16,11 +16,13 @@ # This spec catches the regression where a new tag emits Markdown into # an HTML block — the Markdown would render literally rather than being # interpreted. +# +# It runs through the shared example that ships in `markbridge/rspec`, +# so consumers' custom tags go through exactly this check. +require "markbridge/rspec" + RSpec.describe "html_mode rendering contract" do let(:library) { Markbridge::Renderers::Discourse::TagLibrary.default } - let(:renderer) { Markbridge::Renderers::Discourse::Renderer.new } - let(:context) { Markbridge::Renderers::Discourse::RenderContext.new([], html_mode: true) } - let(:interface) { Markbridge::Renderers::Discourse::RenderingInterface.new(renderer, context) } ELEMENT_FACTORIES = { Markbridge::AST::Heading => -> { Markbridge::AST::Heading.new(level: 1) }, @@ -42,16 +44,6 @@ Markbridge::AST::Poll => -> { Markbridge::AST::Poll.new(options: %w[a b]) }, }.freeze - # Reject obvious Markdown sigils that would surface as literal text - # inside an HTML block: emphasis (`*`, `_`, `~`) and link middles - # (`](`). Allow them only when the output is a `\n\n…\n\n` Markdown - # island, since the blank-line wrap signals deliberate re-parsing. - def html_block_safe?(output) - return true if output.empty? - return true if output.start_with?("\n\n") && output.end_with?("\n\n") - !output.match?(/[*_~]|\]\(/) - end - def build_element(element_class) element = ELEMENT_FACTORIES.fetch(element_class) { -> { element_class.new } }.call if element.is_a?(Markbridge::AST::Element) && element.children.empty? @@ -64,14 +56,11 @@ def build_element(element_class) element_class = Markbridge::Renderers::Discourse::TagLibrary.default.ast_class_for(tag_constant) next unless element_class - it "#{tag_constant} produces output safe to splice into an HTML block" do - element = build_element(element_class) - output = library[element_class].render(element, interface) - - expect(html_block_safe?(output)).to be(true), - "Expected #{tag_constant} to render as raw HTML or " \ - "a \\n\\n-wrapped Markdown island in html_mode, " \ - "got: #{output.inspect}" + describe tag_constant.to_s do + it_behaves_like "an html_mode safe tag" do + let(:tag) { library[element_class] } + let(:element) { build_element(element_class) } + end end end end diff --git a/spec/system/html_to_markdown_spec.rb b/spec/system/html_to_markdown_spec.rb index 5677ba54..04faf8e4 100644 --- a/spec/system/html_to_markdown_spec.rb +++ b/spec/system/html_to_markdown_spec.rb @@ -73,6 +73,30 @@ end end + describe "headings" do + it "converts a heading" do + result = Markbridge.html_to_markdown("

Title

") + expect(result.markdown).to eq("## Title") + end + + it "converts all heading levels" do + (1..6).each do |level| + result = Markbridge.html_to_markdown("Title") + expect(result.markdown).to eq("#{"#" * level} Title") + end + end + + it "converts a heading with inline formatting" do + result = Markbridge.html_to_markdown("

A bold title

") + expect(result.markdown).to eq("### A **bold** title") + end + + it "separates headings from surrounding content with blank lines" do + result = Markbridge.html_to_markdown("

Top

Some text

Sub

") + expect(result.markdown).to eq("# Top\n\nSome text\n\n## Sub") + end + end + describe "lists" do it "converts unordered list" do html = <<~HTML diff --git a/spec/unit/markbridge/parsers/html/handler_registry_spec.rb b/spec/unit/markbridge/parsers/html/handler_registry_spec.rb index 8ed558e4..801b4936 100644 --- a/spec/unit/markbridge/parsers/html/handler_registry_spec.rb +++ b/spec/unit/markbridge/parsers/html/handler_registry_spec.rb @@ -139,6 +139,12 @@ "a" => [Markbridge::Parsers::HTML::Handlers::UrlHandler, Markbridge::AST::Url], "img" => [Markbridge::Parsers::HTML::Handlers::ImageHandler, Markbridge::AST::Image], "blockquote" => [Markbridge::Parsers::HTML::Handlers::QuoteHandler, Markbridge::AST::Quote], + "h1" => [Markbridge::Parsers::HTML::Handlers::HeadingHandler, Markbridge::AST::Heading], + "h2" => [Markbridge::Parsers::HTML::Handlers::HeadingHandler, Markbridge::AST::Heading], + "h3" => [Markbridge::Parsers::HTML::Handlers::HeadingHandler, Markbridge::AST::Heading], + "h4" => [Markbridge::Parsers::HTML::Handlers::HeadingHandler, Markbridge::AST::Heading], + "h5" => [Markbridge::Parsers::HTML::Handlers::HeadingHandler, Markbridge::AST::Heading], + "h6" => [Markbridge::Parsers::HTML::Handlers::HeadingHandler, Markbridge::AST::Heading], "ul" => [Markbridge::Parsers::HTML::Handlers::ListHandler, Markbridge::AST::List], "ol" => [Markbridge::Parsers::HTML::Handlers::ListHandler, Markbridge::AST::List], "li" => [Markbridge::Parsers::HTML::Handlers::ListItemHandler, Markbridge::AST::ListItem], diff --git a/spec/unit/markbridge/parsers/html/handlers/heading_handler_spec.rb b/spec/unit/markbridge/parsers/html/handlers/heading_handler_spec.rb new file mode 100644 index 00000000..682cc6e5 --- /dev/null +++ b/spec/unit/markbridge/parsers/html/handlers/heading_handler_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +RSpec.describe Markbridge::Parsers::HTML::Handlers::HeadingHandler do + let(:handler) { described_class.new } + let(:parent) { Markbridge::AST::Document.new } + + def build_element(html) + Nokogiri::HTML.fragment(html).children.first + end + + describe "#process" do + it "creates a Heading element and returns it so children get processed inside" do + result = handler.process(element: build_element("

Title

"), parent:) + + expect(parent.children.size).to eq(1) + expect(parent.children[0]).to be_a(Markbridge::AST::Heading) + expect(result).to eq(parent.children[0]) + end + + (1..6).each do |level| + it "maps to a Heading with level #{level}" do + result = handler.process(element: build_element("Title"), parent:) + + expect(result.level).to eq(level) + end + end + + # The handler itself accepts any element name — a consumer can register + # it for other tags — so levels outside 1..6 must stay in range. + it "clamps levels above 6 down to 6" do + result = handler.process(element: build_element("Title"), parent:) + + expect(result.level).to eq(6) + end + + it "uses level 1 when the tag name has no number" do + # A made-up tag name a consumer might register; not to be confused + # with the HTML5
element, which stays out of the registry. + result = handler.process(element: build_element("Title"), parent:) + + expect(result.level).to eq(1) + end + end + + describe "#element_class" do + it "returns AST::Heading" do + expect(handler.element_class).to eq(Markbridge::AST::Heading) + end + end +end diff --git a/spec/unit/markbridge/renderers/discourse/html_block_safety_spec.rb b/spec/unit/markbridge/renderers/discourse/html_block_safety_spec.rb new file mode 100644 index 00000000..0d846f1e --- /dev/null +++ b/spec/unit/markbridge/renderers/discourse/html_block_safety_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +RSpec.describe Markbridge::Renderers::Discourse::HtmlBlockSafety do + describe ".safe?" do + it "accepts an empty string" do + expect(described_class.safe?("")).to be(true) + end + + it "accepts plain text without Markdown sigils" do + expect(described_class.safe?("plain text")).to be(true) + end + + it "accepts a raw HTML fragment" do + expect(described_class.safe?("x")).to be(true) + end + + it "rejects emphasis stars" do + expect(described_class.safe?("**x**")).to be(false) + end + + it "rejects underscores" do + expect(described_class.safe?("_x_")).to be(false) + end + + it "rejects tildes" do + expect(described_class.safe?("~x~")).to be(false) + end + + it "rejects a link middle" do + expect(described_class.safe?("[x](https://example.com)")).to be(false) + end + + it "accepts a lone closing bracket" do + expect(described_class.safe?("a] b")).to be(true) + end + + it "accepts a lone opening parenthesis" do + expect(described_class.safe?("a (b)")).to be(true) + end + + it "accepts Markdown inside a blank-line wrapped island" do + expect(described_class.safe?("\n\n**x**\n\n")).to be(true) + end + + it "rejects Markdown when only the leading blank line is present" do + expect(described_class.safe?("\n\n**x**")).to be(false) + end + + it "rejects Markdown when only the trailing blank line is present" do + expect(described_class.safe?("**x**\n\n")).to be(false) + end + + it "rejects Markdown when the island starts with a single newline" do + expect(described_class.safe?("\n**x**\n\n")).to be(false) + end + + it "rejects Markdown when the island ends with a single newline" do + expect(described_class.safe?("\n\n**x**\n")).to be(false) + end + end +end From c83b4435d7024fe478b582130723a473696f5480 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Sat, 25 Jul 2026 21:53:41 +0200 Subject: [PATCH 2/3] FIX: Improve code language detection and drop empty element output RawHandler used the whole class attribute as the code language, so styling classes like "hljs" ended up on the fence line, and it never looked at the child inside
, where the CommonMark
convention puts the language-* class. The language now comes from the
first of: a language-* class on the element or its direct 
child, the lang attribute, or a lone class used as-is. A lone class
ranks below lang because a class can be pure styling. The result must
be a single clean token, otherwise the fence line would break.

Empty elements also left junk in the output: an empty AST::Code
rendered as a bare `` pair, an empty Details as an empty [details]
shell, and an empty Spoiler as [spoiler][/spoiler]. All three now
render to nothing. An empty AST::Quote already collapsed to nothing
through its blank-line bracketing, so it needed no change.
---
 docs/parsers/html.md                          |  8 ++
 .../parsers/html/handlers/raw_handler.rb      | 48 +++++++++++-
 .../renderers/discourse/tags/code_tag.rb      |  4 +
 .../renderers/discourse/tags/details_tag.rb   |  8 +-
 .../renderers/discourse/tags/spoiler_tag.rb   |  4 +
 spec/system/bbcode_to_markdown_spec.rb        |  5 ++
 spec/system/html_to_markdown_spec.rb          | 22 ++++++
 .../parsers/html/handlers/raw_handler_spec.rb | 78 ++++++++++++++++++-
 .../renderers/discourse/tags/code_tag_spec.rb |  7 ++
 .../discourse/tags/details_tag_spec.rb        | 22 ++++++
 .../discourse/tags/spoiler_tag_spec.rb        |  6 ++
 11 files changed, 202 insertions(+), 10 deletions(-)

diff --git a/docs/parsers/html.md b/docs/parsers/html.md
index 4a7b3098..ef03c349 100644
--- a/docs/parsers/html.md
+++ b/docs/parsers/html.md
@@ -70,6 +70,14 @@ end
 | `` | `AST::Code` | Inline or block code |
 | `
` | `AST::Code` | Preformatted code block |
 
+The language for syntax highlighting is read from the first of: a
+`language-*` class on the element itself or on its direct `` child
+(the CommonMark convention, `
`), the
+`lang` attribute, or a lone class used as-is. Styling classes like
+`class="hljs codeblock"` are ignored, and the result must be a single
+clean token (letters, digits, `_`, `+`, `-`) so it is safe on a fence
+line.
+
 ### Link Tags
 
 | HTML Tag | AST Node | Notes |
diff --git a/lib/markbridge/parsers/html/handlers/raw_handler.rb b/lib/markbridge/parsers/html/handlers/raw_handler.rb
index cb485f3b..078fba72 100644
--- a/lib/markbridge/parsers/html/handlers/raw_handler.rb
+++ b/lib/markbridge/parsers/html/handlers/raw_handler.rb
@@ -6,6 +6,12 @@ module HTML
       module Handlers
         # Handler for raw/preformatted tags that preserve content as-is
         class RawHandler < BaseHandler
+          # A language must be one clean token — the renderer splices it
+          # into the code fence line, so a class attribute with spaces or
+          # other markup characters must not end up there.
+          LANGUAGE_PATTERN = /\A[a-z0-9][a-z0-9_+-]*\z/
+          private_constant :LANGUAGE_PATTERN
+
           def initialize(element_class)
             @element_class = element_class
           end
@@ -14,10 +20,7 @@ def process(element:, parent:)
             # Get the inner text content
             content = element.inner_text
 
-            # Extract language from class or lang attribute
-            language = element["class"] || element["lang"]
-
-            ast_element = @element_class.new(language:)
+            ast_element = @element_class.new(language: language_for(element))
             ast_element << AST::Text.new(content) unless content.empty?
             parent << ast_element
 
@@ -26,6 +29,43 @@ def process(element:, parent:)
           end
 
           attr_reader :element_class
+
+          private
+
+          # The language of a code block, from the strongest signal to the
+          # weakest: a `language-*` class on the element itself or on its
+          # direct  child (the CommonMark convention for fenced code,
+          # `
`), then the `lang` attribute,
+          # then a lone class used as-is. A lone class ranks below `lang`
+          # because a class can be pure styling (`hljs`, `prettyprint`).
+          def language_for(element)
+            code_child_classes = element.at_xpath("./code")&.[]("class")
+
+            prefixed_language(element["class"]) || prefixed_language(code_child_classes) ||
+              attribute_language(element["lang"]) || single_class_language(element["class"]) ||
+              single_class_language(code_child_classes)
+          end
+
+          def prefixed_language(classes)
+            classes
+              &.split
+              &.filter_map do |name|
+                name.delete_prefix("language-").downcase if name.start_with?("language-")
+              end
+              &.find { |language| LANGUAGE_PATTERN.match?(language) }
+          end
+
+          def attribute_language(value)
+            language = value&.strip&.downcase
+            language if language&.match?(LANGUAGE_PATTERN)
+          end
+
+          def single_class_language(classes)
+            names = classes&.split
+            return unless names&.length == 1
+
+            attribute_language(names.first)
+          end
         end
       end
     end
diff --git a/lib/markbridge/renderers/discourse/tags/code_tag.rb b/lib/markbridge/renderers/discourse/tags/code_tag.rb
index a677f553..d62aea86 100644
--- a/lib/markbridge/renderers/discourse/tags/code_tag.rb
+++ b/lib/markbridge/renderers/discourse/tags/code_tag.rb
@@ -9,6 +9,10 @@ def render(element, interface)
             child_context = interface.with_parent(element)
             content = interface.render_children(element, context: child_context)
 
+            # An empty element renders to nothing — a bare `` pair or an
+            # empty fence would only add noise to the output.
+            return "" if content.empty?
+
             if interface.block_context?(element)
               if interface.html_mode?
                 render_html_block(content, element.language)
diff --git a/lib/markbridge/renderers/discourse/tags/details_tag.rb b/lib/markbridge/renderers/discourse/tags/details_tag.rb
index 5ed2e61e..fab4e05c 100644
--- a/lib/markbridge/renderers/discourse/tags/details_tag.rb
+++ b/lib/markbridge/renderers/discourse/tags/details_tag.rb
@@ -25,12 +25,16 @@ class DetailsTag < Tag
 
           def render(element, interface)
             child_context = interface.with_parent(element)
-            content = interface.render_children(element, context: child_context)
+            content = interface.render_children(element, context: child_context).strip
+
+            # A details block with nothing to show renders to nothing —
+            # an empty [details] shell would only add noise to the output.
+            return "" if content.empty?
 
             return render_html(element.title, content) if interface.html_mode?
 
             opener = element.title ? %([details="#{element.title}"]) : "[details]"
-            "\n\n#{opener}\n#{content.strip}\n[/details]\n\n"
+            "\n\n#{opener}\n#{content}\n[/details]\n\n"
           end
 
           private
diff --git a/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb b/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb
index f05a801c..66920abd 100644
--- a/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb
+++ b/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb
@@ -11,6 +11,10 @@ def render(element, interface)
             child_context = interface.with_parent(element)
             content = interface.render_children(element, context: child_context)
 
+            # A spoiler with nothing to hide renders to nothing — an empty
+            # [spoiler] shell would only add noise to the output.
+            return "" if content.empty?
+
             return render_html(element.title, content) if interface.html_mode?
 
             if element.title
diff --git a/spec/system/bbcode_to_markdown_spec.rb b/spec/system/bbcode_to_markdown_spec.rb
index c5f62752..badbba2e 100644
--- a/spec/system/bbcode_to_markdown_spec.rb
+++ b/spec/system/bbcode_to_markdown_spec.rb
@@ -188,6 +188,11 @@
       expect(result.markdown).to eq("`code text`")
     end
 
+    it "renders empty code tags to nothing" do
+      result = Markbridge.bbcode_to_markdown("a [code][/code] b")
+      expect(result.markdown).to eq("a  b")
+    end
+
     it "handles nested formatting" do
       result = Markbridge.bbcode_to_markdown("[b][i]bold italic[/i][/b]")
       expect(result.markdown).to eq("***bold italic***")
diff --git a/spec/system/html_to_markdown_spec.rb b/spec/system/html_to_markdown_spec.rb
index 04faf8e4..abe1ff66 100644
--- a/spec/system/html_to_markdown_spec.rb
+++ b/spec/system/html_to_markdown_spec.rb
@@ -172,6 +172,12 @@
       expect(result.markdown).to eq(expected)
     end
 
+    it "renders empty code elements to nothing" do
+      result = Markbridge.html_to_markdown(" and 
")
+
+      expect(result.markdown).to eq("and")
+    end
+
     it "converts code block" do
       html = "
function hello() {\n  console.log('hi');\n}
" expected = "```\nfunction hello() {\n console.log('hi');\n}\n```" @@ -179,6 +185,22 @@ result = Markbridge.html_to_markdown(html) expect(result.markdown).to eq(expected) end + + it "puts the language-* class of the inner code element on the fence" do + html = "
def hello\n  puts 'hi'\nend
" + expected = "```ruby\ndef hello\n puts 'hi'\nend\n```" + + result = Markbridge.html_to_markdown(html) + expect(result.markdown).to eq(expected) + end + + it "keeps styling classes off the fence" do + html = "
plain()\nrun()
" + expected = "```\nplain()\nrun()\n```" + + result = Markbridge.html_to_markdown(html) + expect(result.markdown).to eq(expected) + end end describe "blockquote" do diff --git a/spec/unit/markbridge/parsers/html/handlers/raw_handler_spec.rb b/spec/unit/markbridge/parsers/html/handlers/raw_handler_spec.rb index 2616ab77..47981e8f 100644 --- a/spec/unit/markbridge/parsers/html/handlers/raw_handler_spec.rb +++ b/spec/unit/markbridge/parsers/html/handlers/raw_handler_spec.rb @@ -17,24 +17,94 @@ def build_element(html) expect(parent.children[0].children[0].text).to eq("code content") end - it "extracts language from the class attribute" do - handler.process(element: build_element('code'), parent:) + it "extracts language from a language-* class" do + handler.process(element: build_element('code'), parent:) + + expect(parent.children[0].language).to eq("ruby") + end + + it "extracts language from a language-* class among styling classes" do + handler.process(element: build_element('
code
'), parent:) + + expect(parent.children[0].language).to eq("ruby") + end + + it "extracts language from a language-* class on the direct code child" do + handler.process( + element: build_element('
code
'), + parent:, + ) + + expect(parent.children[0].language).to eq("python") + end + + it "prefers a language-* class on the code child over a lone class on the element" do + handler.process( + element: + build_element('
code
'), + parent:, + ) expect(parent.children[0].language).to eq("ruby") end - it "extracts language from the lang attribute when class is missing" do + it "extracts language from the lang attribute" do handler.process(element: build_element('code'), parent:) expect(parent.children[0].language).to eq("python") end - it "prefers the class attribute over lang when both are present" do + it "strips and downcases the lang attribute" do + handler.process(element: build_element('code'), parent:) + + expect(parent.children[0].language).to eq("ruby") + end + + it "downcases a lone class on the direct code child" do + handler.process(element: build_element('
code
'), parent:) + + expect(parent.children[0].language).to eq("ruby") + end + + it "prefers a language-* class over the lang attribute" do + handler.process( + element: build_element('code'), + parent:, + ) + + expect(parent.children[0].language).to eq("ruby") + end + + it "prefers the lang attribute over a lone class without language- prefix" do handler.process( element: build_element('code'), parent:, ) + expect(parent.children[0].language).to eq("python") + end + + it "uses a lone class as language when nothing else matches" do + handler.process(element: build_element('code'), parent:) + + expect(parent.children[0].language).to eq("ruby") + end + + it "leaves language nil for multiple classes without language- prefix" do + handler.process(element: build_element('
code
'), parent:) + + expect(parent.children[0].language).to be_nil + end + + it "leaves language nil when the extracted token is not a valid language" do + handler.process(element: build_element('code'), parent:) + + expect(parent.children[0].language).to be_nil + end + + it "downcases the extracted language" do + handler.process(element: build_element('code'), parent:) + expect(parent.children[0].language).to eq("ruby") end diff --git a/spec/unit/markbridge/renderers/discourse/tags/code_tag_spec.rb b/spec/unit/markbridge/renderers/discourse/tags/code_tag_spec.rb index ae456b3e..bcaf1755 100644 --- a/spec/unit/markbridge/renderers/discourse/tags/code_tag_spec.rb +++ b/spec/unit/markbridge/renderers/discourse/tags/code_tag_spec.rb @@ -15,6 +15,13 @@ expect(result).to eq("`code`") end + it "renders an empty element to nothing" do + element = Markbridge::AST::Code.new(language: "ruby") + + result = tag.render(element, interface) + expect(result).to eq("") + end + it "renders block code with newlines" do element = Markbridge::AST::Code.new element << Markbridge::AST::Text.new("line1\nline2") diff --git a/spec/unit/markbridge/renderers/discourse/tags/details_tag_spec.rb b/spec/unit/markbridge/renderers/discourse/tags/details_tag_spec.rb index 95127f2f..2b377ed7 100644 --- a/spec/unit/markbridge/renderers/discourse/tags/details_tag_spec.rb +++ b/spec/unit/markbridge/renderers/discourse/tags/details_tag_spec.rb @@ -16,6 +16,28 @@ ) end + it "strips whitespace on both sides of the body" do + element = Markbridge::AST::Details.new(title: "Show more") + element << Markbridge::AST::Text.new(" \nhidden body\n ") + + expect(tag.render(element, interface)).to eq( + %(\n\n[details="Show more"]\nhidden body\n[/details]\n\n), + ) + end + + it "renders an element with no content to nothing" do + element = Markbridge::AST::Details.new(title: "Empty") + + expect(tag.render(element, interface)).to eq("") + end + + it "renders whitespace-only content to nothing" do + element = Markbridge::AST::Details.new(title: "Empty") + element << Markbridge::AST::Text.new(" \n ") + + expect(tag.render(element, interface)).to eq("") + end + it "omits the =\"…\" when no title is set, producing bare [details]" do element = Markbridge::AST::Details.new element << Markbridge::AST::Text.new("body") diff --git a/spec/unit/markbridge/renderers/discourse/tags/spoiler_tag_spec.rb b/spec/unit/markbridge/renderers/discourse/tags/spoiler_tag_spec.rb index 2dd14270..28a31821 100644 --- a/spec/unit/markbridge/renderers/discourse/tags/spoiler_tag_spec.rb +++ b/spec/unit/markbridge/renderers/discourse/tags/spoiler_tag_spec.rb @@ -14,6 +14,12 @@ expect(tag.render(element, interface)).to eq("[spoiler]hidden[/spoiler]") end + it "renders an empty element to nothing" do + element = Markbridge::AST::Spoiler.new(title: "Click me") + + expect(tag.render(element, interface)).to eq("") + end + it "uses [spoiler=title] form when a title is set" do element = Markbridge::AST::Spoiler.new(title: "Click me") element << Markbridge::AST::Text.new("hidden") From 7e8e0c8e667942fcc69a7a60acb80db074e4a66a Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Sat, 25 Jul 2026 22:48:49 +0200 Subject: [PATCH 3/3] DEV: Fix stale parser docs and ignore the docs build output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTML parser docs showed a handler signature with a processor: parameter that the parser never passes, a RawHandler example without the required element_class argument, and lambda handlers, which the parser does not support — it always calls #process on the registered handler. The examples now match the real API. The Astro docs tooling leaves docs/.astro, docs/dist, docs/node_modules, and .pnpm-store in the working tree. They were untracked but not ignored, so staging a directory could sweep them into a commit. docs/src stays visible because it is real source on the docs branch. --- .gitignore | 6 ++++++ docs/parsers/html.md | 35 +++++++++++++++-------------------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 6e540b98..b7798560 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ # IDE and silo-local state /.idea/ /.silo.local.yml + +# Docs site build output (the docs branch holds the source) +/docs/.astro/ +/docs/dist/ +/docs/node_modules/ +/.pnpm-store/ diff --git a/docs/parsers/html.md b/docs/parsers/html.md index ef03c349..65d8c107 100644 --- a/docs/parsers/html.md +++ b/docs/parsers/html.md @@ -17,10 +17,9 @@ This guide explains how the HTML parser converts standard HTML into the Markbrid The HTML parser (`Markbridge::Parsers::HTML::Parser`) uses Nokogiri to convert HTML markup into AST. It provides a simpler alternative to the BBCode parser when working with HTML content. **Key Features:** -- Leverages Nokogiri's battle-tested HTML parser (libxml2 on MRI/TruffleRuby, Xerces/NekoHTML on JRuby) +- Uses Nokogiri's HTML parser (libxml2 on MRI/TruffleRuby, Xerces/NekoHTML on JRuby) - Handles malformed HTML gracefully - Stateless handler API (simpler than BBCode) -- Lambda handler support for quick customization - Void element detection (self-closing tags) **Dependencies:** @@ -169,14 +168,17 @@ class CustomHandler < Markbridge::Parsers::HTML::Handlers::BaseHandler @element_class = element_class end - def process(element:, parent:, processor:) + def process(element:, parent:) # element: Nokogiri::XML::Element (complete DOM element) - # parent: AST::Element (where to add children) - # processor: Parser (for processing children) + # parent: AST::Element (where to add the new node) ast_element = @element_class.new parent << ast_element - processor.process_children(element, ast_element) + + # Return the created element and the parser processes the DOM + # element's children into it. Return nil to skip the children + # (for example when the handler consumed the content itself). + ast_element end attr_reader :element_class @@ -196,11 +198,11 @@ registry.register(["b", "strong"], handler) #### RawHandler -For code blocks that preserve content: +For code blocks that preserve content. Takes the AST class to create: ```ruby -handler = RawHandler.new -registry.register(["code", "pre"], handler) +handler = RawHandler.new(Markbridge::AST::Code) +registry.register(["code", "pre", "tt"], handler) ``` #### UrlHandler @@ -221,20 +223,13 @@ registry.register(["ul", "ol"], ListHandler.new) registry.register("li", ListItemHandler.new) ``` -### Lambda Handlers +#### SelfClosingHandler -For simple cases, use lambda handlers: +For void elements that map to a childless AST node: ```ruby -registry.register("hr", ->(element:, parent:, **) { - parent << AST::HorizontalRule.new - nil # Return nil to skip processing children -}) - -registry.register("br", ->(element:, parent:, **) { - parent << AST::LineBreak.new - nil -}) +registry.register("br", SelfClosingHandler.new(Markbridge::AST::LineBreak)) +registry.register("hr", SelfClosingHandler.new(Markbridge::AST::HorizontalRule)) ``` ## Configuration