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
@@ -21,10 +21,13 @@ module Markbridge
21
21
  # parser = Markbridge::Parsers::MediaWiki::Parser.new
22
22
  # ast = parser.parse("'''bold''' and ''italic''")
23
23
  class Parser
24
- def initialize
25
- @document = nil
26
- @inline_parser = nil
27
- @list_stack = []
24
+ # @param inline_tag_registry [InlineTagRegistry, nil] custom registry or use default
25
+ # @yield [InlineTagRegistry] optional block to customize the default registry
26
+ def initialize(inline_tag_registry: nil, &block)
27
+ # InlineParser falls back to InlineTagRegistry.default when this is
28
+ # nil, so we don't need to materialise it here.
29
+ @inline_tag_registry =
30
+ block_given? ? InlineTagRegistry.build_from_default(&block) : inline_tag_registry
28
31
  end
29
32
 
30
33
  # Parse MediaWiki wikitext into an AST Document.
@@ -33,14 +36,13 @@ module Markbridge
33
36
  # @return [AST::Document]
34
37
  def parse(input)
35
38
  normalized = normalize_line_endings(input)
36
- lines = normalized.split("\n", -1)
39
+ lines = normalized.split("\n")
37
40
 
38
41
  @document = AST::Document.new
39
- @inline_parser = InlineParser.new
42
+ @inline_parser = InlineParser.new(inline_tag_registry: @inline_tag_registry)
40
43
  @list_stack = []
41
44
 
42
45
  process_lines(lines)
43
- close_open_lists
44
46
  @document
45
47
  end
46
48
 
@@ -60,7 +62,7 @@ module Markbridge
60
62
  def process_lines(lines)
61
63
  i = 0
62
64
  while i < lines.length
63
- line = lines[i]
65
+ line = lines.fetch(i)
64
66
 
65
67
  if heading_line?(line)
66
68
  close_open_lists
@@ -90,12 +92,15 @@ module Markbridge
90
92
  end
91
93
  end
92
94
 
95
+ HEADING_LINE = /\A={1,6}(?:[^=].*[^=]={1,6}|[^=]+=*)\s*\z/
96
+ private_constant :HEADING_LINE
97
+
93
98
  # Check if a line is a heading (starts and ends with = signs).
94
99
  #
95
100
  # @param line [String]
96
101
  # @return [Boolean]
97
102
  def heading_line?(line)
98
- line.match?(/\A={1,6}[^=].*[^=]={1,6}\s*\z/) || line.match?(/\A={1,6}[^=]+=*\s*\z/)
103
+ line.match?(HEADING_LINE)
99
104
  end
100
105
 
101
106
  # Check if a line is a horizontal rule (4+ dashes).
@@ -135,9 +140,13 @@ module Markbridge
135
140
  # @param line [String]
136
141
  # @return [Boolean]
137
142
  def blank_line?(line)
138
- line.strip.empty?
143
+ !line.match?(/\S/)
139
144
  end
140
145
 
146
+ HEADING_LEVEL_PREFIX = /\A={1,6}/
147
+ HEADING_LEVEL_SUFFIX = /\s*={1,6}\s*\z/
148
+ private_constant :HEADING_LEVEL_PREFIX, :HEADING_LEVEL_SUFFIX
149
+
141
150
  # Check if a line starts a table ({|).
142
151
  #
143
152
  # @param line [String]
@@ -253,23 +262,16 @@ module Markbridge
253
262
  end
254
263
 
255
264
  parts << buffer
256
- parts
257
265
  end
258
266
 
259
267
  # Process a heading line and add it to the document.
260
268
  #
261
269
  # @param line [String]
262
270
  def process_heading(line)
263
- stripped = line.strip
264
- # Count leading = signs for level
265
- level = 0
266
- level += 1 while level < stripped.length && stripped[level] == "="
267
- level = [level, 6].min
271
+ leading = line[HEADING_LEVEL_PREFIX]
272
+ content = line[leading.length..].sub(HEADING_LEVEL_SUFFIX, "").strip
268
273
 
269
- # Remove leading/trailing = signs and whitespace
270
- content = stripped[level..].sub(/\s*={1,6}\s*\z/, "").strip
271
-
272
- heading = AST::Heading.new(level:)
274
+ heading = AST::Heading.new(level: leading.length)
273
275
  @inline_parser.parse(content, parent: heading)
274
276
  @document << heading
275
277
  end
@@ -278,48 +280,28 @@ module Markbridge
278
280
  #
279
281
  # @param line [String]
280
282
  def process_list_item(line)
281
- # Count prefix characters to determine depth and type
282
- prefix = +""
283
- i = 0
284
- while i < line.length && (line[i] == "*" || line[i] == "#")
285
- prefix << line[i]
286
- i += 1
287
- end
288
-
289
- content = line[i..].strip
290
- desired_depth = prefix.length
283
+ prefix = line[/\A[*#]+/]
284
+ content = line[prefix.length..].strip
291
285
 
292
- # Adjust list stack to match desired depth
293
- reconcile_list_stack(prefix, desired_depth)
286
+ reconcile_list_stack(prefix)
294
287
 
295
- # Create list item and add content
296
288
  item = AST::ListItem.new
297
289
  @inline_parser.parse(content, parent: item)
298
- @list_stack.last[:list] << item
290
+ @list_stack.last.fetch(:list) << item
299
291
  end
300
292
 
301
293
  # Reconcile the list stack with the desired prefix.
302
294
  # Opens new lists or closes existing ones as needed.
303
295
  #
304
296
  # @param prefix [String] the list prefix characters (e.g., "**#")
305
- # @param desired_depth [Integer]
306
- def reconcile_list_stack(prefix, desired_depth)
307
- # Close lists that no longer match
308
- @list_stack.pop while @list_stack.length > desired_depth
309
-
310
- # Check if existing stack entries match the type at each level
311
- prefix.chars.each_with_index do |char, idx|
312
- ordered = char == "#"
313
- if idx < @list_stack.length
314
- # If type changed at this level, close from here and reopen
315
- if @list_stack[idx][:ordered] != ordered
316
- @list_stack.pop while @list_stack.length > idx
317
- open_new_list(ordered, idx)
318
- end
319
- else
320
- open_new_list(ordered, idx)
321
- end
322
- end
297
+ def reconcile_list_stack(prefix)
298
+ keep = matching_prefix_depth(prefix)
299
+ @list_stack.pop while @list_stack.length > keep
300
+ prefix[keep..].each_char { |char| open_new_list(char == "#", @list_stack.length) }
301
+ end
302
+
303
+ def matching_prefix_depth(prefix)
304
+ @list_stack.take_while.with_index { |entry, i| entry.fetch(:char) == prefix[i] }.length
323
305
  end
324
306
 
325
307
  # Open a new list at the given depth.
@@ -332,13 +314,12 @@ module Markbridge
332
314
  if depth.zero?
333
315
  @document << list
334
316
  else
335
- # Nest inside the last item of the parent list
336
- parent_list = @list_stack.last[:list]
317
+ parent_list = @list_stack.last.fetch(:list)
337
318
  parent_list << AST::ListItem.new if parent_list.children.empty?
338
319
  parent_list.children.last << list
339
320
  end
340
321
 
341
- @list_stack << { list:, ordered: }
322
+ @list_stack << { list:, char: ordered ? "#" : "*" }
342
323
  end
343
324
 
344
325
  # Close all open lists.
@@ -352,45 +333,39 @@ module Markbridge
352
333
  # @param start_index [Integer]
353
334
  # @return [Integer] the last index consumed (will be incremented by caller)
354
335
  def process_preformatted_block(lines, start_index)
355
- content_lines = []
356
- i = start_index
357
-
358
- while i < lines.length && lines[i].start_with?(" ")
359
- content_lines << lines[i][1..] # Remove leading space
360
- i += 1
361
- end
336
+ consumed = lines[start_index..].take_while { |line| line.start_with?(" ") }
337
+ content = consumed.map { |line| line[1..] }.join("\n")
362
338
 
363
339
  code = AST::Code.new
364
- code << AST::Text.new(content_lines.join("\n"))
340
+ code << AST::Text.new(content)
365
341
  @document << code
366
342
 
367
- i - 1 # Return last consumed index
343
+ start_index + consumed.length - 1
368
344
  end
369
345
 
346
+ PRE_TAG_OPEN = /\A\s*<pre\b[^>]*>/i
347
+ PRE_TAG_CLOSE = %r{</pre\s*>}i
348
+ PRE_TAG_CLOSE_TRAILING = %r{</pre\s*>\s*\z}i
349
+ private_constant :PRE_TAG_OPEN, :PRE_TAG_CLOSE, :PRE_TAG_CLOSE_TRAILING
350
+
370
351
  # Process a <pre>...</pre> block that may span multiple lines.
371
352
  #
372
353
  # @param lines [Array<String>]
373
354
  # @param start_index [Integer]
374
355
  # @return [Integer] the last index consumed
375
356
  def process_pre_tag_block(lines, start_index)
376
- combined = +""
377
- i = start_index
357
+ consumed = lines[start_index..].take_while { |line| !line.match?(PRE_TAG_CLOSE) }
358
+ terminated = consumed.length < lines.length - start_index
359
+ consumed << lines.fetch(start_index + consumed.length) if terminated
378
360
 
379
- while i < lines.length
380
- combined << lines[i]
381
- break if lines[i].match?(%r{</pre\s*>}i)
382
- combined << "\n"
383
- i += 1
384
- end
385
-
386
- # Extract content between <pre> and </pre>
387
- content = combined.sub(/\A\s*<pre\b[^>]*>/i, "").sub(%r{</pre\s*>\s*\z}i, "")
361
+ combined = consumed.join("\n")
362
+ content = combined.sub(PRE_TAG_OPEN, "").sub(PRE_TAG_CLOSE_TRAILING, "")
388
363
 
389
364
  code = AST::Code.new
390
365
  code << AST::Text.new(content)
391
366
  @document << code
392
367
 
393
- i
368
+ start_index + consumed.length - 1
394
369
  end
395
370
 
396
371
  # Process a line as inline content wrapped in a paragraph.
@@ -4,6 +4,7 @@
4
4
  require_relative "../ast"
5
5
 
6
6
  # Parser components
7
+ require_relative "media_wiki/inline_tag_registry"
7
8
  require_relative "media_wiki/inline_parser"
8
9
  require_relative "media_wiki/parser"
9
10
 
@@ -18,14 +18,6 @@ module Markbridge
18
18
  # r.register("CUSTOM", MyCustomHandler.new)
19
19
  # r.register("B", SimpleHandler.new(AST::Bold)) # Override default
20
20
  # end
21
- #
22
- # @example Using lambdas for simple mappings
23
- # registry = HandlerRegistry.new
24
- # registry.register("CUSTOM", ->(element:, parent:) {
25
- # node = AST::Custom.new
26
- # parent << node
27
- # node # Return node to process children
28
- # })
29
21
  class HandlerRegistry
30
22
  # Create a new registry with default mappings
31
23
  # @return [HandlerRegistry]
@@ -46,11 +38,7 @@ module Markbridge
46
38
 
47
39
  # Register a handler for an element
48
40
  # @param element_name [String] XML element name (case-insensitive)
49
- # @param handler [#process, #call] Handler object or lambda
50
- # @example With handler object
51
- # registry.register("CUSTOM", MyCustomHandler.new)
52
- # @example With lambda
53
- # registry.register("CUSTOM", ->(element:, parent:) { ... })
41
+ # @param handler [#process] Handler object responding to `process(element:, parent:)`
54
42
  def register(element_name, handler)
55
43
  @mappings[element_name.upcase] = handler
56
44
  end
@@ -69,16 +57,7 @@ module Markbridge
69
57
  def process_element(element, parent)
70
58
  tag_name = element.name.upcase
71
59
  handler = @mappings[tag_name]
72
- return nil unless handler
73
-
74
- # Call handler and return its result (element or nil)
75
- if handler.respond_to?(:process)
76
- handler.process(element:, parent:)
77
- elsif handler.respond_to?(:call)
78
- handler.call(element:, parent:)
79
- else
80
- raise ArgumentError, "Handler must respond to :process or :call"
81
- end
60
+ handler&.process(element:, parent:)
82
61
  end
83
62
 
84
63
  # Register all default s9e/TextFormatter element mappings
@@ -96,22 +75,13 @@ module Markbridge
96
75
  register("QUOTE", Handlers::QuoteHandler.new)
97
76
  register("IMG", Handlers::ImageHandler.new)
98
77
  register("LIST", Handlers::ListHandler.new)
99
- register(
100
- "COLOR",
101
- Handlers::AttributeHandler.new(AST::Color, attribute: :color, param: :color),
102
- )
103
- register(
104
- "SIZE",
105
- Handlers::AttributeHandler.new(AST::Size, attribute: :size, param: :size),
106
- )
78
+ register("COLOR", Handlers::AttributeHandler.new(AST::Color, attribute: :color))
79
+ register("SIZE", Handlers::AttributeHandler.new(AST::Size, attribute: :size))
107
80
  register(
108
81
  "ALIGN",
109
82
  Handlers::AttributeHandler.new(AST::Align, attribute: :align, param: :alignment),
110
83
  )
111
- register(
112
- "SPOILER",
113
- Handlers::AttributeHandler.new(AST::Spoiler, attribute: :title, param: :title),
114
- )
84
+ register("SPOILER", Handlers::AttributeHandler.new(AST::Spoiler, attribute: :title))
115
85
  register("ATTACHMENT", Handlers::AttachmentHandler.new)
116
86
  register("ATTACH", Handlers::AttachmentHandler.new)
117
87
 
@@ -127,8 +97,6 @@ module Markbridge
127
97
  register("TR", Handlers::SimpleHandler.new(AST::TableRow))
128
98
  register("TD", Handlers::TableCellHandler.new)
129
99
  register("TH", Handlers::TableCellHandler.new)
130
-
131
- self
132
100
  end
133
101
  end
134
102
  end
@@ -57,11 +57,8 @@ module Markbridge
57
57
  document = AST::Document.new
58
58
  process_node(root, document)
59
59
  document
60
- rescue Nokogiri::XML::SyntaxError => e
61
- # Invalid XML - treat as plain text
62
- document = AST::Document.new
63
- document << AST::Text.new(input)
64
- document
60
+ rescue Nokogiri::XML::SyntaxError
61
+ AST::Document.new << AST::Text.new(input)
65
62
  end
66
63
 
67
64
  # Process children of an XML element (public for handler access)
@@ -122,9 +119,7 @@ module Markbridge
122
119
  # @param ast_parent [AST::Element]
123
120
  def process_text(text_node, ast_parent)
124
121
  text = text_node.content
125
- return if text.strip.empty?
126
-
127
- ast_parent << AST::Text.new(text)
122
+ ast_parent << AST::Text.new(text) if text.match?(/\S/)
128
123
  end
129
124
  end
130
125
  end
@@ -31,11 +31,12 @@ module Markbridge
31
31
 
32
32
  def initialize
33
33
  @in_fenced_block = false
34
- @fence_char = nil
35
- @fence_length = 0
36
34
  @in_indented_block = false
37
35
  @in_inline_code = false
38
- @inline_delimiter = nil
36
+ # @fence_char / @fence_length / @inline_delimiter are set by
37
+ # open_fence / open_inline before any helper reads them;
38
+ # they're only consulted when the corresponding in_X flag is
39
+ # true, which requires a prior open_* call.
39
40
  end
40
41
 
41
42
  # Check if currently inside any code context
@@ -53,9 +54,10 @@ module Markbridge
53
54
  return nil unless line_start
54
55
 
55
56
  input_length = input.length
56
- scan_pos = skip_leading_spaces(input, pos, input_length)
57
- return nil if scan_pos >= input_length
58
-
57
+ scan_pos = skip_leading_spaces(input, pos)
58
+ # No `scan_pos >= input_length` guard: `input[input_length]` is
59
+ # nil, and `nil == "`"` / `nil == "~"` are both false so the
60
+ # next check returns nil anyway.
59
61
  fence_char = input[scan_pos]
60
62
  return nil unless fence_char == "`" || fence_char == "~"
61
63
 
@@ -106,10 +108,11 @@ module Markbridge
106
108
  # @return [Integer, nil] end position after inline code, or nil if not at boundary
107
109
  def check_inline_boundary(input, pos)
108
110
  return nil if @in_fenced_block || @in_indented_block
111
+ # No `pos >= input_length` guard: `input[input_length]` is nil,
112
+ # and `nil != "`"` is true so the next check returns nil anyway.
113
+ return nil if input[pos] != "`"
109
114
 
110
115
  input_length = input.length
111
- return nil if pos >= input_length || input[pos] != "`"
112
-
113
116
  if @in_inline_code
114
117
  try_close_inline(input, pos, input_length)
115
118
  else
@@ -120,10 +123,10 @@ module Markbridge
120
123
  private
121
124
 
122
125
  # Skip up to 3 leading spaces of indentation.
123
- def skip_leading_spaces(input, pos, input_length)
126
+ def skip_leading_spaces(input, pos)
124
127
  scan_pos = pos
125
128
  spaces = 0
126
- while spaces < 3 && scan_pos < input_length && input[scan_pos] == " "
129
+ while spaces < 3 && input[scan_pos] == " "
127
130
  spaces += 1
128
131
  scan_pos += 1
129
132
  end
@@ -148,9 +151,10 @@ module Markbridge
148
151
  scan_pos += 1 while scan_pos < input_length && input[scan_pos] == " "
149
152
  return nil unless scan_pos >= input_length || input[scan_pos] == "\n"
150
153
 
154
+ # @fence_char / @fence_length are not reset here: they are only
155
+ # consulted while @in_fenced_block is true, and open_fence
156
+ # overwrites them on the next opening.
151
157
  @in_fenced_block = false
152
- @fence_char = nil
153
- @fence_length = 0
154
158
  pos_after_line(scan_pos, input_length)
155
159
  end
156
160
 
@@ -174,8 +178,10 @@ module Markbridge
174
178
  next_pos = pos + delimiter_length
175
179
  return nil if next_pos < input_length && input[next_pos] == "`"
176
180
 
181
+ # @inline_delimiter is not reset here: it is only consulted
182
+ # while @in_inline_code is true, and open_inline overwrites it
183
+ # on the next opening.
177
184
  @in_inline_code = false
178
- @inline_delimiter = nil
179
185
  next_pos
180
186
  end
181
187
 
@@ -196,14 +202,15 @@ module Markbridge
196
202
 
197
203
  public
198
204
 
199
- # Reset the tracker state
205
+ # Reset the tracker state. The @fence_char / @fence_length /
206
+ # @inline_delimiter companions are not cleared: they're only
207
+ # consulted while the corresponding in_* flag is true, and
208
+ # open_fence / open_inline overwrites them on the next
209
+ # opening (same pattern as try_close_fence / try_close_inline).
200
210
  def reset!
201
211
  @in_fenced_block = false
202
- @fence_char = nil
203
- @fence_length = 0
204
212
  @in_indented_block = false
205
213
  @in_inline_code = false
206
- @inline_delimiter = nil
207
214
  end
208
215
  end
209
216
  end
@@ -38,29 +38,23 @@ module Markbridge
38
38
  !prev_char.match?(/\w/)
39
39
  end
40
40
 
41
- # Helper to extract a word starting at position
41
+ WORD_PATTERN = /\A[\w\-]*/
42
+ private_constant :WORD_PATTERN
43
+
44
+ # Helper to extract a word starting at position. Caller must ensure
45
+ # pos is within input bounds (`pos <= input.length`).
42
46
  # @param input [String] the input string
43
47
  # @param pos [Integer] starting position
44
48
  # @return [String] the word (may be empty)
45
49
  def extract_word(input, pos)
46
- word = +""
47
- while pos < input.length && input[pos].match?(/[\w\-]/)
48
- word << input[pos]
49
- pos += 1
50
- end
51
- word
50
+ input[pos..].match(WORD_PATTERN)[0]
52
51
  end
53
52
 
54
- # Parse key="value" or key='value' attribute pairs from a string
55
- # @param attr_string [String, nil] the attribute string to parse
53
+ # Parse key="value" or key='value' attribute pairs from a string.
54
+ # @param attr_string [String] the attribute string to parse
56
55
  # @return [Hash<String, String>] parsed attributes with downcased keys
57
56
  def parse_attributes(attr_string)
58
- attrs = {}
59
- return attrs if attr_string.nil? || attr_string.empty?
60
-
61
- attr_string.scan(/(\w+)=["']([^"']*)["']/) { |key, value| attrs[key.downcase] = value }
62
-
63
- attrs
57
+ attr_string.scan(/(\w+)=["']([^"']*)["']/).to_h.transform_keys(&:downcase)
64
58
  end
65
59
  end
66
60
  end
@@ -12,7 +12,7 @@ module Markbridge
12
12
  # match = detector.detect(input, 0)
13
13
  # match.node.name # => "Meeting"
14
14
  class Event < Base
15
- OPEN_TAG_PATTERN = /\[event([^\]]*)\]/i
15
+ OPEN_TAG_PATTERN = /\[event(?<attrs>[^\]]*)\]/i
16
16
  CLOSE_TAG_PATTERN = %r{\[/event\]}i
17
17
 
18
18
  # Attempt to detect an event at the given position.
@@ -21,15 +21,14 @@ module Markbridge
21
21
  # @param pos [Integer] current position to check
22
22
  # @return [Match, nil] match result or nil if no match
23
23
  def detect(input, pos)
24
- return nil unless input[pos] == "["
25
-
26
- # Check for opening tag
27
24
  remaining = input[pos..]
28
25
  open_match = OPEN_TAG_PATTERN.match(remaining)
29
26
  return nil unless open_match&.begin(0)&.zero?
30
27
 
31
- # Find closing tag
32
- close_match = CLOSE_TAG_PATTERN.match(remaining, open_match.end(0))
28
+ # Find closing tag. The opening tag pattern forbids `]` between
29
+ # `[event` and its closing `]`, so `[/event]` cannot appear inside
30
+ # the opening tag - no need to skip past it.
31
+ close_match = CLOSE_TAG_PATTERN.match(remaining)
33
32
  return nil unless close_match
34
33
 
35
34
  # Extract raw content
@@ -37,15 +36,17 @@ module Markbridge
37
36
  raw = input[pos...end_pos]
38
37
 
39
38
  # Parse attributes from opening tag
40
- attrs = parse_attributes(open_match[1])
39
+ attrs = parse_attributes(open_match[:attrs])
41
40
 
42
41
  # Validate required attributes
43
- return nil unless attrs["name"] && attrs["start"]
42
+ name = attrs["name"]
43
+ starts_at = attrs["start"]
44
+ return nil if name.nil? || starts_at.nil?
44
45
 
45
46
  node =
46
47
  AST::Event.new(
47
- name: attrs["name"],
48
- starts_at: attrs["start"],
48
+ name:,
49
+ starts_at:,
49
50
  ends_at: attrs["end"],
50
51
  status: attrs["status"],
51
52
  timezone: attrs["timezone"],
@@ -12,8 +12,7 @@ module Markbridge
12
12
  # match = detector.detect(input, 0)
13
13
  # match.node.type # => "regular"
14
14
  class Poll < Base
15
- OPEN_TAG_PATTERN = /\[poll([^\]]*)\]/i
16
- CLOSE_TAG_PATTERN = %r{\[/poll\]}i
15
+ TAG_PATTERN = %r{\A\[poll(?<attrs>[^\]]*)\](?<content>.*?)\[/poll\]}im
17
16
 
18
17
  # Attempt to detect a poll at the given position.
19
18
  #
@@ -21,57 +20,30 @@ module Markbridge
21
20
  # @param pos [Integer] current position to check
22
21
  # @return [Match, nil] match result or nil if no match
23
22
  def detect(input, pos)
24
- return nil unless input[pos] == "["
25
-
26
- # Check for opening tag
27
- remaining = input[pos..]
28
- open_match = OPEN_TAG_PATTERN.match(remaining)
29
- return nil unless open_match&.begin(0)&.zero?
30
-
31
- # Find closing tag
32
- close_match = CLOSE_TAG_PATTERN.match(remaining, open_match.end(0))
33
- return nil unless close_match
34
-
35
- # Extract raw content
36
- end_pos = pos + close_match.end(0)
37
- raw = input[pos...end_pos]
38
-
39
- # Parse attributes from opening tag
40
- attrs = parse_attributes(open_match[1])
41
-
42
- # Extract options from content between tags
43
- content = remaining[open_match.end(0)...close_match.begin(0)]
44
- options = extract_options(content)
23
+ match = TAG_PATTERN.match(input[pos..])
24
+ return nil unless match
45
25
 
26
+ attrs = parse_attributes(match[:attrs])
46
27
  node =
47
28
  AST::Poll.new(
48
29
  name: attrs["name"] || "poll",
49
30
  type: attrs["type"],
50
31
  results: attrs["results"],
51
32
  public: attrs["public"] == "true",
52
- chart_type: attrs["charttype"] || attrs["chartType"],
53
- options:,
54
- raw:,
33
+ chart_type: attrs["charttype"],
34
+ options: extract_options(match[:content]),
35
+ raw: match[0],
55
36
  )
56
37
 
57
- Match.new(start_pos: pos, end_pos:, node:)
38
+ Match.new(start_pos: pos, end_pos: pos + match.end(0), node:)
58
39
  end
59
40
 
60
41
  private
61
42
 
43
+ OPTION_PATTERN = /\A\s*(?:\*\s|-\s|\d+\.\s+)(?<value>.+?)\s*\z/
44
+
62
45
  def extract_options(content)
63
- options = []
64
- content.each_line do |line|
65
- line = line.strip
66
- if line.start_with?("* ")
67
- options << line[2..].strip
68
- elsif line.start_with?("- ")
69
- options << line[2..].strip
70
- elsif line.match?(/^\d+\.\s/)
71
- options << line.sub(/^\d+\.\s*/, "").strip
72
- end
73
- end
74
- options
46
+ content.each_line.filter_map { |line| OPTION_PATTERN.match(line)&.[](:value) }
75
47
  end
76
48
  end
77
49
  end