markbridge 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/lib/markbridge/all.rb +4 -7
  3. data/lib/markbridge/ast/document.rb +1 -1
  4. data/lib/markbridge/ast/element.rb +2 -2
  5. data/lib/markbridge/ast/list.rb +2 -2
  6. data/lib/markbridge/ast/table.rb +6 -12
  7. data/lib/markbridge/ast/text.rb +5 -1
  8. data/lib/markbridge/bbcode.rb +4 -0
  9. data/lib/markbridge/gem_loader.rb +2 -3
  10. data/lib/markbridge/html.rb +4 -0
  11. data/lib/markbridge/mediawiki.rb +4 -0
  12. data/lib/markbridge/parsers/bbcode/closing_strategies/base.rb +0 -10
  13. data/lib/markbridge/parsers/bbcode/closing_strategies/reordering.rb +17 -4
  14. data/lib/markbridge/parsers/bbcode/closing_strategies/tag_reconciler.rb +64 -44
  15. data/lib/markbridge/parsers/bbcode/handler_registry.rb +21 -11
  16. data/lib/markbridge/parsers/bbcode/handlers/attachment_handler.rb +17 -12
  17. data/lib/markbridge/parsers/bbcode/handlers/base_handler.rb +0 -10
  18. data/lib/markbridge/parsers/bbcode/handlers/code_handler.rb +6 -10
  19. data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +9 -17
  20. data/lib/markbridge/parsers/bbcode/handlers/list_handler.rb +1 -5
  21. data/lib/markbridge/parsers/bbcode/handlers/list_item_handler.rb +1 -2
  22. data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +6 -18
  23. data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +2 -6
  24. data/lib/markbridge/parsers/bbcode/handlers/self_closing_handler.rb +4 -4
  25. data/lib/markbridge/parsers/bbcode/handlers/table_cell_handler.rb +1 -1
  26. data/lib/markbridge/parsers/bbcode/handlers/table_handler.rb +2 -2
  27. data/lib/markbridge/parsers/bbcode/handlers/table_row_handler.rb +3 -3
  28. data/lib/markbridge/parsers/bbcode/parser.rb +5 -8
  29. data/lib/markbridge/parsers/bbcode/parser_state.rb +12 -18
  30. data/lib/markbridge/parsers/bbcode/peekable_enumerator.rb +9 -59
  31. data/lib/markbridge/parsers/bbcode/raw_content_collector.rb +2 -2
  32. data/lib/markbridge/parsers/bbcode/scanner.rb +49 -63
  33. data/lib/markbridge/parsers/bbcode/tokens/tag_end_token.rb +1 -5
  34. data/lib/markbridge/parsers/bbcode/tokens/tag_start_token.rb +1 -6
  35. data/lib/markbridge/parsers/bbcode/tokens/text_token.rb +1 -7
  36. data/lib/markbridge/parsers/bbcode/tokens/token.rb +1 -1
  37. data/lib/markbridge/parsers/bbcode.rb +1 -0
  38. data/lib/markbridge/parsers/html/handler_registry.rb +32 -49
  39. data/lib/markbridge/parsers/html/handlers/base_handler.rb +0 -2
  40. data/lib/markbridge/parsers/html/handlers/image_handler.rb +1 -4
  41. data/lib/markbridge/parsers/html/parser.rb +3 -13
  42. data/lib/markbridge/parsers/media_wiki/inline_parser.rb +56 -67
  43. data/lib/markbridge/parsers/media_wiki/inline_tag_registry.rb +103 -0
  44. data/lib/markbridge/parsers/media_wiki/parser.rb +51 -76
  45. data/lib/markbridge/parsers/media_wiki.rb +1 -0
  46. data/lib/markbridge/parsers/text_formatter/handler_registry.rb +5 -37
  47. data/lib/markbridge/parsers/text_formatter/parser.rb +3 -8
  48. data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +24 -17
  49. data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +9 -15
  50. data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +11 -10
  51. data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +11 -39
  52. data/lib/markbridge/processors/discourse_markdown/detectors/upload.rb +38 -63
  53. data/lib/markbridge/processors/discourse_markdown/scanner.rb +25 -33
  54. data/lib/markbridge/renderers/discourse/builders/list_item_builder.rb +6 -6
  55. data/lib/markbridge/renderers/discourse/html_escaper.rb +20 -0
  56. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +57 -50
  57. data/lib/markbridge/renderers/discourse/render_context.rb +23 -11
  58. data/lib/markbridge/renderers/discourse/renderer.rb +54 -12
  59. data/lib/markbridge/renderers/discourse/rendering_interface.rb +12 -4
  60. data/lib/markbridge/renderers/discourse/tag.rb +14 -1
  61. data/lib/markbridge/renderers/discourse/tag_library.rb +30 -25
  62. data/lib/markbridge/renderers/discourse/tags/align_tag.rb +15 -7
  63. data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +2 -0
  64. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +14 -9
  65. data/lib/markbridge/renderers/discourse/tags/email_tag.rb +5 -3
  66. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +3 -1
  67. data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +6 -2
  68. data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +2 -2
  69. data/lib/markbridge/renderers/discourse/tags/image_tag.rb +13 -2
  70. data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +2 -0
  71. data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +2 -2
  72. data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +24 -47
  73. data/lib/markbridge/renderers/discourse/tags/list_tag.rb +10 -15
  74. data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +5 -1
  75. data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +10 -0
  76. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +9 -2
  77. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +2 -0
  78. data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +9 -0
  79. data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +2 -0
  80. data/lib/markbridge/renderers/discourse/tags/table_tag.rb +12 -8
  81. data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +10 -3
  82. data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +29 -2
  83. data/lib/markbridge/renderers/discourse/tags/url_tag.rb +5 -3
  84. data/lib/markbridge/renderers/discourse.rb +1 -0
  85. data/lib/markbridge/textformatter.rb +4 -0
  86. data/lib/markbridge/version.rb +1 -1
  87. data/lib/markbridge.rb +8 -8
  88. metadata +8 -2
@@ -5,10 +5,13 @@ module Markbridge
5
5
  module Discourse
6
6
  # Renders AST to Discourse-flavored Markdown in-memory.
7
7
  class Renderer
8
- def initialize(tag_library: nil, escaper: nil)
8
+ def initialize(tag_library: nil, escaper: nil, html_escaper: nil)
9
9
  @tag_library = tag_library || TagLibrary.default
10
10
  @escaper = escaper || MarkdownEscaper.new
11
- @interface_cache = nil
11
+ @html_escaper = html_escaper || HtmlEscaper
12
+ # @interface_cache is lazily initialized in #render's top-level
13
+ # call and reset to nil after the call completes. No init
14
+ # needed here — unset ivar returns nil under `.nil?` check.
12
15
  end
13
16
 
14
17
  # Render a node to Markdown
@@ -26,18 +29,12 @@ module Markbridge
26
29
  end
27
30
 
28
31
  case node
29
- when AST::Document, AST::Element
32
+ when AST::Element # Document is an Element subclass
30
33
  render_children(node, context:)
31
34
  when AST::MarkdownText
32
- # Pass through markdown text as-is (already formatted)
33
- node.text
35
+ render_markdown_text(node, context)
34
36
  when AST::Text
35
- # Escape plain text unless we're inside a code block
36
- if context.has_parent?(AST::Code)
37
- node.text
38
- else
39
- @escaper.escape(node.text)
40
- end
37
+ render_text(node, context)
41
38
  else
42
39
  ""
43
40
  end
@@ -50,14 +47,59 @@ module Markbridge
50
47
  # @param context [RenderContext] rendering context
51
48
  # @return [String]
52
49
  def render_children(node, context:)
53
- node.children.map { |child| render(child, context:) }.join
50
+ result = +""
51
+ node.children.each do |child|
52
+ part = render(child, context:)
53
+ next if part.empty?
54
+
55
+ # Integer-byte check avoids allocating substrings for the
56
+ # per-child adjacency probe. EMPHASIS_DELIMITER_BYTES.include?
57
+ # over a 4-element Set is O(1).
58
+ if !result.empty? && (last_byte = result.getbyte(-1)) == part.getbyte(0) &&
59
+ EMPHASIS_DELIMITER_BYTES.include?(last_byte)
60
+ result << EMPHASIS_BOUNDARY
61
+ end
62
+ result << part
63
+ end
64
+ result
54
65
  end
55
66
 
56
67
  private
57
68
 
69
+ # Inserted between sibling outputs when their adjacent characters
70
+ # would merge into a longer Markdown emphasis delimiter run (e.g.
71
+ # `***` + `*...` becoming `****...`). The HTML comment is invisible
72
+ # in rendered output but breaks the delimiter run during Markdown
73
+ # parsing.
74
+ EMPHASIS_BOUNDARY = "<!---->"
75
+ # Bytes where adjacent runs merge into a single longer run during
76
+ # Markdown parsing: emphasis (* _), strikethrough (~), code spans (`).
77
+ EMPHASIS_DELIMITER_BYTES = Set[42, 95, 126, 96].freeze
78
+ private_constant :EMPHASIS_BOUNDARY, :EMPHASIS_DELIMITER_BYTES
79
+
58
80
  def interface_for(context)
59
81
  @interface_cache[context.object_id] ||= RenderingInterface.new(self, context)
60
82
  end
83
+
84
+ # In html_mode, surround pre-formatted Markdown with blank lines so that
85
+ # CommonMark terminates the enclosing HTML block (e.g. <table>) and
86
+ # parses the content as Markdown before the closing tags reopen another
87
+ # HTML block.
88
+ def render_markdown_text(node, context)
89
+ context.html_mode? ? "\n\n#{node.text}\n\n" : node.text
90
+ end
91
+
92
+ def render_text(node, context)
93
+ # In html_mode even inside a code block we must HTML-escape, otherwise a
94
+ # stray `<` in a code cell would break the surrounding <td>.
95
+ if context.has_parent?(AST::Code)
96
+ context.html_mode? ? @html_escaper.escape(node.text) : node.text
97
+ elsif context.html_mode?
98
+ @html_escaper.escape(node.text)
99
+ else
100
+ @escaper.escape(node.text)
101
+ end
102
+ end
61
103
  end
62
104
  end
63
105
  end
@@ -27,6 +27,14 @@ module Markbridge
27
27
  @context.with_parent(element)
28
28
  end
29
29
 
30
+ def with_html_mode(value)
31
+ @context.with_html_mode(value)
32
+ end
33
+
34
+ def html_mode?
35
+ @context.html_mode?
36
+ end
37
+
30
38
  def find_parent(klass)
31
39
  @context.find_parent(klass)
32
40
  end
@@ -48,18 +56,18 @@ module Markbridge
48
56
  # @return [Boolean]
49
57
  def block_context?(node)
50
58
  # Check if it's a block-level element type (but not code, which can be inline)
51
- return true if node.is_a?(AST::List) || node.is_a?(AST::HorizontalRule)
59
+ return true if node.instance_of?(AST::List) || node.instance_of?(AST::HorizontalRule)
52
60
  return false unless node.is_a?(AST::Element)
53
61
 
54
62
  # Check if content has newlines
55
- node.children.any? { |c| c.is_a?(AST::Text) && c.text.include?("\n") }
63
+ node.children.any? { |c| c.instance_of?(AST::Text) && c.text.include?("\n") }
56
64
  end
57
65
 
58
66
  # Helper: wrap inline content with markers
59
67
  # Handles edge cases like existing markers and whitespace
60
68
  def wrap_inline(content, open_marker, close_marker = nil)
61
69
  close_marker ||= open_marker
62
- return content if content.strip.empty?
70
+ return content unless content.match?(/\S/)
63
71
 
64
72
  # Handle conflicts with existing markers
65
73
  if content.include?(open_marker) || content.include?(close_marker)
@@ -75,7 +83,7 @@ module Markbridge
75
83
  end
76
84
 
77
85
  # Preserve leading/trailing whitespace
78
- content.sub(/^(\s*)(.+?)(\s*)$/m) do
86
+ content.sub(/\A(\s*)(.+?)(\s*)\z/m) do
79
87
  match = Regexp.last_match
80
88
  "#{match[1]}#{open_marker}#{match[2]}#{close_marker}#{match[3]}"
81
89
  end
@@ -12,7 +12,20 @@ module Markbridge
12
12
  @render_block = block
13
13
  end
14
14
 
15
- # Render a node to Discourse Markdown
15
+ # Render a node to Discourse Markdown.
16
+ #
17
+ # When `interface.html_mode?` is true the surrounding output is
18
+ # a CommonMark HTML block (§4.6): content is treated as raw HTML
19
+ # and is not re-parsed for Markdown except across blank lines.
20
+ # Every tag must pick one of two contracts:
21
+ #
22
+ # 1. Emit raw HTML (e.g. `<strong>` for `**`).
23
+ # 2. Wrap Markdown output in `\n\n…\n\n` so the blank lines close
24
+ # the HTML block, CommonMark parses the content as a Markdown
25
+ # island, then re-opens. Visible: blank-line wrapping forces a
26
+ # `<p>` margin around inline content, so prefer (1) when the
27
+ # tag has an HTML form.
28
+ #
16
29
  # @param element [AST::Node] the node to render
17
30
  # @param interface [RenderingInterface] the rendering interface
18
31
  # @return [String] the rendered markdown
@@ -5,6 +5,8 @@ module Markbridge
5
5
  module Discourse
6
6
  # Library of rendering tags for different element types
7
7
  class TagLibrary
8
+ include Enumerable
9
+
8
10
  def initialize
9
11
  @tags = {}
10
12
  end
@@ -24,42 +26,45 @@ module Markbridge
24
26
  @tags[element_class]
25
27
  end
26
28
 
29
+ # Iterate over registered (element_class, tag) pairs.
30
+ # Useful for debugging custom libraries — e.g. confirming an override
31
+ # has stuck. Iteration order matches registration order.
32
+ # @yieldparam element_class [Class]
33
+ # @yieldparam tag [Tag]
34
+ # @return [Enumerator] when no block is given
35
+ def each(&block)
36
+ @tags.each(&block)
37
+ end
38
+
27
39
  # Auto-register all tags using naming convention
28
40
  # Convention: BoldTag handles AST::Bold, ItalicTag handles AST::Italic, etc.
29
41
  # @return [self]
30
42
  def auto_register!
31
43
  Tags.constants.each do |tag_constant|
32
- tag_class = Tags.const_get(tag_constant)
33
- next unless tag_class.is_a?(Class) && tag_class < Tag
34
-
35
- # Extract element name from tag name: BoldTag → Bold
36
- element_name = tag_constant.to_s.sub(/Tag$/, "")
37
- element_class =
38
- begin
39
- AST.const_get(element_name)
40
- rescue StandardError
41
- nil
42
- end
43
-
44
- register(element_class, tag_class.new) if element_class
44
+ element_class = ast_class_for(tag_constant)
45
+ register(element_class, Tags.const_get(tag_constant).new) if element_class
45
46
  end
46
-
47
47
  self
48
48
  end
49
49
 
50
- # Create the default tag library for Discourse Markdown
50
+ # Look up the AST element class matching a +XxxTag+ constant via the
51
+ # +XxxTag → AST::Xxx+ naming convention.
52
+ # @return [Class, nil]
53
+ def ast_class_for(tag_constant)
54
+ AST.const_get(tag_constant.to_s.sub(/Tag\z/, ""))
55
+ rescue NameError
56
+ nil
57
+ end
58
+
59
+ # Create the default tag library for Discourse Markdown.
60
+ #
61
+ # Each call returns a *fresh* instance — mutations made to one will
62
+ # not be visible to another. If you want a process-wide singleton,
63
+ # use {Markbridge.default_tag_library} instead, which memoizes.
64
+ #
51
65
  # @return [TagLibrary]
52
66
  def self.default
53
- library = new
54
-
55
- # Auto-register tags based on naming convention
56
- library.auto_register!
57
-
58
- # Special cases: inline tags that don't follow the convention
59
- library.register AST::LineBreak, Tags::LineBreakTag.new
60
- library.register AST::HorizontalRule, Tags::HorizontalRuleTag.new
61
-
62
- library
67
+ new.auto_register!
63
68
  end
64
69
  end
65
70
  end
@@ -4,18 +4,26 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Tag for rendering aligned text
8
- # Renders as HTML div with align attribute
7
+ # Discourse's HTML sanitizer allows the (HTML5-deprecated) `align`
8
+ # attribute on `<div>` but strips inline `style`, so we emit the
9
+ # legacy form. Alignment is constrained to a known keyword set
10
+ # for defense in depth — anything outside the set falls through
11
+ # to bare content rather than getting interpolated into the
12
+ # attribute.
9
13
  class AlignTag < Tag
14
+ ALLOWED_ALIGNMENTS = Set["left", "right", "center", "justify"].freeze
15
+ private_constant :ALLOWED_ALIGNMENTS
16
+
10
17
  def render(element, interface)
11
18
  child_context = interface.with_parent(element)
12
19
  content = interface.render_children(element, context: child_context)
13
20
 
14
- if element.alignment
15
- "<div align=\"#{element.alignment}\">#{content}</div>\n\n"
16
- else
17
- content
18
- end
21
+ return content unless ALLOWED_ALIGNMENTS.include?(element.alignment)
22
+
23
+ wrapper = %(<div align="#{element.alignment}">#{content}</div>)
24
+ # Skip the trailing blank line in html_mode: a blank line would
25
+ # terminate the surrounding HTML block (e.g. an enclosing <table>).
26
+ interface.html_mode? ? wrapper : "#{wrapper}\n\n"
19
27
  end
20
28
  end
21
29
  end
@@ -9,6 +9,8 @@ module Markbridge
9
9
  def render(element, interface)
10
10
  child_context = interface.with_parent(element)
11
11
  content = interface.render_children(element, context: child_context)
12
+ return "<strong>#{content}</strong>" if interface.html_mode?
13
+
12
14
  interface.wrap_inline(content, "**")
13
15
  end
14
16
  end
@@ -4,15 +4,19 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Tag for rendering code
8
7
  class CodeTag < Tag
9
8
  def render(element, interface)
10
9
  child_context = interface.with_parent(element)
11
10
  content = interface.render_children(element, context: child_context)
12
11
 
13
- # Determine if inline or block based on context
14
12
  if interface.block_context?(element)
15
- render_block(content, element.language)
13
+ if interface.html_mode?
14
+ render_html_block(content, element.language)
15
+ else
16
+ render_block(content, element.language)
17
+ end
18
+ elsif interface.html_mode?
19
+ "<code>#{content}</code>"
16
20
  else
17
21
  render_inline(content)
18
22
  end
@@ -24,24 +28,25 @@ module Markbridge
24
28
  "`#{content}`"
25
29
  end
26
30
 
31
+ # Trailing blank line keeps an adjacent fence on the next block from
32
+ # being parsed as a continuation of this one.
27
33
  def render_block(content, language)
28
34
  fence = calculate_fence(content)
29
- lang = language || ""
35
+ "#{fence}#{language}\n#{content}\n#{fence}\n\n"
36
+ end
30
37
 
31
- # Blank line keeps adjacent fences from merging.
32
- "#{fence}#{lang}\n#{content}\n#{fence}\n\n"
38
+ def render_html_block(content, language)
39
+ class_attr = %( class="language-#{HtmlEscaper.escape(language)}") if language
40
+ "<pre><code#{class_attr}>#{content}</code></pre>"
33
41
  end
34
42
 
35
43
  def calculate_fence(content)
36
- # Find longest sequence of backticks and tildes
37
44
  max_backticks = content.scan(/`+/).map(&:length).max || 0
38
45
  max_tildes = content.scan(/~+/).map(&:length).max || 0
39
46
 
40
- # Need fence longer than any sequence in content (minimum 3)
41
47
  required_backticks = [3, max_backticks + 1].max
42
48
  required_tildes = [3, max_tildes + 1].max
43
49
 
44
- # Choose whichever requires fewer characters
45
50
  if required_backticks <= required_tildes
46
51
  "`" * required_backticks
47
52
  else
@@ -11,10 +11,12 @@ module Markbridge
11
11
  text = interface.render_children(element, context: child_context)
12
12
  address = element.address
13
13
 
14
- if address
15
- "[#{text}](mailto:#{address})"
14
+ return text unless address
15
+
16
+ if interface.html_mode?
17
+ %(<a href="mailto:#{HtmlEscaper.escape(address)}">#{text}</a>)
16
18
  else
17
- text
19
+ "[#{text}](mailto:#{address})"
18
20
  end
19
21
  end
20
22
  end
@@ -18,8 +18,10 @@ module Markbridge
18
18
  # end
19
19
  # end
20
20
  class EventTag < Tag
21
- def render(element, _interface)
21
+ def render(element, interface)
22
22
  body = element.raw || build_event_bbcode(element)
23
+ return "\n\n#{body}\n\n" if interface.html_mode?
24
+
23
25
  "#{body}\n\n"
24
26
  end
25
27
 
@@ -4,12 +4,16 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Tag for rendering headings
8
- # Renders as ATX-style Markdown headings (# through ######)
9
7
  class HeadingTag < Tag
10
8
  def render(element, interface)
11
9
  child_context = interface.with_parent(element)
12
10
  content = interface.render_children(element, context: child_context)
11
+
12
+ if interface.html_mode?
13
+ level = element.level.clamp(1, 6)
14
+ return "<h#{level}>#{content}</h#{level}>"
15
+ end
16
+
13
17
  prefix = "#" * element.level
14
18
 
15
19
  "#{prefix} #{content}\n\n"
@@ -6,8 +6,8 @@ module Markbridge
6
6
  module Tags
7
7
  # Tag for rendering horizontal rules
8
8
  class HorizontalRuleTag < Tag
9
- def render(_element, _interface)
10
- "\n\n---\n\n"
9
+ def render(_element, interface)
10
+ interface.html_mode? ? "<hr>" : "\n\n---\n\n"
11
11
  end
12
12
  end
13
13
  end
@@ -7,11 +7,13 @@ module Markbridge
7
7
  # Tag for rendering images
8
8
  # Renders to Markdown image syntax with optional Discourse sizing
9
9
  class ImageTag < Tag
10
- def render(element, _interface)
11
- src = element.src || ""
10
+ def render(element, interface)
11
+ src = element.src
12
12
  width = element.width
13
13
  height = element.height
14
14
 
15
+ return render_html(src, width, height) if interface.html_mode?
16
+
15
17
  # Build Discourse image syntax with dimensions
16
18
  # Format: ![alt|WIDTHxHEIGHT](url) or ![alt|WIDTH](url)
17
19
  if width && height
@@ -22,6 +24,15 @@ module Markbridge
22
24
  "![](#{src})"
23
25
  end
24
26
  end
27
+
28
+ private
29
+
30
+ def render_html(src, width, height)
31
+ attrs = %(src="#{HtmlEscaper.escape(src)}" alt="")
32
+ attrs << %( width="#{width}") if width
33
+ attrs << %( height="#{height}") if height
34
+ "<img #{attrs}>"
35
+ end
25
36
  end
26
37
  end
27
38
  end
@@ -9,6 +9,8 @@ module Markbridge
9
9
  def render(element, interface)
10
10
  child_context = interface.with_parent(element)
11
11
  content = interface.render_children(element, context: child_context)
12
+ return "<em>#{content}</em>" if interface.html_mode?
13
+
12
14
  interface.wrap_inline(content, "*")
13
15
  end
14
16
  end
@@ -6,8 +6,8 @@ module Markbridge
6
6
  module Tags
7
7
  # Tag for rendering line breaks
8
8
  class LineBreakTag < Tag
9
- def render(_element, _interface)
10
- "\n"
9
+ def render(_element, interface)
10
+ interface.html_mode? ? "<br>" : "\n"
11
11
  end
12
12
  end
13
13
  end
@@ -7,77 +7,54 @@ module Markbridge
7
7
  # Tag for rendering list items
8
8
  class ListItemTag < Tag
9
9
  def initialize
10
- super
11
10
  @builder = Builders::ListItemBuilder.new
12
11
  end
13
12
 
14
13
  def render(element, interface)
15
- # Create new context with this list item as parent
16
14
  child_context = interface.with_parent(element)
17
-
18
- # Render children with updated context
19
15
  content = interface.render_children(element, context: child_context).strip
20
16
  return "" if content.empty?
21
17
 
22
- # Get parent list to determine marker
23
- parent_list = interface.find_parent(AST::List)
24
- marker = determine_marker(parent_list)
25
-
26
- # Calculate indentation based on ancestor lists
27
- indent = calculate_indent(interface)
18
+ return "<li>#{content}</li>" if interface.html_mode?
28
19
 
29
- # Delegate formatting to builder
30
- @builder.build(content, marker:, indent:)
20
+ parent_list = interface.find_parent(AST::List)
21
+ @builder.build(
22
+ content,
23
+ marker: determine_marker(parent_list),
24
+ indent: calculate_indent(interface),
25
+ )
31
26
  end
32
27
 
33
28
  private
34
29
 
35
- # Determine the list marker based on parent list type
36
30
  # @param parent_list [AST::List, nil]
37
31
  # @return [String]
38
32
  def determine_marker(parent_list)
39
33
  parent_list&.ordered? ? "1. " : "- "
40
34
  end
41
35
 
42
- # Calculate indentation for this list item
36
+ # Each ancestor List (excluding the direct parent) contributes
37
+ # indentation matching its own marker width:
38
+ # ordered = " " (3 chars for "1. "), unordered = " " (2 chars for "- ").
43
39
  # @param interface [RenderingInterface]
44
40
  # @return [String]
45
41
  def calculate_indent(interface)
46
- # Calculate indentation: count ancestor Lists (not including direct parent)
47
- # The direct parent List shouldn't add indentation, only grandparent Lists
48
42
  list_count = interface.count_parents(AST::List)
49
- # Subtract 1 because the immediate parent list doesn't contribute to indent
50
- ancestor_lists = list_count.positive? ? list_count - 1 : 0
51
-
52
- # Indentation width depends on markers of ancestor lists
53
- # Walk up the context to determine correct indentation
54
- calculate_indent_from_ancestors(interface.context, ancestor_lists)
55
- end
56
-
57
- # Calculate the correct indentation for nested list items
58
- # Each level matches the marker width of its parent: ordered="1. " (3 chars), unordered="- " (2 chars)
59
- # @param context [RenderContext] the rendering context
60
- # @param ancestor_count [Integer] number of ancestor lists
61
- # @return [String] the indentation string
62
- def calculate_indent_from_ancestors(context, ancestor_count)
63
- return "" if ancestor_count.zero?
64
-
65
- # Walk through parents from outermost to innermost to build indentation
66
- # Each ancestor contributes indentation based on ITS OWN marker width
67
- lists = context.parents.select { |p| p.is_a?(AST::List) }
68
-
69
- # Skip the immediate parent (last list in the array)
70
- ancestor_lists = lists[0...-1]
71
-
72
- # Build indentation string
73
- indent = ""
74
- ancestor_lists
75
- .first(ancestor_count)
76
- .each do |list|
77
- # Each level's indentation matches that list's marker width
78
- indent += list.ordered? ? " " : " "
79
- end
43
+ # Top-level list no ancestor lists contribute indent. The
44
+ # common case; short-circuit before any array allocations.
45
+ return "" if list_count <= 1
80
46
 
47
+ indent = +""
48
+ found = 0
49
+ interface.context.parents.each do |parent|
50
+ next unless parent.instance_of?(AST::List)
51
+ found += 1
52
+ # The immediate parent List is the LAST one in parents; its
53
+ # marker width is already accounted for by the item's own
54
+ # marker, so it doesn't contribute to indent.
55
+ break if found == list_count
56
+ indent << (parent.ordered? ? " " : " ")
57
+ end
81
58
  indent
82
59
  end
83
60
  end
@@ -4,31 +4,26 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Tag for rendering lists
8
7
  class ListTag < Tag
9
8
  def render(element, interface)
10
- # Create new context with this list as parent
11
9
  child_context = interface.with_parent(element)
12
10
 
13
- # Render children with updated context, dropping empty items
14
- rendered_items =
15
- element.children.filter_map do |child|
16
- rendered = interface.render_node(child, context: child_context)
17
- rendered unless rendered.strip.empty?
18
- end
11
+ content =
12
+ element
13
+ .children
14
+ .map { |child| interface.render_node(child, context: child_context) }
15
+ .join
19
16
 
20
- content = rendered_items.join
17
+ if interface.html_mode?
18
+ tag_name = element.ordered? ? "ol" : "ul"
19
+ return "<#{tag_name}>#{content}</#{tag_name}>"
20
+ end
21
21
 
22
- # Check if we're nested - either inside another List OR inside a ListItem
23
- has_list_parent = interface.has_parent?(AST::List)
24
- has_list_item_parent = interface.has_parent?(AST::ListItem)
25
- nested = has_list_parent || has_list_item_parent
22
+ nested = interface.has_parent?(AST::List) || interface.has_parent?(AST::ListItem)
26
23
 
27
24
  if nested
28
- # Nested list: add leading newline so it starts on its own line
29
25
  "\n#{content}"
30
26
  else
31
- # Top-level list: add spacing
32
27
  "\n\n#{content}\n\n"
33
28
  end
34
29
  end
@@ -25,7 +25,11 @@ module Markbridge
25
25
  # end
26
26
  class MentionTag < Tag
27
27
  def render(element, _interface)
28
- "@#{element.name}"
28
+ # Escape unconditionally: realistic Discourse usernames have no
29
+ # HTML-special characters so the Markdown path is unaffected,
30
+ # and the html_mode path needs the escape to splice safely into
31
+ # a raw HTML block.
32
+ "@#{HtmlEscaper.escape(element.name)}"
29
33
  end
30
34
  end
31
35
  end
@@ -11,6 +11,16 @@ module Markbridge
11
11
  child_context = interface.with_parent(element)
12
12
  content = interface.render_children(element, context: child_context)
13
13
 
14
+ if interface.html_mode?
15
+ # Inside a table cell the surrounding <td> already provides the
16
+ # block context, so a <p> wrapper just adds vertical margin —
17
+ # and if the paragraph contains a block element (e.g. a list),
18
+ # `<p><ul>…</ul></p>` is invalid per HTML5. Drop the wrapper.
19
+ return content if interface.has_parent?(AST::TableCell)
20
+
21
+ return "<p>#{content}</p>"
22
+ end
23
+
14
24
  # Paragraph followed by blank line (two newlines)
15
25
  "#{content}\n\n"
16
26
  end