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.
- checksums.yaml +4 -4
- data/lib/markbridge/all.rb +4 -7
- data/lib/markbridge/ast/document.rb +1 -1
- data/lib/markbridge/ast/element.rb +2 -2
- data/lib/markbridge/ast/list.rb +2 -2
- data/lib/markbridge/ast/table.rb +6 -12
- data/lib/markbridge/ast/text.rb +5 -1
- data/lib/markbridge/bbcode.rb +4 -0
- data/lib/markbridge/gem_loader.rb +2 -3
- data/lib/markbridge/html.rb +4 -0
- data/lib/markbridge/mediawiki.rb +4 -0
- data/lib/markbridge/parsers/bbcode/closing_strategies/base.rb +0 -10
- data/lib/markbridge/parsers/bbcode/closing_strategies/reordering.rb +17 -4
- data/lib/markbridge/parsers/bbcode/closing_strategies/tag_reconciler.rb +64 -44
- data/lib/markbridge/parsers/bbcode/handler_registry.rb +21 -11
- data/lib/markbridge/parsers/bbcode/handlers/attachment_handler.rb +17 -12
- data/lib/markbridge/parsers/bbcode/handlers/base_handler.rb +0 -10
- data/lib/markbridge/parsers/bbcode/handlers/code_handler.rb +6 -10
- data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +9 -17
- data/lib/markbridge/parsers/bbcode/handlers/list_handler.rb +1 -5
- data/lib/markbridge/parsers/bbcode/handlers/list_item_handler.rb +1 -2
- data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +6 -18
- data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +2 -6
- data/lib/markbridge/parsers/bbcode/handlers/self_closing_handler.rb +4 -4
- data/lib/markbridge/parsers/bbcode/handlers/table_cell_handler.rb +1 -1
- data/lib/markbridge/parsers/bbcode/handlers/table_handler.rb +2 -2
- data/lib/markbridge/parsers/bbcode/handlers/table_row_handler.rb +3 -3
- data/lib/markbridge/parsers/bbcode/parser.rb +5 -8
- data/lib/markbridge/parsers/bbcode/parser_state.rb +12 -18
- data/lib/markbridge/parsers/bbcode/peekable_enumerator.rb +9 -59
- data/lib/markbridge/parsers/bbcode/raw_content_collector.rb +2 -2
- data/lib/markbridge/parsers/bbcode/scanner.rb +49 -63
- data/lib/markbridge/parsers/bbcode/tokens/tag_end_token.rb +1 -5
- data/lib/markbridge/parsers/bbcode/tokens/tag_start_token.rb +1 -6
- data/lib/markbridge/parsers/bbcode/tokens/text_token.rb +1 -7
- data/lib/markbridge/parsers/bbcode/tokens/token.rb +1 -1
- data/lib/markbridge/parsers/bbcode.rb +1 -0
- data/lib/markbridge/parsers/html/handler_registry.rb +32 -49
- data/lib/markbridge/parsers/html/handlers/base_handler.rb +0 -2
- data/lib/markbridge/parsers/html/handlers/image_handler.rb +1 -4
- data/lib/markbridge/parsers/html/parser.rb +3 -13
- data/lib/markbridge/parsers/media_wiki/inline_parser.rb +56 -67
- data/lib/markbridge/parsers/media_wiki/inline_tag_registry.rb +103 -0
- data/lib/markbridge/parsers/media_wiki/parser.rb +51 -76
- data/lib/markbridge/parsers/media_wiki.rb +1 -0
- data/lib/markbridge/parsers/text_formatter/handler_registry.rb +5 -37
- data/lib/markbridge/parsers/text_formatter/parser.rb +3 -8
- data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +24 -17
- data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +9 -15
- data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +11 -10
- data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +11 -39
- data/lib/markbridge/processors/discourse_markdown/detectors/upload.rb +38 -63
- data/lib/markbridge/processors/discourse_markdown/scanner.rb +25 -33
- data/lib/markbridge/renderers/discourse/builders/list_item_builder.rb +6 -6
- data/lib/markbridge/renderers/discourse/html_escaper.rb +20 -0
- data/lib/markbridge/renderers/discourse/markdown_escaper.rb +57 -50
- data/lib/markbridge/renderers/discourse/render_context.rb +23 -11
- data/lib/markbridge/renderers/discourse/renderer.rb +54 -12
- data/lib/markbridge/renderers/discourse/rendering_interface.rb +12 -4
- data/lib/markbridge/renderers/discourse/tag.rb +14 -1
- data/lib/markbridge/renderers/discourse/tag_library.rb +30 -25
- data/lib/markbridge/renderers/discourse/tags/align_tag.rb +15 -7
- data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +2 -0
- data/lib/markbridge/renderers/discourse/tags/code_tag.rb +14 -9
- data/lib/markbridge/renderers/discourse/tags/email_tag.rb +5 -3
- data/lib/markbridge/renderers/discourse/tags/event_tag.rb +3 -1
- data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +6 -2
- data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +2 -2
- data/lib/markbridge/renderers/discourse/tags/image_tag.rb +13 -2
- data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +2 -0
- data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +2 -2
- data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +24 -47
- data/lib/markbridge/renderers/discourse/tags/list_tag.rb +10 -15
- data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +5 -1
- data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +10 -0
- data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +9 -2
- data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +2 -0
- data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +9 -0
- data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +2 -0
- data/lib/markbridge/renderers/discourse/tags/table_tag.rb +12 -8
- data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +10 -3
- data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +29 -2
- data/lib/markbridge/renderers/discourse/tags/url_tag.rb +5 -3
- data/lib/markbridge/renderers/discourse.rb +1 -0
- data/lib/markbridge/textformatter.rb +4 -0
- data/lib/markbridge/version.rb +1 -1
- data/lib/markbridge.rb +8 -8
- 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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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"
|
|
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
|
|
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?(
|
|
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.
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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(
|
|
340
|
+
code << AST::Text.new(content)
|
|
365
341
|
@document << code
|
|
366
342
|
|
|
367
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
368
|
+
start_index + consumed.length - 1
|
|
394
369
|
end
|
|
395
370
|
|
|
396
371
|
# Process a line as inline content wrapped in a paragraph.
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
57
|
-
|
|
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
|
|
126
|
+
def skip_leading_spaces(input, pos)
|
|
124
127
|
scan_pos = pos
|
|
125
128
|
spaces = 0
|
|
126
|
-
while spaces < 3 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
39
|
+
attrs = parse_attributes(open_match[:attrs])
|
|
41
40
|
|
|
42
41
|
# Validate required attributes
|
|
43
|
-
|
|
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
|
|
48
|
-
starts_at
|
|
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
|
-
|
|
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
|
-
|
|
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"]
|
|
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
|
|
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
|
-
|
|
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
|