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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 395eaa44d32b7fc497e7153b1648250d6abd675ef3fcafa7939c1282725683e2
4
- data.tar.gz: bd1c7215feeeb2a6fd6e0ebb213122c3652037eec277eec396191321e3e050e0
3
+ metadata.gz: cd32ca1996ac298962ade2f5728f0e0b9999ae0e2934b44474e003ca74888e32
4
+ data.tar.gz: 43334aa515a7326ac7e514e1ebd252f7517866a6fe254c350f7c51085ca26ba4
5
5
  SHA512:
6
- metadata.gz: ee252b9c8fdd75437e99e5cf2aa446c71c58f9091a1c1d33a0724feaa3e8239f1267e31154957b60a7d1b72c951ec0cf11367ecf8b13a15a5147e93f702a1d1f
7
- data.tar.gz: da07e85f2ba664a1239578c2fead05e2bbbf43a563f2e2da5c6d2f409192d7777cfe5875148bb2058fd1750838ff9f56556938ee8ca000ac918e1a608276d5df
6
+ metadata.gz: a2d9e6f8ed438bbe9425a33c4cf834e707bea7efc953bfd0d1c1aa53faea9b578ed54d7cb6a0fccb1a79ee433f43feb2a6858bf00f8fc19f8d55bef0237842f0
7
+ data.tar.gz: 90f49ebacb4bc8be03b27aa0b730aea8a4b6f6206a601e19cd7fdc6cfb56e23916dec63ae990ce05c525103afd29d2949b15e812d74662e53776dced970177f2
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../markbridge"
4
-
5
- require_relative "ast"
6
- require_relative "parsers/bbcode"
7
- require_relative "parsers/html"
8
- require_relative "parsers/text_formatter"
9
- require_relative "renderers/discourse"
3
+ require_relative "bbcode"
4
+ require_relative "html"
5
+ require_relative "mediawiki"
6
+ require_relative "textformatter"
@@ -20,7 +20,7 @@ module Markbridge
20
20
  # @param children [Array<Node>] optional array of initial child nodes
21
21
  def initialize(children = [])
22
22
  super()
23
- Array(children).each { |c| self << c }
23
+ children.each { |c| self << c }
24
24
  end
25
25
  end
26
26
  end
@@ -31,10 +31,10 @@ module Markbridge
31
31
  def <<(child)
32
32
  unless child.is_a?(Node)
33
33
  actual = child.nil? ? "nil" : child.class
34
- raise TypeError, "child must be a #{Markbridge::AST::Node} (got #{actual})"
34
+ raise TypeError, "<< on #{self.class} expected a #{Node}, got #{actual}"
35
35
  end
36
36
 
37
- if child.is_a?(Text) && children.last.is_a?(Text)
37
+ if child.instance_of?(Text) && children.last.instance_of?(Text)
38
38
  @children.last.merge(child)
39
39
  else
40
40
  @children << child
@@ -21,9 +21,9 @@ module Markbridge
21
21
  # @return [List] self for chaining
22
22
  # @raise [TypeError] if child is not a Node
23
23
  def <<(child)
24
- return self if child.is_a?(Text) && child.text.strip.empty?
24
+ return self if child.instance_of?(Text) && !child.text.match?(/\S/)
25
25
 
26
- if child.is_a?(ListItem)
26
+ if child.instance_of?(ListItem)
27
27
  super
28
28
  else
29
29
  @children << ListItem.new if @children.empty?
@@ -8,13 +8,11 @@ module Markbridge
8
8
  # table = AST::Table.new
9
9
  # table << AST::TableRow.new
10
10
  class Table < Element
11
- # Add a child node to the table.
12
- # Whitespace-only Text nodes are ignored.
13
- #
14
- # @param child [Node] the node to add
15
- # @return [Table] self for chaining
11
+ # HTML/BBCode parsers add a Text("\n") child for the whitespace
12
+ # between `<table>` and `<tr>` (and equivalent BBCode). Drop
13
+ # those so the AST contains only TableRow children.
16
14
  def <<(child)
17
- return self if child.is_a?(Text) && child.text.strip.empty?
15
+ return self if child.instance_of?(Text) && child.text.strip.empty?
18
16
 
19
17
  super
20
18
  end
@@ -26,13 +24,9 @@ module Markbridge
26
24
  # row = AST::TableRow.new
27
25
  # row << AST::TableCell.new
28
26
  class TableRow < Element
29
- # Add a child node to the row.
30
- # Whitespace-only Text nodes are ignored.
31
- #
32
- # @param child [Node] the node to add
33
- # @return [TableRow] self for chaining
27
+ # See Table#<< same whitespace skip for `<tr>` / `<td>` gaps.
34
28
  def <<(child)
35
- return self if child.is_a?(Text) && child.text.strip.empty?
29
+ return self if child.instance_of?(Text) && child.text.strip.empty?
36
30
 
37
31
  super
38
32
  end
@@ -19,9 +19,13 @@ module Markbridge
19
19
 
20
20
  # Create a new text node with the given content.
21
21
  #
22
+ # The text is copied into a fresh mutable buffer so that subsequent
23
+ # in-place mutations (e.g. via {#merge}) do not leak back to the caller's
24
+ # original string.
25
+ #
22
26
  # @param text [String] the text content
23
27
  def initialize(text)
24
- @text = +text
28
+ @text = text.dup
25
29
  end
26
30
 
27
31
  # Merge another text node's content into this one.
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../markbridge"
4
+ require_relative "parsers/bbcode"
@@ -12,10 +12,9 @@ module Markbridge
12
12
  private
13
13
 
14
14
  def missing_message(gem, feature)
15
- gem_name = gem.to_s
16
15
  [
17
- "#{gem_name.capitalize} is required for #{feature}.",
18
- "Add 'gem \"#{gem_name}\"' to your Gemfile or install it with 'gem install #{gem_name}'.",
16
+ "#{gem.capitalize} is required for #{feature}.",
17
+ "Add 'gem \"#{gem}\"' to your Gemfile or install it with 'gem install #{gem}'.",
19
18
  ].join(" ")
20
19
  end
21
20
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../markbridge"
4
+ require_relative "parsers/html"
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../markbridge"
4
+ require_relative "parsers/media_wiki"
@@ -9,27 +9,17 @@ module Markbridge
9
9
  end
10
10
 
11
11
  def handle_close(token:, context:, registry:, tokens: nil)
12
- return unless context.current.is_a?(AST::Element)
13
-
14
12
  current_handler = registry.handler_for_element(context.current)
15
13
  closing_handler = registry[token.tag]
16
14
 
17
15
  if current_handler == closing_handler
18
16
  context.pop
19
- elsif try_reorder(context:, tokens:, closing_handler:)
20
- # Reordering handled
21
17
  elsif @reconciler.try_auto_close(handler: closing_handler, context:)
22
18
  # Auto-close succeeded
23
19
  else
24
20
  context.add_child(AST::Text.new(token.source))
25
21
  end
26
22
  end
27
-
28
- private
29
-
30
- def try_reorder(context:, tokens:, closing_handler:)
31
- false
32
- end
33
23
  end
34
24
  end
35
25
  end
@@ -4,11 +4,24 @@ module Markbridge
4
4
  module BBCode
5
5
  module ClosingStrategies
6
6
  class Reordering < Base
7
- private
7
+ def handle_close(token:, context:, registry:, tokens: nil)
8
+ closing_handler = registry[token.tag]
8
9
 
9
- def try_reorder(context:, tokens:, closing_handler:)
10
- return false unless tokens
11
- @reconciler.try_reorder(handler: closing_handler, tokens:, context:)
10
+ # Fast path: when the current element matches the closing tag,
11
+ # pop and return. try_reorder/try_reopen are reconciliation
12
+ # strategies that only make sense when the close is mismatched;
13
+ # running them eagerly here costs a full elements_from_current
14
+ # walk plus two sort_by/compare passes on every close token,
15
+ # which dominates runtime for well-formed input.
16
+ if registry.handler_for_element(context.current) == closing_handler
17
+ context.pop
18
+ return
19
+ end
20
+
21
+ return if tokens && @reconciler.try_reorder(handler: closing_handler, tokens:, context:)
22
+ return if @reconciler.try_reopen(handler: closing_handler, context:, tokens:)
23
+
24
+ super
12
25
  end
13
26
  end
14
27
  end
@@ -18,15 +18,48 @@ module Markbridge
18
18
  # @param context [ParserState]
19
19
  # @return [Boolean] true if successful, false if auto-close not possible
20
20
  def try_auto_close(handler:, context:)
21
- match_depth = find_matching_handler_depth(handler, context)
21
+ count = auto_close_count(handler, context)
22
+ return false if count.nil?
23
+
24
+ count.times { context.pop }
25
+ context.auto_close!(count)
26
+
27
+ true
28
+ end
22
29
 
23
- return false if match_depth.nil? || match_depth >= MAX_AUTO_CLOSE_DEPTH
30
+ # Attempt to close the target tag and reopen any intervening
31
+ # auto-closeable tags so subsequent content continues in the same
32
+ # formatting context. Used when closing tags are not adjacent
33
+ # (e.g. "[b][i]x[/b] more[/i]").
34
+ #
35
+ # Reopening only makes sense when there is upcoming content that
36
+ # would benefit from the reopened context. If the next token is a
37
+ # closing tag (or nothing), plain auto-close is correct.
38
+ #
39
+ # @param handler [BaseHandler] the handler for the closing tag
40
+ # @param context [ParserState]
41
+ # @param tokens [Object, nil] the token stream (used to check that content follows)
42
+ # @return [Boolean] true if successful, false otherwise
43
+ def try_reopen(handler:, context:, tokens:)
44
+ case tokens&.peek
45
+ when TextToken, TagStartToken
46
+ nil # content follows -> reopening is justified
47
+ else
48
+ return false
49
+ end
50
+
51
+ match_depth = find_matching_handler_depth(handler, context)
52
+ return false if match_depth.nil? || match_depth.zero?
24
53
  return false unless all_auto_closeable?(context, match_depth)
25
54
 
55
+ intervening = context.elements_from_current(match_depth - 1).map(&:class)
56
+
26
57
  count = match_depth + 1
27
58
  count.times { context.pop }
28
59
  context.auto_close!(count)
29
60
 
61
+ intervening.reverse_each { |klass| context.push(klass.new) }
62
+
30
63
  true
31
64
  end
32
65
 
@@ -38,24 +71,17 @@ module Markbridge
38
71
  # @return [Boolean] true if successful, false otherwise
39
72
  def try_reorder(handler:, tokens:, context:)
40
73
  match_depth = find_matching_handler_depth(handler, context)
41
- return false if match_depth.nil? || match_depth >= MAX_AUTO_CLOSE_DEPTH
42
-
43
74
  opening_handlers = collect_auto_closeable_handlers(context, match_depth)
44
- return false if opening_handlers.empty?
45
75
 
46
- closing_handlers = [handler]
47
- closing_handlers.concat(peek_closing_handlers(tokens, opening_handlers.size - 1))
48
- return false if closing_handlers.size != opening_handlers.size
76
+ closing_handlers = [handler, *peek_closing_handlers(tokens, opening_handlers.size - 1)]
49
77
  unless opening_handlers.sort_by(&:object_id) == closing_handlers.sort_by(&:object_id)
50
78
  return false
51
79
  end
52
80
 
53
- # Consume the extra closing tags
54
- (opening_handlers.size - 1).times do
55
- peeked = tokens.peek
56
- break unless peeked.is_a?(TagEndToken)
57
- tokens.next
58
- end
81
+ # Consume the extra closing tags. We've already verified via
82
+ # peek_closing_handlers that the next opening_handlers.size - 1
83
+ # tokens are TagEndTokens with handlers we accept.
84
+ (opening_handlers.size - 1).times { tokens.next }
59
85
 
60
86
  opening_handlers.each { context.pop }
61
87
  context.auto_close!(opening_handlers.size)
@@ -65,15 +91,26 @@ module Markbridge
65
91
 
66
92
  private
67
93
 
68
- def find_matching_handler_depth(handler, context)
69
- elements = context.elements_from_current(MAX_AUTO_CLOSE_DEPTH)
94
+ # Number of stack elements to pop in order to close `handler`, or nil
95
+ # when the handler is not on the stack within MAX_AUTO_CLOSE_DEPTH or
96
+ # any intervening element is not auto-closeable.
97
+ def auto_close_count(handler, context)
98
+ context
99
+ .elements_from_current(MAX_AUTO_CLOSE_DEPTH - 1)
100
+ .each_with_index do |element, depth|
101
+ return nil unless @registry.auto_closeable?(element.class)
102
+ return depth + 1 if @registry.handler_for_element(element) == handler
103
+ end
70
104
 
71
- elements.each_with_index do |element, depth|
72
- next unless element.is_a?(AST::Element)
105
+ nil
106
+ end
73
107
 
74
- element_handler = @registry.handler_for_element(element)
75
- return depth if element_handler == handler
76
- end
108
+ def find_matching_handler_depth(handler, context)
109
+ context
110
+ .elements_from_current(MAX_AUTO_CLOSE_DEPTH - 1)
111
+ .each_with_index do |element, depth|
112
+ return depth if @registry.handler_for_element(element) == handler
113
+ end
77
114
 
78
115
  nil
79
116
  end
@@ -84,35 +121,18 @@ module Markbridge
84
121
  .all? { |element| @registry.auto_closeable?(element.class) }
85
122
  end
86
123
 
124
+ # Caller must have verified all_auto_closeable?(context, target_depth) first.
87
125
  def collect_auto_closeable_handlers(context, target_depth)
88
- handlers = []
89
-
90
126
  context
91
127
  .elements_from_current(target_depth)
92
- .each do |element|
93
- return [] unless @registry.auto_closeable?(element.class)
94
-
95
- handler = @registry.handler_for_element(element)
96
- handlers << handler if handler
97
- end
98
-
99
- handlers
128
+ .map { |element| @registry.handler_for_element(element) }
100
129
  end
101
130
 
102
131
  def peek_closing_handlers(tokens, max_count)
103
- handlers = []
104
- peeked_tokens = tokens.peek_ahead(max_count)
105
-
106
- peeked_tokens.each do |token|
107
- break unless token.is_a?(TagEndToken)
108
-
109
- handler = @registry[token.tag]
110
- break unless handler
111
-
112
- handlers << handler
113
- end
114
-
115
- handlers
132
+ tokens
133
+ .peek_ahead(max_count)
134
+ .take_while { |token| token.instance_of?(TagEndToken) }
135
+ .map { |token| @registry[token.tag] }
116
136
  end
117
137
  end
118
138
  end
@@ -5,26 +5,33 @@ module Markbridge
5
5
  module BBCode
6
6
  # Registry of BBCode tag handlers
7
7
  class HandlerRegistry
8
+ include Enumerable
9
+
8
10
  attr_writer :closing_strategy
9
11
 
10
12
  def initialize(closing_strategy: nil)
11
13
  @handlers = {}
12
- @normalized_tag_cache = {}
13
14
  @element_handlers = {}
14
15
  @auto_closeable_elements = Set.new
15
16
  @closing_strategy = closing_strategy
16
17
  end
17
18
 
19
+ # Iterate over registered (tag_name, handler) pairs.
20
+ # Useful for debugging custom registries — e.g. confirming an override
21
+ # has stuck. Iteration order matches registration order.
22
+ # @yieldparam tag_name [String]
23
+ # @yieldparam handler [BaseHandler]
24
+ # @return [Enumerator] when no block is given
25
+ def each(&block)
26
+ @handlers.each(&block)
27
+ end
28
+
18
29
  # Register a handler for one or more tag names and associate it with an element class
19
30
  # @param tag_names [String, Array<String>] tag name(s) to register
20
31
  # @param handler [BaseHandler] the handler instance
21
32
  def register(tag_names, handler)
22
33
  element_class = handler.element_class
23
- Array(tag_names).each do |tag_name|
24
- normalized = tag_name.to_s.downcase
25
- @handlers[normalized] = handler
26
- @normalized_tag_cache[tag_name.to_s] = normalized
27
- end
34
+ Array(tag_names).each { |tag_name| @handlers[tag_name.to_s.downcase] = handler }
28
35
  @element_handlers[element_class] = handler
29
36
  @auto_closeable_elements << element_class if handler.auto_closeable?
30
37
  self
@@ -34,9 +41,7 @@ module Markbridge
34
41
  # @param tag_name [String]
35
42
  # @return [BaseHandler, nil]
36
43
  def [](tag_name)
37
- tag_str = tag_name.to_s
38
- normalized = @normalized_tag_cache[tag_str] || tag_str.downcase
39
- @handlers[normalized]
44
+ @handlers[tag_name.to_s.downcase]
40
45
  end
41
46
 
42
47
  # Get handler for an element instance
@@ -61,7 +66,12 @@ module Markbridge
61
66
  @closing_strategy&.handle_close(token:, context:, registry: self, tokens:)
62
67
  end
63
68
 
64
- # Create the default handler registry with common BBCode tags
69
+ # Create the default handler registry with common BBCode tags.
70
+ #
71
+ # Each call returns a *fresh* instance — mutations made to one will
72
+ # not be visible to another. If you want a process-wide singleton,
73
+ # use {Markbridge.default_handlers} instead, which memoizes.
74
+ #
65
75
  # @param closing_strategy [Object, nil] optional closing strategy to apply, defaults to Reordering strategy
66
76
  # @return [HandlerRegistry]
67
77
  def self.default(closing_strategy: nil)
@@ -95,7 +105,7 @@ module Markbridge
95
105
  )
96
106
 
97
107
  # Code handlers (raw content)
98
- registry.register(%w[code pre tt], Handlers::RawHandler.new(AST::Code))
108
+ registry.register(%w[code pre tt], Handlers::CodeHandler.new)
99
109
 
100
110
  # Image handler
101
111
  registry.register("img", Handlers::ImageHandler.new)
@@ -38,21 +38,26 @@ module Markbridge
38
38
  @collector.collect(token.tag, tokens).content
39
39
  end
40
40
 
41
+ CLOSING_TAG_PEEK_DEPTH = 100
42
+ private_constant :CLOSING_TAG_PEEK_DEPTH
43
+
41
44
  def closing_tag_ahead?(tag, tokens)
42
- tokens.peek_ahead(100).any? { |token| token.is_a?(TagEndToken) && token.tag == tag }
45
+ tokens
46
+ .peek_ahead(CLOSING_TAG_PEEK_DEPTH)
47
+ .any? { |token| token.instance_of?(TagEndToken) && token.tag == tag }
43
48
  end
44
49
 
45
50
  def build_attachment(token:, content:)
46
51
  attrs = normalize_attrs(token.attrs)
47
52
  option = attrs[:option]
48
- body = presence(content&.strip)
53
+ body = presence(content)
49
54
 
50
55
  id = preferred_id(attrs)
51
56
  index = preferred_index(attrs)
52
57
  filename = attrs[:filename]
53
58
  alt = attrs[:alt]
54
59
 
55
- index ||= option if option && numeric?(option)
60
+ index ||= option if numeric?(option)
56
61
  id, filename = apply_body_content(body:, id:, index:, filename:)
57
62
 
58
63
  AST::Attachment.new(id:, index:, filename:, alt:)
@@ -63,8 +68,6 @@ module Markbridge
63
68
  end
64
69
 
65
70
  def apply_body_content(body:, id:, index:, filename:)
66
- return id, filename unless body
67
-
68
71
  if id.nil?
69
72
  return body, filename if index.nil?
70
73
  return body, filename if numeric?(body)
@@ -75,27 +78,29 @@ module Markbridge
75
78
  [id, filename]
76
79
  end
77
80
 
81
+ # Caller must have applied normalize_attrs first (whitespace already stripped).
78
82
  def preferred_id(attrs)
79
- presence(attrs[:msg]) || presence(attrs[:id])
83
+ attrs[:msg] || attrs[:id]
80
84
  end
81
85
 
86
+ # Caller must have applied normalize_attrs first.
82
87
  def preferred_index(attrs)
83
- explicit_index = presence(attrs[:index])
84
- smf_index = presence(attrs[:id]) if attrs[:msg]
85
-
86
- explicit_index || smf_index
88
+ attrs[:index] || (attrs[:id] if attrs[:msg])
87
89
  end
88
90
 
91
+ # Callers only ever pass String or nil (via #normalize_attrs
92
+ # over token.attrs, which the BBCode scanner populates with
93
+ # String values exclusively), so no defensive non-String
94
+ # passthrough is needed here or in #numeric?.
89
95
  def presence(value)
90
96
  return if value.nil?
91
- return value unless value.is_a?(String)
92
97
 
93
98
  stripped = value.strip
94
99
  stripped unless stripped.empty?
95
100
  end
96
101
 
97
102
  def numeric?(value)
98
- value.is_a?(String) && value.match?(/\A\d+\z/)
103
+ value&.match?(/\A\d+\z/)
99
104
  end
100
105
  end
101
106
  end
@@ -5,36 +5,26 @@ module Markbridge
5
5
  module BBCode
6
6
  module Handlers
7
7
  class BaseHandler
8
- # Default opening behavior: create element and push to context
9
- # Subclasses should override this method
10
8
  # @param token [TagStartToken]
11
9
  # @param context [ParserState]
12
10
  # @param registry [HandlerRegistry]
13
11
  # @param tokens [Enumerator, nil]
14
- # @return [void]
15
12
  def on_open(token:, context:, registry:, tokens: nil)
16
- # Default: do nothing, subclasses override
17
13
  end
18
14
 
19
- # Default closing behavior: pop matching element from stack
20
- # Subclasses can override or call super for custom behavior
21
15
  # @param token [TagEndToken]
22
16
  # @param context [ParserState]
23
17
  # @param registry [HandlerRegistry]
24
18
  # @param tokens [PeekableEnumerator, nil]
25
- # @return [void]
26
19
  def on_close(token:, context:, registry:, tokens: nil)
27
20
  registry.close_element(token:, context:, tokens:)
28
21
  end
29
22
 
30
- # Whether elements created by this handler can be auto-closed
31
23
  # @return [Boolean]
32
24
  def auto_closeable?
33
25
  false
34
26
  end
35
27
 
36
- # The element class created by this handler
37
- # Subclasses must expose this via attr_reader :element_class
38
28
  # @return [Class]
39
29
  attr_reader :element_class
40
30
  end
@@ -4,16 +4,12 @@ module Markbridge
4
4
  module Parsers
5
5
  module BBCode
6
6
  module Handlers
7
- # Handler for [code]...[/code] tags
8
- #
9
- # Preserves content as-is without parsing nested BBCode
10
- # Inherits from RawHandler using AST::Code element
11
- #
12
- # Example:
13
- # [code=python]
14
- # def hello_world():
15
- # print("Hello, world!")
16
- # [/code]
7
+ # @example
8
+ # # [code=python]
9
+ # # def hello_world
10
+ # # puts "hi"
11
+ # # end
12
+ # # [/code]
17
13
  class CodeHandler < RawHandler
18
14
  def initialize
19
15
  super(AST::Code)
@@ -22,29 +22,21 @@ module Markbridge
22
22
  AST::Image.new(src: content, width:, height:)
23
23
  end
24
24
 
25
- def extract_dimensions(token)
26
- width = sanitize_dimension(token.attrs[:width])
27
- height = sanitize_dimension(token.attrs[:height])
25
+ OPTION_DIMENSIONS_PATTERN = /\A(?<width>\d+)(?:x(?<height>\d+))?\z/i
26
+ private_constant :OPTION_DIMENSIONS_PATTERN
28
27
 
29
- option = token.attrs[:option]
30
- if option&.match?(/^\d+x\d+$/i)
31
- parts = option.split("x", 2)
32
- width = sanitize_dimension(parts[0])
33
- height = sanitize_dimension(parts[1])
34
- elsif option&.match?(/^\d+$/)
35
- width = sanitize_dimension(option)
36
- end
28
+ def extract_dimensions(token)
29
+ option_match = OPTION_DIMENSIONS_PATTERN.match(token.attrs[:option])
37
30
 
38
- [width, height]
31
+ [
32
+ sanitize_dimension(option_match&.[](:width) || token.attrs[:width]),
33
+ sanitize_dimension(option_match&.[](:height) || token.attrs[:height]),
34
+ ]
39
35
  end
40
36
 
41
- # Convert dimension to positive integer or nil
42
- # Handles string input from BBCode attributes
43
37
  def sanitize_dimension(value)
44
- return nil if value.nil?
45
-
46
38
  dim = value.to_i
47
- dim.positive? ? dim : nil
39
+ dim if dim.positive?
48
40
  end
49
41
  end
50
42
  end
@@ -11,7 +11,6 @@ module Markbridge
11
11
  end
12
12
 
13
13
  def on_open(token:, context:, registry:, tokens: nil)
14
- # Check if ordered: explicit ol/olist tag, or type=1, or option=1
15
14
  ordered =
16
15
  %w[ol olist].include?(token.tag) || token.attrs[:type] == "1" ||
17
16
  token.attrs[:option] == "1"
@@ -21,10 +20,7 @@ module Markbridge
21
20
  end
22
21
 
23
22
  def on_close(token:, context:, registry:, tokens: nil)
24
- # Auto-close open list item before closing list
25
- context.pop if context.current.is_a?(AST::ListItem)
26
-
27
- # Then use default closing behavior
23
+ context.pop if context.current.instance_of?(AST::ListItem)
28
24
  super
29
25
  end
30
26
 
@@ -11,8 +11,7 @@ module Markbridge
11
11
  end
12
12
 
13
13
  def on_open(token:, context:, registry:, tokens: nil)
14
- # Auto-close previous list item if opening a new one
15
- context.pop if context.current.is_a?(AST::ListItem)
14
+ context.pop if context.current.instance_of?(AST::ListItem)
16
15
 
17
16
  element = AST::ListItem.new
18
17
  context.push(element, token:)