markbridge 0.1.0

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 (144) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/lib/markbridge/all.rb +9 -0
  4. data/lib/markbridge/ast/align.rb +24 -0
  5. data/lib/markbridge/ast/attachment.rb +42 -0
  6. data/lib/markbridge/ast/bold.rb +13 -0
  7. data/lib/markbridge/ast/code.rb +27 -0
  8. data/lib/markbridge/ast/color.rb +25 -0
  9. data/lib/markbridge/ast/document.rb +27 -0
  10. data/lib/markbridge/ast/element.rb +47 -0
  11. data/lib/markbridge/ast/email.rb +27 -0
  12. data/lib/markbridge/ast/event.rb +59 -0
  13. data/lib/markbridge/ast/heading.rb +23 -0
  14. data/lib/markbridge/ast/horizontal_rule.rb +12 -0
  15. data/lib/markbridge/ast/image.rb +35 -0
  16. data/lib/markbridge/ast/italic.rb +13 -0
  17. data/lib/markbridge/ast/line_break.rb +12 -0
  18. data/lib/markbridge/ast/list.rb +52 -0
  19. data/lib/markbridge/ast/list_item.rb +13 -0
  20. data/lib/markbridge/ast/markdown_text.rb +37 -0
  21. data/lib/markbridge/ast/mention.rb +29 -0
  22. data/lib/markbridge/ast/node.rb +19 -0
  23. data/lib/markbridge/ast/paragraph.rb +13 -0
  24. data/lib/markbridge/ast/poll.rb +74 -0
  25. data/lib/markbridge/ast/quote.rb +46 -0
  26. data/lib/markbridge/ast/size.rb +25 -0
  27. data/lib/markbridge/ast/spoiler.rb +27 -0
  28. data/lib/markbridge/ast/strikethrough.rb +13 -0
  29. data/lib/markbridge/ast/subscript.rb +13 -0
  30. data/lib/markbridge/ast/superscript.rb +13 -0
  31. data/lib/markbridge/ast/text.rb +38 -0
  32. data/lib/markbridge/ast/underline.rb +13 -0
  33. data/lib/markbridge/ast/upload.rb +74 -0
  34. data/lib/markbridge/ast/url.rb +27 -0
  35. data/lib/markbridge/ast.rb +42 -0
  36. data/lib/markbridge/configuration.rb +11 -0
  37. data/lib/markbridge/gem_loader.rb +23 -0
  38. data/lib/markbridge/parsers/bbcode/closing_strategies/base.rb +37 -0
  39. data/lib/markbridge/parsers/bbcode/closing_strategies/reordering.rb +17 -0
  40. data/lib/markbridge/parsers/bbcode/closing_strategies/strict.rb +12 -0
  41. data/lib/markbridge/parsers/bbcode/closing_strategies/tag_reconciler.rb +121 -0
  42. data/lib/markbridge/parsers/bbcode/errors/max_depth_exceeded_error.rb +13 -0
  43. data/lib/markbridge/parsers/bbcode/handler_registry.rb +160 -0
  44. data/lib/markbridge/parsers/bbcode/handlers/align_handler.rb +26 -0
  45. data/lib/markbridge/parsers/bbcode/handlers/attachment_handler.rb +104 -0
  46. data/lib/markbridge/parsers/bbcode/handlers/base_handler.rb +44 -0
  47. data/lib/markbridge/parsers/bbcode/handlers/code_handler.rb +25 -0
  48. data/lib/markbridge/parsers/bbcode/handlers/color_handler.rb +31 -0
  49. data/lib/markbridge/parsers/bbcode/handlers/email_handler.rb +25 -0
  50. data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +51 -0
  51. data/lib/markbridge/parsers/bbcode/handlers/list_handler.rb +36 -0
  52. data/lib/markbridge/parsers/bbcode/handlers/list_item_handler.rb +26 -0
  53. data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +64 -0
  54. data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +48 -0
  55. data/lib/markbridge/parsers/bbcode/handlers/self_closing_handler.rb +28 -0
  56. data/lib/markbridge/parsers/bbcode/handlers/simple_handler.rb +28 -0
  57. data/lib/markbridge/parsers/bbcode/handlers/size_handler.rb +31 -0
  58. data/lib/markbridge/parsers/bbcode/handlers/spoiler_handler.rb +28 -0
  59. data/lib/markbridge/parsers/bbcode/handlers/url_handler.rb +24 -0
  60. data/lib/markbridge/parsers/bbcode/parser.rb +123 -0
  61. data/lib/markbridge/parsers/bbcode/parser_state.rb +93 -0
  62. data/lib/markbridge/parsers/bbcode/peekable_enumerator.rb +126 -0
  63. data/lib/markbridge/parsers/bbcode/raw_content_collector.rb +35 -0
  64. data/lib/markbridge/parsers/bbcode/raw_content_result.rb +25 -0
  65. data/lib/markbridge/parsers/bbcode/scanner.rb +231 -0
  66. data/lib/markbridge/parsers/bbcode/tokens/tag_end_token.rb +21 -0
  67. data/lib/markbridge/parsers/bbcode/tokens/tag_start_token.rb +23 -0
  68. data/lib/markbridge/parsers/bbcode/tokens/text_token.rb +23 -0
  69. data/lib/markbridge/parsers/bbcode/tokens/token.rb +16 -0
  70. data/lib/markbridge/parsers/bbcode.rb +56 -0
  71. data/lib/markbridge/parsers/html/handler_registry.rb +87 -0
  72. data/lib/markbridge/parsers/html/handlers/base_handler.rb +27 -0
  73. data/lib/markbridge/parsers/html/handlers/image_handler.rb +40 -0
  74. data/lib/markbridge/parsers/html/handlers/list_handler.rb +29 -0
  75. data/lib/markbridge/parsers/html/handlers/list_item_handler.rb +26 -0
  76. data/lib/markbridge/parsers/html/handlers/paragraph_handler.rb +17 -0
  77. data/lib/markbridge/parsers/html/handlers/quote_handler.rb +28 -0
  78. data/lib/markbridge/parsers/html/handlers/raw_handler.rb +33 -0
  79. data/lib/markbridge/parsers/html/handlers/simple_handler.rb +26 -0
  80. data/lib/markbridge/parsers/html/handlers/url_handler.rb +27 -0
  81. data/lib/markbridge/parsers/html/parser.rb +113 -0
  82. data/lib/markbridge/parsers/html.rb +30 -0
  83. data/lib/markbridge/parsers/media_wiki/inline_parser.rb +332 -0
  84. data/lib/markbridge/parsers/media_wiki/parser.rb +279 -0
  85. data/lib/markbridge/parsers/media_wiki.rb +15 -0
  86. data/lib/markbridge/parsers/text_formatter/handler_registry.rb +130 -0
  87. data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +33 -0
  88. data/lib/markbridge/parsers/text_formatter/handlers/attribute_handler.rb +40 -0
  89. data/lib/markbridge/parsers/text_formatter/handlers/base_handler.rb +45 -0
  90. data/lib/markbridge/parsers/text_formatter/handlers/code_handler.rb +28 -0
  91. data/lib/markbridge/parsers/text_formatter/handlers/email_handler.rb +27 -0
  92. data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +32 -0
  93. data/lib/markbridge/parsers/text_formatter/handlers/list_handler.rb +31 -0
  94. data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +33 -0
  95. data/lib/markbridge/parsers/text_formatter/handlers/simple_handler.rb +37 -0
  96. data/lib/markbridge/parsers/text_formatter/handlers/url_handler.rb +29 -0
  97. data/lib/markbridge/parsers/text_formatter/parser.rb +132 -0
  98. data/lib/markbridge/parsers/text_formatter.rb +31 -0
  99. data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +199 -0
  100. data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +57 -0
  101. data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +73 -0
  102. data/lib/markbridge/processors/discourse_markdown/detectors/mention.rb +57 -0
  103. data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +90 -0
  104. data/lib/markbridge/processors/discourse_markdown/detectors/upload.rb +123 -0
  105. data/lib/markbridge/processors/discourse_markdown/scanner.rb +199 -0
  106. data/lib/markbridge/processors/discourse_markdown.rb +16 -0
  107. data/lib/markbridge/processors.rb +8 -0
  108. data/lib/markbridge/renderers/discourse/builders/list_item_builder.rb +83 -0
  109. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +468 -0
  110. data/lib/markbridge/renderers/discourse/render_context.rb +80 -0
  111. data/lib/markbridge/renderers/discourse/renderer.rb +63 -0
  112. data/lib/markbridge/renderers/discourse/rendering_interface.rb +86 -0
  113. data/lib/markbridge/renderers/discourse/tag.rb +29 -0
  114. data/lib/markbridge/renderers/discourse/tag_library.rb +67 -0
  115. data/lib/markbridge/renderers/discourse/tags/align_tag.rb +24 -0
  116. data/lib/markbridge/renderers/discourse/tags/attachment_tag.rb +46 -0
  117. data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +18 -0
  118. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +54 -0
  119. data/lib/markbridge/renderers/discourse/tags/color_tag.rb +27 -0
  120. data/lib/markbridge/renderers/discourse/tags/email_tag.rb +24 -0
  121. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +49 -0
  122. data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +21 -0
  123. data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +16 -0
  124. data/lib/markbridge/renderers/discourse/tags/image_tag.rb +29 -0
  125. data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +18 -0
  126. data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +16 -0
  127. data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +87 -0
  128. data/lib/markbridge/renderers/discourse/tags/list_tag.rb +39 -0
  129. data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +34 -0
  130. data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +21 -0
  131. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +51 -0
  132. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +32 -0
  133. data/lib/markbridge/renderers/discourse/tags/size_tag.rb +27 -0
  134. data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +24 -0
  135. data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +18 -0
  136. data/lib/markbridge/renderers/discourse/tags/subscript_tag.rb +19 -0
  137. data/lib/markbridge/renderers/discourse/tags/superscript_tag.rb +19 -0
  138. data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +19 -0
  139. data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +80 -0
  140. data/lib/markbridge/renderers/discourse/tags/url_tag.rb +24 -0
  141. data/lib/markbridge/renderers/discourse.rb +50 -0
  142. data/lib/markbridge/version.rb +5 -0
  143. data/lib/markbridge.rb +201 -0
  144. metadata +186 -0
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Processors
5
+ module DiscourseMarkdown
6
+ module Detectors
7
+ # Detects Discourse poll blocks [poll]...[/poll].
8
+ #
9
+ # @example
10
+ # detector = Poll.new
11
+ # input = "[poll type=\"regular\"]\n* A\n* B\n[/poll]"
12
+ # match = detector.detect(input, 0)
13
+ # match.node.type # => "regular"
14
+ class Poll < Base
15
+ OPEN_TAG_PATTERN = /\[poll([^\]]*)\]/i
16
+ CLOSE_TAG_PATTERN = %r{\[/poll\]}i
17
+
18
+ # Attempt to detect a poll at the given position.
19
+ #
20
+ # @param input [String] the full input string
21
+ # @param pos [Integer] current position to check
22
+ # @return [Match, nil] match result or nil if no match
23
+ 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)
45
+
46
+ node =
47
+ AST::Poll.new(
48
+ name: attrs["name"] || "poll",
49
+ type: attrs["type"],
50
+ results: attrs["results"],
51
+ public: attrs["public"] == "true",
52
+ chart_type: attrs["charttype"] || attrs["chartType"],
53
+ options:,
54
+ raw:,
55
+ )
56
+
57
+ Match.new(start_pos: pos, end_pos:, node:)
58
+ end
59
+
60
+ private
61
+
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
71
+
72
+ 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
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Processors
5
+ module DiscourseMarkdown
6
+ module Detectors
7
+ # Detects Discourse upload references using upload:// URLs.
8
+ #
9
+ # Supports two formats:
10
+ # - Images: ![alt|dimensions](upload://sha1.ext)
11
+ # - Attachments: [filename|attachment](upload://sha1.ext) (size)
12
+ #
13
+ # @example Image
14
+ # detector = Upload.new
15
+ # input = "![logo|64x64](upload://abc123.png)"
16
+ # match = detector.detect(input, 0)
17
+ # match.node.type # => :image
18
+ #
19
+ # @example Attachment
20
+ # detector = Upload.new
21
+ # input = "[doc.pdf|attachment](upload://xyz789.pdf) (1.2 MB)"
22
+ # match = detector.detect(input, 0)
23
+ # match.node.type # => :attachment
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*\([^)]+\))?}
30
+
31
+ # Attempt to detect an upload at the given position.
32
+ #
33
+ # @param input [String] the full input string
34
+ # @param pos [Integer] current position to check
35
+ # @return [Match, nil] match result or nil if no match
36
+ def detect(input, pos)
37
+ char = input[pos]
38
+ return nil unless char == "!" || char == "["
39
+
40
+ remaining = input[pos..]
41
+
42
+ if char == "!"
43
+ detect_image(remaining, pos)
44
+ else
45
+ detect_attachment(remaining, pos)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def detect_image(remaining, pos)
52
+ 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]
58
+
59
+ # Parse alt and dimensions from "alt|dimensions" format
60
+ alt, dimensions = parse_alt_dimensions(alt_part)
61
+
62
+ # Extract SHA1 and filename from URL
63
+ sha1, filename = parse_upload_url(url_part)
64
+
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
+ end
69
+
70
+ def detect_attachment(remaining, pos)
71
+ 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, "")
81
+
82
+ # Extract SHA1 from URL
83
+ sha1, _url_filename = parse_upload_url(url_part)
84
+
85
+ # Parse size if present
86
+ size = size_part&.strip&.delete_prefix("(")&.delete_suffix(")")
87
+
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
104
+ end
105
+
106
+ 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]
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Processors
5
+ module DiscourseMarkdown
6
+ # Result of scanning Discourse Markdown
7
+ # @attr_reader markdown [String] the processed markdown with placeholders
8
+ # @attr_reader nodes [Array<AST::Node>] extracted AST nodes in order of appearance
9
+ ScanResult = Data.define(:markdown, :nodes)
10
+
11
+ # Single-pass scanner for Discourse Markdown that extracts specific constructs
12
+ # (mentions, polls, events, uploads) while preserving all other content unchanged.
13
+ #
14
+ # The scanner respects code blocks (fenced, indented, and inline) and will not
15
+ # extract constructs that appear within code.
16
+ #
17
+ # @example Basic usage
18
+ # scanner = Scanner.new
19
+ # result = scanner.scan("Hello @gerhard!")
20
+ # result.nodes.first # => AST::Mention
21
+ #
22
+ # @example With custom tag library for rendering
23
+ # scanner = Scanner.new(tag_library: my_library)
24
+ # result = scanner.scan(input)
25
+ # result.markdown # => "Hello <<MENTION:1>>!"
26
+ #
27
+ # @example With mention type resolver
28
+ # scanner = Scanner.new(mention_resolver: ->(name) {
29
+ # groups.include?(name) ? :group : :user
30
+ # })
31
+ # result = scanner.scan("@Testers and @gerhard")
32
+ # result.nodes[0].type # => :group
33
+ # result.nodes[1].type # => :user
34
+ class Scanner
35
+ # Default detectors in priority order
36
+ DEFAULT_DETECTORS = [
37
+ Detectors::Poll,
38
+ Detectors::Event,
39
+ Detectors::Upload,
40
+ Detectors::Mention,
41
+ ].freeze
42
+
43
+ # Characters that can start a construct (for fast bailout)
44
+ TRIGGER_CHARS = Set.new(["@", "[", "!"]).freeze
45
+
46
+ # @param detectors [Array<Class>] detector classes to use (instantiated automatically)
47
+ # @param tag_library [Renderers::Discourse::TagLibrary, nil] tag library for rendering placeholders
48
+ # @param mention_resolver [#call, nil] callable that takes a name and returns :user or :group
49
+ def initialize(detectors: DEFAULT_DETECTORS, tag_library: nil, mention_resolver: nil)
50
+ @detector_instances = build_detectors(detectors, mention_resolver)
51
+ @tag_library = tag_library
52
+ end
53
+
54
+ # Scan input and extract constructs.
55
+ #
56
+ # @param input [String] Discourse Markdown input
57
+ # @return [ScanResult] result containing processed markdown and extracted nodes
58
+ def scan(input)
59
+ return ScanResult.new(markdown: "", nodes: []) if input.nil? || input.empty?
60
+
61
+ @code_tracker = CodeBlockTracker.new
62
+ @result = +""
63
+ @nodes = []
64
+ @node_index = 0
65
+ @pos = 0
66
+ @input = input
67
+ @line_start = true
68
+
69
+ scan_input
70
+
71
+ ScanResult.new(markdown: @result, nodes: @nodes)
72
+ end
73
+
74
+ private
75
+
76
+ def build_detectors(detectors, mention_resolver)
77
+ detectors.map do |klass|
78
+ if klass.is_a?(Class)
79
+ if klass == Detectors::Mention && mention_resolver
80
+ klass.new(type_resolver: mention_resolver)
81
+ else
82
+ klass.new
83
+ end
84
+ else
85
+ klass
86
+ end
87
+ end
88
+ end
89
+
90
+ def scan_input
91
+ while @pos < @input.length
92
+ # Check for fenced code block boundary at line start
93
+ if @line_start
94
+ new_pos = @code_tracker.check_fenced_boundary(@input, @pos, line_start: true)
95
+ if new_pos
96
+ @result << @input[@pos...new_pos]
97
+ @pos = new_pos
98
+ @line_start = new_pos > 0 && @input[new_pos - 1] == "\n"
99
+ next
100
+ end
101
+
102
+ # Check for indented code block (4+ spaces or tab)
103
+ new_pos = @code_tracker.check_indented_boundary(@input, @pos, line_start: true)
104
+ if new_pos
105
+ @result << @input[@pos...new_pos]
106
+ @pos = new_pos
107
+ @line_start = new_pos > 0 && @input[new_pos - 1] == "\n"
108
+ next
109
+ end
110
+ end
111
+
112
+ # Check for inline code boundary
113
+ if @input[@pos] == "`" && !@code_tracker.in_fenced_block &&
114
+ !@code_tracker.in_indented_block
115
+ new_pos = @code_tracker.check_inline_boundary(@input, @pos)
116
+ if new_pos
117
+ @result << @input[@pos...new_pos]
118
+ @pos = new_pos
119
+ @line_start = false
120
+ next
121
+ end
122
+ end
123
+
124
+ # If in code, pass through unchanged
125
+ if @code_tracker.in_code?
126
+ @result << @input[@pos]
127
+ @line_start = @input[@pos] == "\n"
128
+ @pos += 1
129
+ next
130
+ end
131
+
132
+ # Fast path: only try detectors if current char could start a construct
133
+ char = @input[@pos]
134
+ if TRIGGER_CHARS.include?(char)
135
+ match = detect_at_position
136
+ if match
137
+ handle_match(match)
138
+ next
139
+ end
140
+ end
141
+
142
+ @result << char
143
+ @line_start = char == "\n"
144
+ @pos += 1
145
+ end
146
+ end
147
+
148
+ def detect_at_position
149
+ @detector_instances.each do |detector|
150
+ match = detector.detect(@input, @pos)
151
+ return match if match
152
+ end
153
+ nil
154
+ end
155
+
156
+ def handle_match(match)
157
+ node = match.node
158
+ @nodes << node
159
+
160
+ # Render placeholder using tag library if available
161
+ placeholder = render_placeholder(node)
162
+ @result << placeholder
163
+
164
+ @pos = match.end_pos
165
+ @line_start = @pos > 0 && @input[@pos - 1] == "\n"
166
+ @node_index += 1
167
+ end
168
+
169
+ def render_placeholder(node)
170
+ if @tag_library
171
+ tag = @tag_library[node.class]
172
+ if tag
173
+ # Create a minimal interface for rendering
174
+ return tag.render(node, nil)
175
+ end
176
+ end
177
+
178
+ # Default placeholder format if no tag library or tag not found
179
+ default_placeholder(node)
180
+ end
181
+
182
+ def default_placeholder(node)
183
+ case node
184
+ when AST::Mention
185
+ "<<MENTION:#{@node_index}:#{node.name}>>"
186
+ when AST::Poll
187
+ "<<POLL:#{@node_index}:#{node.name}>>"
188
+ when AST::Event
189
+ "<<EVENT:#{@node_index}:#{node.name}>>"
190
+ when AST::Upload
191
+ "<<UPLOAD:#{@node_index}:#{node.sha1}>>"
192
+ else
193
+ "<<UNKNOWN:#{@node_index}>>"
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "discourse_markdown/code_block_tracker"
4
+ require_relative "discourse_markdown/detectors/base"
5
+ require_relative "discourse_markdown/detectors/mention"
6
+ require_relative "discourse_markdown/detectors/poll"
7
+ require_relative "discourse_markdown/detectors/event"
8
+ require_relative "discourse_markdown/detectors/upload"
9
+ require_relative "discourse_markdown/scanner"
10
+
11
+ module Markbridge
12
+ module Processors
13
+ module DiscourseMarkdown
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "processors/discourse_markdown"
4
+
5
+ module Markbridge
6
+ module Processors
7
+ end
8
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Renderers
5
+ module Discourse
6
+ module Builders
7
+ # Builder for list item formatting
8
+ # Handles complex multi-line formatting with proper indentation
9
+ # and preservation of blank lines and nested list items
10
+ class ListItemBuilder
11
+ # Build a formatted list item string
12
+ # @param content [String] the item content
13
+ # @param marker [String] the list marker ("- " or "1. ")
14
+ # @param indent [String] the indentation string
15
+ # @return [String]
16
+ def build(content, marker:, indent:)
17
+ lines = content.split("\n")
18
+ lines = [""] if lines.empty? # Handle empty content
19
+ first_line = "#{indent}#{marker}#{lines.first}"
20
+
21
+ return "#{first_line}\n" if lines.size == 1
22
+
23
+ # Handle multi-line content with sophisticated blank line handling
24
+ format_multiline(lines, first_line, indent)
25
+ end
26
+
27
+ private
28
+
29
+ # Format multi-line content with proper indentation
30
+ # @param lines [Array<String>] content lines
31
+ # @param first_line [String] the formatted first line
32
+ # @param indent [String] base indentation
33
+ # @return [String]
34
+ def format_multiline(lines, first_line, indent)
35
+ continuation_indent = "#{indent} "
36
+ continuation_lines = lines[1..]
37
+
38
+ rest =
39
+ continuation_lines.each_with_index.filter_map do |line, idx|
40
+ format_continuation_line(line, idx, continuation_lines, continuation_indent)
41
+ end
42
+
43
+ "#{([first_line] + rest).join("\n")}\n"
44
+ end
45
+
46
+ # Format a single continuation line
47
+ # @param line [String] the line to format
48
+ # @param idx [Integer] index in continuation_lines array
49
+ # @param continuation_lines [Array<String>] all continuation lines
50
+ # @param continuation_indent [String] indent for continuation
51
+ # @return [String, nil] formatted line or nil to skip
52
+ def format_continuation_line(line, idx, continuation_lines, continuation_indent)
53
+ # Handle empty lines
54
+ return handle_empty_line(idx, continuation_lines, continuation_indent) if line.empty?
55
+
56
+ # Check if line is already a list item (has indentation + marker)
57
+ if line.match?(/\A\s*(?:-|\d+\.)\s/)
58
+ # Already a list item - don't add extra indentation
59
+ line
60
+ else
61
+ # Regular continuation line - add indentation
62
+ "#{continuation_indent}#{line}"
63
+ end
64
+ end
65
+
66
+ # Handle empty lines in continuation
67
+ # @param idx [Integer] index in continuation_lines
68
+ # @param continuation_lines [Array<String>] all continuation lines
69
+ # @param continuation_indent [String] indent for continuation
70
+ # @return [String, nil] formatted line or nil to skip
71
+ def handle_empty_line(idx, continuation_lines, continuation_indent)
72
+ # Skip empty lines that come before nested list items (structural blanks)
73
+ next_line = continuation_lines[idx + 1]
74
+ return nil if next_line&.match?(/\A\s*(?:-|\d+\.)\s/)
75
+
76
+ # Preserve empty lines within text content (paragraph breaks) with indentation
77
+ continuation_indent
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end