markbridge 0.1.0 → 0.1.2

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 (98) 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 +61 -0
  7. data/lib/markbridge/ast/text.rb +5 -1
  8. data/lib/markbridge/ast.rb +1 -0
  9. data/lib/markbridge/bbcode.rb +4 -0
  10. data/lib/markbridge/gem_loader.rb +2 -3
  11. data/lib/markbridge/html.rb +4 -0
  12. data/lib/markbridge/mediawiki.rb +4 -0
  13. data/lib/markbridge/parsers/bbcode/closing_strategies/base.rb +0 -10
  14. data/lib/markbridge/parsers/bbcode/closing_strategies/reordering.rb +17 -4
  15. data/lib/markbridge/parsers/bbcode/closing_strategies/tag_reconciler.rb +64 -44
  16. data/lib/markbridge/parsers/bbcode/handler_registry.rb +26 -11
  17. data/lib/markbridge/parsers/bbcode/handlers/attachment_handler.rb +17 -12
  18. data/lib/markbridge/parsers/bbcode/handlers/base_handler.rb +0 -10
  19. data/lib/markbridge/parsers/bbcode/handlers/code_handler.rb +6 -10
  20. data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +13 -19
  21. data/lib/markbridge/parsers/bbcode/handlers/list_handler.rb +1 -5
  22. data/lib/markbridge/parsers/bbcode/handlers/list_item_handler.rb +1 -2
  23. data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +30 -35
  24. data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +2 -6
  25. data/lib/markbridge/parsers/bbcode/handlers/self_closing_handler.rb +4 -4
  26. data/lib/markbridge/parsers/bbcode/handlers/table_cell_handler.rb +26 -0
  27. data/lib/markbridge/parsers/bbcode/handlers/table_handler.rb +32 -0
  28. data/lib/markbridge/parsers/bbcode/handlers/table_row_handler.rb +35 -0
  29. data/lib/markbridge/parsers/bbcode/parser.rb +5 -8
  30. data/lib/markbridge/parsers/bbcode/parser_state.rb +12 -18
  31. data/lib/markbridge/parsers/bbcode/peekable_enumerator.rb +9 -59
  32. data/lib/markbridge/parsers/bbcode/raw_content_collector.rb +2 -2
  33. data/lib/markbridge/parsers/bbcode/scanner.rb +49 -63
  34. data/lib/markbridge/parsers/bbcode/tokens/tag_end_token.rb +1 -5
  35. data/lib/markbridge/parsers/bbcode/tokens/tag_start_token.rb +1 -6
  36. data/lib/markbridge/parsers/bbcode/tokens/text_token.rb +1 -7
  37. data/lib/markbridge/parsers/bbcode/tokens/token.rb +1 -1
  38. data/lib/markbridge/parsers/bbcode.rb +4 -0
  39. data/lib/markbridge/parsers/html/handler_registry.rb +32 -44
  40. data/lib/markbridge/parsers/html/handlers/base_handler.rb +0 -3
  41. data/lib/markbridge/parsers/html/handlers/image_handler.rb +1 -4
  42. data/lib/markbridge/parsers/html/handlers/table_cell_handler.rb +24 -0
  43. data/lib/markbridge/parsers/html/handlers/table_handler.rb +24 -0
  44. data/lib/markbridge/parsers/html/handlers/table_row_handler.rb +24 -0
  45. data/lib/markbridge/parsers/html/parser.rb +16 -15
  46. data/lib/markbridge/parsers/html.rb +3 -0
  47. data/lib/markbridge/parsers/media_wiki/inline_parser.rb +115 -151
  48. data/lib/markbridge/parsers/media_wiki/inline_tag_registry.rb +103 -0
  49. data/lib/markbridge/parsers/media_wiki/parser.rb +174 -71
  50. data/lib/markbridge/parsers/media_wiki.rb +1 -0
  51. data/lib/markbridge/parsers/text_formatter/handler_registry.rb +10 -36
  52. data/lib/markbridge/parsers/text_formatter/handlers/table_cell_handler.rb +26 -0
  53. data/lib/markbridge/parsers/text_formatter/parser.rb +3 -8
  54. data/lib/markbridge/parsers/text_formatter.rb +1 -0
  55. data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +111 -92
  56. data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +13 -7
  57. data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +11 -20
  58. data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +10 -48
  59. data/lib/markbridge/processors/discourse_markdown/detectors/upload.rb +38 -63
  60. data/lib/markbridge/processors/discourse_markdown/scanner.rb +36 -41
  61. data/lib/markbridge/renderers/discourse/builders/list_item_builder.rb +6 -6
  62. data/lib/markbridge/renderers/discourse/html_escaper.rb +20 -0
  63. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +262 -205
  64. data/lib/markbridge/renderers/discourse/render_context.rb +23 -11
  65. data/lib/markbridge/renderers/discourse/renderer.rb +54 -11
  66. data/lib/markbridge/renderers/discourse/rendering_interface.rb +12 -4
  67. data/lib/markbridge/renderers/discourse/tag.rb +14 -1
  68. data/lib/markbridge/renderers/discourse/tag_library.rb +30 -25
  69. data/lib/markbridge/renderers/discourse/tags/align_tag.rb +15 -7
  70. data/lib/markbridge/renderers/discourse/tags/attachment_tag.rb +1 -1
  71. data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +2 -0
  72. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +14 -8
  73. data/lib/markbridge/renderers/discourse/tags/email_tag.rb +5 -3
  74. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +3 -3
  75. data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +6 -2
  76. data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +2 -2
  77. data/lib/markbridge/renderers/discourse/tags/image_tag.rb +12 -1
  78. data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +2 -0
  79. data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +2 -2
  80. data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +24 -47
  81. data/lib/markbridge/renderers/discourse/tags/list_tag.rb +10 -15
  82. data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +6 -2
  83. data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +10 -0
  84. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +9 -4
  85. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +17 -11
  86. data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +9 -0
  87. data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +2 -0
  88. data/lib/markbridge/renderers/discourse/tags/table_cell_tag.rb +18 -0
  89. data/lib/markbridge/renderers/discourse/tags/table_row_tag.rb +18 -0
  90. data/lib/markbridge/renderers/discourse/tags/table_tag.rb +128 -0
  91. data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +10 -3
  92. data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +28 -1
  93. data/lib/markbridge/renderers/discourse/tags/url_tag.rb +5 -3
  94. data/lib/markbridge/renderers/discourse.rb +4 -0
  95. data/lib/markbridge/textformatter.rb +4 -0
  96. data/lib/markbridge/version.rb +1 -1
  97. data/lib/markbridge.rb +27 -62
  98. metadata +19 -2
@@ -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
@@ -52,55 +53,21 @@ module Markbridge
52
53
  def check_fenced_boundary(input, pos, line_start:)
53
54
  return nil unless line_start
54
55
 
55
- # Skip up to 3 spaces of indentation
56
- scan_pos = pos
57
- spaces = 0
58
- while spaces < 3 && scan_pos < input.length && input[scan_pos] == " "
59
- spaces += 1
60
- scan_pos += 1
61
- end
62
-
63
- return nil if scan_pos >= input.length
64
-
56
+ input_length = input.length
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.
65
61
  fence_char = input[scan_pos]
66
62
  return nil unless fence_char == "`" || fence_char == "~"
67
63
 
68
- # Count consecutive fence characters
69
- fence_start = scan_pos
70
- fence_length = 0
71
- while scan_pos < input.length && input[scan_pos] == fence_char
72
- fence_length += 1
73
- scan_pos += 1
74
- end
75
-
64
+ fence_length, scan_pos = count_fence_chars(input, scan_pos, fence_char, input_length)
76
65
  return nil if fence_length < 3
77
66
 
78
67
  if @in_fenced_block
79
- # Check if this closes the current block
80
- if fence_char == @fence_char && fence_length >= @fence_length
81
- # Closing fence - must be followed by newline or end of input
82
- # Skip any trailing whitespace
83
- scan_pos += 1 while scan_pos < input.length && input[scan_pos] == " "
84
-
85
- if scan_pos >= input.length || input[scan_pos] == "\n"
86
- @in_fenced_block = false
87
- @fence_char = nil
88
- @fence_length = 0
89
- # Return position after the newline if present
90
- return scan_pos < input.length ? scan_pos + 1 : scan_pos
91
- end
92
- end
93
- nil
68
+ try_close_fence(input, scan_pos, fence_char, fence_length, input_length)
94
69
  else
95
- # Opening fence - skip to end of line (info string)
96
- scan_pos += 1 while scan_pos < input.length && input[scan_pos] != "\n"
97
-
98
- @in_fenced_block = true
99
- @fence_char = fence_char
100
- @fence_length = fence_length
101
-
102
- # Return position after the newline if present
103
- scan_pos < input.length ? scan_pos + 1 : scan_pos
70
+ open_fence(input, scan_pos, fence_char, fence_length, input_length)
104
71
  end
105
72
  end
106
73
 
@@ -116,38 +83,23 @@ module Markbridge
116
83
  return nil unless line_start
117
84
  return nil if @in_fenced_block # Fenced blocks take precedence
118
85
 
119
- # Find end of line
120
- line_end = input.index("\n", pos) || input.length
121
-
122
- # Check if line is blank
86
+ input_length = input.length
87
+ line_end = input.index("\n", pos) || input_length
123
88
  line_content = input[pos...line_end]
124
89
  is_blank = line_content.match?(/\A\s*\z/)
125
-
126
- # Check indentation (4+ spaces or tab)
127
90
  has_code_indent = line_content.start_with?(" ") || line_content.start_with?("\t")
128
91
 
129
92
  if @in_indented_block
130
- if is_blank
131
- # Blank lines continue the indented block
132
- # Return end of line (after newline if present)
133
- return line_end < input.length ? line_end + 1 : line_end
134
- elsif has_code_indent
135
- # Still in indented code
136
- return line_end < input.length ? line_end + 1 : line_end
93
+ if is_blank || has_code_indent
94
+ pos_after_line(line_end, input_length)
137
95
  else
138
- # Non-blank, non-indented line ends the block
139
96
  @in_indented_block = false
140
- return nil
141
- end
142
- else
143
- if has_code_indent
144
- # Start of indented code block
145
- @in_indented_block = true
146
- return line_end < input.length ? line_end + 1 : line_end
97
+ nil
147
98
  end
99
+ elsif has_code_indent
100
+ @in_indented_block = true
101
+ pos_after_line(line_end, input_length)
148
102
  end
149
-
150
- nil
151
103
  end
152
104
 
153
105
  # Check for inline code boundary
@@ -156,42 +108,109 @@ module Markbridge
156
108
  # @return [Integer, nil] end position after inline code, or nil if not at boundary
157
109
  def check_inline_boundary(input, pos)
158
110
  return nil if @in_fenced_block || @in_indented_block
159
- return nil if pos >= input.length || input[pos] != "`"
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] != "`"
160
114
 
115
+ input_length = input.length
161
116
  if @in_inline_code
162
- # Check if this closes the current inline code
163
- delimiter_length = @inline_delimiter.length
164
- if input[pos, delimiter_length] == @inline_delimiter
165
- # Check what follows - should not be another backtick
166
- next_pos = pos + delimiter_length
167
- if next_pos >= input.length || input[next_pos] != "`"
168
- @in_inline_code = false
169
- @inline_delimiter = nil
170
- return next_pos
171
- end
172
- end
173
- nil
117
+ try_close_inline(input, pos, input_length)
174
118
  else
175
- # Opening inline code - count backticks
176
- delimiter_start = pos
177
- pos += 1 while pos < input.length && input[pos] == "`"
119
+ open_inline(input, pos, input_length)
120
+ end
121
+ end
178
122
 
179
- @inline_delimiter = input[delimiter_start...pos]
180
- @in_inline_code = true
123
+ private
124
+
125
+ # Skip up to 3 leading spaces of indentation.
126
+ def skip_leading_spaces(input, pos)
127
+ scan_pos = pos
128
+ spaces = 0
129
+ while spaces < 3 && input[scan_pos] == " "
130
+ spaces += 1
131
+ scan_pos += 1
132
+ end
133
+ scan_pos
134
+ end
181
135
 
182
- # Return position after opening delimiter
183
- pos
136
+ # Count consecutive fence characters and return [count, new_position].
137
+ def count_fence_chars(input, scan_pos, fence_char, input_length)
138
+ fence_length = 0
139
+ while scan_pos < input_length && input[scan_pos] == fence_char
140
+ fence_length += 1
141
+ scan_pos += 1
184
142
  end
143
+ [fence_length, scan_pos]
144
+ end
145
+
146
+ # Try to close an open fenced code block. Returns position after fence or nil.
147
+ def try_close_fence(input, scan_pos, fence_char, fence_length, input_length)
148
+ return nil unless fence_char == @fence_char && fence_length >= @fence_length
149
+
150
+ # Closing fence must be followed only by spaces then newline/EOF
151
+ scan_pos += 1 while scan_pos < input_length && input[scan_pos] == " "
152
+ return nil unless scan_pos >= input_length || input[scan_pos] == "\n"
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.
157
+ @in_fenced_block = false
158
+ pos_after_line(scan_pos, input_length)
159
+ end
160
+
161
+ # Open a new fenced code block. Returns position after the opening line.
162
+ def open_fence(input, scan_pos, fence_char, fence_length, input_length)
163
+ # Skip to end of line (info string)
164
+ scan_pos += 1 while scan_pos < input_length && input[scan_pos] != "\n"
165
+
166
+ @in_fenced_block = true
167
+ @fence_char = fence_char
168
+ @fence_length = fence_length
169
+ pos_after_line(scan_pos, input_length)
170
+ end
171
+
172
+ # Try to close inline code. Returns position after delimiter or nil.
173
+ def try_close_inline(input, pos, input_length)
174
+ delimiter_length = @inline_delimiter.length
175
+ return nil unless input[pos, delimiter_length] == @inline_delimiter
176
+
177
+ # Should not be followed by another backtick
178
+ next_pos = pos + delimiter_length
179
+ return nil if next_pos < input_length && input[next_pos] == "`"
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.
184
+ @in_inline_code = false
185
+ next_pos
185
186
  end
186
187
 
187
- # Reset the tracker state
188
+ # Open inline code. Returns position after opening delimiter.
189
+ def open_inline(input, pos, input_length)
190
+ delimiter_start = pos
191
+ pos += 1 while pos < input_length && input[pos] == "`"
192
+
193
+ @inline_delimiter = input[delimiter_start...pos]
194
+ @in_inline_code = true
195
+ pos
196
+ end
197
+
198
+ # Return position after a line (after newline if present, otherwise at end).
199
+ def pos_after_line(line_end, input_length)
200
+ line_end < input_length ? line_end + 1 : line_end
201
+ end
202
+
203
+ public
204
+
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).
188
210
  def reset!
189
211
  @in_fenced_block = false
190
- @fence_char = nil
191
- @fence_length = 0
192
212
  @in_indented_block = false
193
213
  @in_inline_code = false
194
- @inline_delimiter = nil
195
214
  end
196
215
  end
197
216
  end
@@ -38,17 +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]
51
+ end
52
+
53
+ # Parse key="value" or key='value' attribute pairs from a string.
54
+ # @param attr_string [String] the attribute string to parse
55
+ # @return [Hash<String, String>] parsed attributes with downcased keys
56
+ def parse_attributes(attr_string)
57
+ attr_string.scan(/(\w+)=["']([^"']*)["']/).to_h.transform_keys(&:downcase)
52
58
  end
53
59
  end
54
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"],
@@ -56,16 +57,6 @@ module Markbridge
56
57
  end
57
58
 
58
59
  private
59
-
60
- def parse_attributes(attr_string)
61
- attrs = {}
62
- return attrs if attr_string.nil? || attr_string.empty?
63
-
64
- # Match key="value" or key='value' patterns
65
- attr_string.scan(/(\w+)=["']([^"']*)["']/) { |key, value| attrs[key.downcase] = value }
66
-
67
- attrs
68
- end
69
60
  end
70
61
  end
71
62
  end
@@ -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,67 +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
 
62
- def parse_attributes(attr_string)
63
- attrs = {}
64
- return attrs if attr_string.nil? || attr_string.empty?
65
-
66
- # Match key="value" or key='value' patterns
67
- attr_string.scan(/(\w+)=["']([^"']*)["']/) { |key, value| attrs[key.downcase] = value }
68
-
69
- attrs
70
- end
43
+ OPTION_PATTERN = /\A\s*(?:\*\s|-\s|\d+\.\s+)(?<value>.+?)\s*\z/
71
44
 
72
45
  def extract_options(content)
73
- options = []
74
- content.each_line do |line|
75
- line = line.strip
76
- if line.start_with?("* ")
77
- options << line[2..].strip
78
- elsif line.start_with?("- ")
79
- options << line[2..].strip
80
- elsif line.match?(/^\d+\.\s/)
81
- options << line.sub(/^\d+\.\s*/, "").strip
82
- end
83
- end
84
- options
46
+ content.each_line.filter_map { |line| OPTION_PATTERN.match(line)&.[](:value) }
85
47
  end
86
48
  end
87
49
  end
@@ -22,11 +22,18 @@ module Markbridge
22
22
  # match = detector.detect(input, 0)
23
23
  # match.node.type # => :attachment
24
24
  class Upload < Base
25
- # Pattern for image: ![alt|dimensions](upload://sha1.ext)
26
- IMAGE_PATTERN = %r{!\[([^\]]*)\]\(upload://([^)]+)\)}
27
-
28
- # Pattern for attachment: [filename|attachment](upload://sha1.ext) followed by optional (size)
29
- ATTACHMENT_PATTERN = %r{\[([^\]]*\|attachment)\]\(upload://([^)]+)\)(\s*\([^)]+\))?}
25
+ # Image: ![alt|dimensions](upload://sha1.ext)
26
+ IMAGE_PATTERN =
27
+ %r{\A!\[(?<alt>[^|\]]*)(?:\|(?<dimensions>[^\]]*))?\]\(upload://(?<url>[^)]+)\)}
28
+
29
+ # Attachment: [filename|attachment](upload://sha1.ext) (size)
30
+ ATTACHMENT_PATTERN =
31
+ %r{
32
+ \A
33
+ \[(?<filename>[^|\]]*)\|attachment\]
34
+ \(upload://(?<url>[^)]+)\)
35
+ (?:\s*\((?<size>[^)]+)\))?
36
+ }xi
30
37
 
31
38
  # Attempt to detect an upload at the given position.
32
39
  #
@@ -34,14 +41,11 @@ module Markbridge
34
41
  # @param pos [Integer] current position to check
35
42
  # @return [Match, nil] match result or nil if no match
36
43
  def detect(input, pos)
37
- char = input[pos]
38
- return nil unless char == "!" || char == "["
39
-
40
44
  remaining = input[pos..]
41
-
42
- if char == "!"
45
+ case input[pos]
46
+ when "!"
43
47
  detect_image(remaining, pos)
44
- else
48
+ when "["
45
49
  detect_attachment(remaining, pos)
46
50
  end
47
51
  end
@@ -50,71 +54,42 @@ module Markbridge
50
54
 
51
55
  def detect_image(remaining, pos)
52
56
  match = IMAGE_PATTERN.match(remaining)
53
- return nil unless match&.begin(0)&.zero?
54
-
55
- raw = match[0]
56
- alt_part = match[1]
57
- url_part = match[2]
57
+ return nil unless match
58
58
 
59
- # Parse alt and dimensions from "alt|dimensions" format
60
- alt, dimensions = parse_alt_dimensions(alt_part)
59
+ sha1, filename = parse_upload_url(match[:url])
60
+ alt = match[:alt]
61
+ alt = nil if alt.empty?
61
62
 
62
- # Extract SHA1 and filename from URL
63
- sha1, filename = parse_upload_url(url_part)
63
+ # `type: :image` is omitted because it is AST::Upload's default -
64
+ # passing it explicitly was an equivalent-mutation surface.
65
+ node =
66
+ AST::Upload.new(sha1:, filename:, alt:, dimensions: match[:dimensions], raw: match[0])
64
67
 
65
- node = AST::Upload.new(sha1:, filename:, type: :image, alt:, dimensions:, raw:)
66
-
67
- Match.new(start_pos: pos, end_pos: pos + raw.length, node:)
68
+ Match.new(start_pos: pos, end_pos: pos + match[0].length, node:)
68
69
  end
69
70
 
70
71
  def detect_attachment(remaining, pos)
71
72
  match = ATTACHMENT_PATTERN.match(remaining)
72
- return nil unless match&.begin(0)&.zero?
73
-
74
- raw = match[0]
75
- name_part = match[1]
76
- url_part = match[2]
77
- size_part = match[3]
78
-
79
- # Parse filename from "filename|attachment" format
80
- filename = name_part.sub(/\|attachment$/i, "")
73
+ return nil unless match
81
74
 
82
- # Extract SHA1 from URL
83
- sha1, _url_filename = parse_upload_url(url_part)
75
+ sha1, = parse_upload_url(match[:url])
84
76
 
85
- # Parse size if present
86
- size = size_part&.strip&.delete_prefix("(")&.delete_suffix(")")
77
+ node =
78
+ AST::Upload.new(
79
+ sha1:,
80
+ filename: match[:filename],
81
+ type: :attachment,
82
+ size: match[:size],
83
+ raw: match[0],
84
+ )
87
85
 
88
- node = AST::Upload.new(sha1:, filename:, type: :attachment, size:, raw:)
89
-
90
- Match.new(start_pos: pos, end_pos: pos + raw.length, node:)
91
- end
92
-
93
- def parse_alt_dimensions(alt_part)
94
- return nil, nil if alt_part.nil? || alt_part.empty?
95
-
96
- if alt_part.include?("|")
97
- parts = alt_part.split("|", 2)
98
- alt = parts[0].empty? ? nil : parts[0]
99
- dimensions = parts[1]
100
- [alt, dimensions]
101
- else
102
- [alt_part, nil]
103
- end
86
+ Match.new(start_pos: pos, end_pos: pos + match[0].length, node:)
104
87
  end
105
88
 
89
+ # URL format: sha1.ext or just sha1. Returns [sha1, filename-or-nil].
106
90
  def parse_upload_url(url_part)
107
- # URL format: sha1.ext or just sha1
108
- if url_part.include?(".")
109
- parts = url_part.split(".", 2)
110
- sha1 = parts[0]
111
- filename = url_part
112
- else
113
- sha1 = url_part
114
- filename = nil
115
- end
116
-
117
- [sha1, filename]
91
+ sha1, _, ext = url_part.partition(".")
92
+ [sha1, ext.empty? ? nil : url_part]
118
93
  end
119
94
  end
120
95
  end