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/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..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:** @@ -70,6 +69,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 |
@@ -89,6 +96,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 | @@ -160,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 @@ -187,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 @@ -212,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 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/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.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/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/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/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 5677ba54..abe1ff66 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 @@ -148,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```" @@ -155,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/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/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/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 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")