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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ # Registry of BBCode tag handlers
7
+ class HandlerRegistry
8
+ attr_writer :closing_strategy
9
+
10
+ def initialize(closing_strategy: nil)
11
+ @handlers = {}
12
+ @normalized_tag_cache = {}
13
+ @element_handlers = {}
14
+ @auto_closeable_elements = Set.new
15
+ @closing_strategy = closing_strategy
16
+ end
17
+
18
+ # Register a handler for one or more tag names and associate it with an element class
19
+ # @param tag_names [String, Array<String>] tag name(s) to register
20
+ # @param handler [BaseHandler] the handler instance
21
+ def register(tag_names, handler)
22
+ element_class = handler.element_class
23
+ Array(tag_names).each do |tag_name|
24
+ normalized = tag_name.to_s.downcase
25
+ @handlers[normalized] = handler
26
+ @normalized_tag_cache[tag_name.to_s] = normalized
27
+ end
28
+ @element_handlers[element_class] = handler
29
+ @auto_closeable_elements << element_class if handler.auto_closeable?
30
+ self
31
+ end
32
+
33
+ # Get handler for a tag name
34
+ # @param tag_name [String]
35
+ # @return [BaseHandler, nil]
36
+ def [](tag_name)
37
+ tag_str = tag_name.to_s
38
+ normalized = @normalized_tag_cache[tag_str] || tag_str.downcase
39
+ @handlers[normalized]
40
+ end
41
+
42
+ # Get handler for an element instance
43
+ # @param element [Element]
44
+ # @return [BaseHandler, nil]
45
+ def handler_for_element(element)
46
+ @element_handlers[element.class]
47
+ end
48
+
49
+ # Check if an element class is auto-closeable
50
+ # @param element_class [Class]
51
+ # @return [Boolean]
52
+ def auto_closeable?(element_class)
53
+ @auto_closeable_elements.include?(element_class)
54
+ end
55
+
56
+ # Close an element using the closing strategy
57
+ # @param token [TagEndToken]
58
+ # @param context [ParserState]
59
+ # @param tokens [PeekableEnumerator, nil]
60
+ def close_element(token:, context:, tokens: nil)
61
+ @closing_strategy&.handle_close(token:, context:, registry: self, tokens:)
62
+ end
63
+
64
+ # Create the default handler registry with common BBCode tags
65
+ # @param closing_strategy [Object, nil] optional closing strategy to apply, defaults to Reordering strategy
66
+ # @return [HandlerRegistry]
67
+ def self.default(closing_strategy: nil)
68
+ # Create registry - we'll set closing strategy after registering handlers
69
+ registry = new
70
+
71
+ # Simple formatting handlers (auto-closeable)
72
+ registry.register(
73
+ %w[b bold strong],
74
+ Handlers::SimpleHandler.new(AST::Bold, auto_closeable: true),
75
+ )
76
+ registry.register(
77
+ %w[i italic em],
78
+ Handlers::SimpleHandler.new(AST::Italic, auto_closeable: true),
79
+ )
80
+ registry.register(
81
+ %w[s strike del],
82
+ Handlers::SimpleHandler.new(AST::Strikethrough, auto_closeable: true),
83
+ )
84
+ registry.register(
85
+ %w[u underline],
86
+ Handlers::SimpleHandler.new(AST::Underline, auto_closeable: true),
87
+ )
88
+ registry.register(
89
+ "sup",
90
+ Handlers::SimpleHandler.new(AST::Superscript, auto_closeable: true),
91
+ )
92
+ registry.register(
93
+ "sub",
94
+ Handlers::SimpleHandler.new(AST::Subscript, auto_closeable: true),
95
+ )
96
+
97
+ # Code handlers (raw content)
98
+ registry.register(%w[code pre tt], Handlers::RawHandler.new(AST::Code))
99
+
100
+ # Image handler
101
+ registry.register("img", Handlers::ImageHandler.new)
102
+
103
+ # Attachment handler
104
+ registry.register(%w[attach attachment], Handlers::AttachmentHandler.new)
105
+
106
+ # URL handler
107
+ registry.register(%w[url link iurl], Handlers::UrlHandler.new)
108
+
109
+ # Email handler
110
+ registry.register("email", Handlers::EmailHandler.new)
111
+
112
+ # Quote handler
113
+ registry.register("quote", Handlers::QuoteHandler.new)
114
+
115
+ # Spoiler handler
116
+ registry.register(%w[spoiler hide], Handlers::SpoilerHandler.new)
117
+
118
+ # Color handler
119
+ registry.register("color", Handlers::ColorHandler.new)
120
+
121
+ # Size handler
122
+ registry.register("size", Handlers::SizeHandler.new)
123
+
124
+ # Alignment handlers (single instance - reads alignment from tag name)
125
+ registry.register(%w[center left right justify], Handlers::AlignHandler.new)
126
+
127
+ # Self-closing handlers
128
+ registry.register("br", Handlers::SelfClosingHandler.new(AST::LineBreak))
129
+ registry.register("hr", Handlers::SelfClosingHandler.new(AST::HorizontalRule))
130
+
131
+ # List handlers
132
+ registry.register(%w[list ul ol ulist olist], Handlers::ListHandler.new)
133
+ registry.register(%w[* li .], Handlers::ListItemHandler.new)
134
+
135
+ # Set the closing strategy
136
+ registry.closing_strategy = closing_strategy || default_closing_strategy(registry)
137
+
138
+ registry
139
+ end
140
+
141
+ # Build a registry from the default configuration with optional customization
142
+ # @yield [HandlerRegistry] the registry to customize
143
+ # @return [HandlerRegistry]
144
+ def self.build_from_default
145
+ registry = default
146
+ yield(registry) if block_given?
147
+ registry
148
+ end
149
+
150
+ # Create the default closing strategy for a registry
151
+ # @param registry [HandlerRegistry] the registry to create a strategy for
152
+ # @return [ClosingStrategies::Reordering]
153
+ def self.default_closing_strategy(registry)
154
+ reconciler = ClosingStrategies::TagReconciler.new(registry:)
155
+ ClosingStrategies::Reordering.new(reconciler)
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module Handlers
7
+ # Handler for ALIGN tags (center, left, right, justify)
8
+ # Creates a generic Align element with the appropriate alignment
9
+ class AlignHandler < BaseHandler
10
+ def initialize(alignment = nil)
11
+ @alignment = alignment
12
+ @element_class = AST::Align
13
+ end
14
+
15
+ def on_open(token:, context:, registry:, tokens: nil)
16
+ alignment = @alignment || token.tag.downcase
17
+ element = AST::Align.new(alignment:)
18
+ context.push(element, token:)
19
+ end
20
+
21
+ attr_reader :element_class
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module Handlers
7
+ # Handler for ATTACH/ATTACHMENT tags across platforms (phpBB, vBulletin, XenForo, SMF mods).
8
+ #
9
+ # Examples:
10
+ # - [attachment=0]image.jpg[/attachment] # phpBB (index + filename)
11
+ # - [ATTACH=CONFIG]1234[/ATTACH] # vBulletin (id)
12
+ # - [ATTACH type="full" alt="diagram"]5678[/ATTACH] # XenForo (id + alt)
13
+ # - [attach id=2 msg=9876] # SMF-style self-contained attributes
14
+ class AttachmentHandler < BaseHandler
15
+ def initialize(collector: RawContentCollector.new)
16
+ @collector = collector
17
+ @element_class = AST::Attachment
18
+ end
19
+
20
+ def on_open(token:, context:, registry:, tokens: nil)
21
+ content = collect_content(token:, tokens:)
22
+ attachment = build_attachment(token:, content:)
23
+
24
+ context.add_child(attachment)
25
+ end
26
+
27
+ # Closing tags are consumed during collection; if one leaks through, treat as text.
28
+ def on_close(token:, context:, registry:, tokens: nil)
29
+ context.add_child(AST::Text.new(token.source))
30
+ end
31
+
32
+ private
33
+
34
+ def collect_content(token:, tokens:)
35
+ return unless tokens
36
+ return unless closing_tag_ahead?(token.tag, tokens)
37
+
38
+ @collector.collect(token.tag, tokens).content
39
+ end
40
+
41
+ def closing_tag_ahead?(tag, tokens)
42
+ tokens.peek_ahead(100).any? { |token| token.is_a?(TagEndToken) && token.tag == tag }
43
+ end
44
+
45
+ def build_attachment(token:, content:)
46
+ attrs = normalize_attrs(token.attrs)
47
+ option = attrs[:option]
48
+ body = presence(content&.strip)
49
+
50
+ id = preferred_id(attrs)
51
+ index = preferred_index(attrs)
52
+ filename = attrs[:filename]
53
+ alt = attrs[:alt]
54
+
55
+ index ||= option if option && numeric?(option)
56
+ id, filename = apply_body_content(body:, id:, index:, filename:)
57
+
58
+ AST::Attachment.new(id:, index:, filename:, alt:)
59
+ end
60
+
61
+ def normalize_attrs(attrs)
62
+ attrs.transform_values { |value| presence(value) }
63
+ end
64
+
65
+ def apply_body_content(body:, id:, index:, filename:)
66
+ return id, filename unless body
67
+
68
+ if id.nil?
69
+ return body, filename if index.nil?
70
+ return body, filename if numeric?(body)
71
+ end
72
+
73
+ filename ||= body
74
+
75
+ [id, filename]
76
+ end
77
+
78
+ def preferred_id(attrs)
79
+ presence(attrs[:msg]) || presence(attrs[:id])
80
+ end
81
+
82
+ def preferred_index(attrs)
83
+ explicit_index = presence(attrs[:index])
84
+ smf_index = presence(attrs[:id]) if attrs[:msg]
85
+
86
+ explicit_index || smf_index
87
+ end
88
+
89
+ def presence(value)
90
+ return if value.nil?
91
+ return value unless value.is_a?(String)
92
+
93
+ stripped = value.strip
94
+ stripped unless stripped.empty?
95
+ end
96
+
97
+ def numeric?(value)
98
+ value.is_a?(String) && value.match?(/\A\d+\z/)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module Handlers
7
+ class BaseHandler
8
+ # Default opening behavior: create element and push to context
9
+ # Subclasses should override this method
10
+ # @param token [TagStartToken]
11
+ # @param context [ParserState]
12
+ # @param registry [HandlerRegistry]
13
+ # @param tokens [Enumerator, nil]
14
+ # @return [void]
15
+ def on_open(token:, context:, registry:, tokens: nil)
16
+ # Default: do nothing, subclasses override
17
+ end
18
+
19
+ # Default closing behavior: pop matching element from stack
20
+ # Subclasses can override or call super for custom behavior
21
+ # @param token [TagEndToken]
22
+ # @param context [ParserState]
23
+ # @param registry [HandlerRegistry]
24
+ # @param tokens [PeekableEnumerator, nil]
25
+ # @return [void]
26
+ def on_close(token:, context:, registry:, tokens: nil)
27
+ registry.close_element(token:, context:, tokens:)
28
+ end
29
+
30
+ # Whether elements created by this handler can be auto-closed
31
+ # @return [Boolean]
32
+ def auto_closeable?
33
+ false
34
+ end
35
+
36
+ # The element class created by this handler
37
+ # Subclasses must expose this via attr_reader :element_class
38
+ # @return [Class]
39
+ attr_reader :element_class
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module Handlers
7
+ # Handler for [code]...[/code] tags
8
+ #
9
+ # Preserves content as-is without parsing nested BBCode
10
+ # Inherits from RawHandler using AST::Code element
11
+ #
12
+ # Example:
13
+ # [code=python]
14
+ # def hello_world():
15
+ # print("Hello, world!")
16
+ # [/code]
17
+ class CodeHandler < RawHandler
18
+ def initialize
19
+ super(AST::Code)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module Handlers
7
+ # Handler for COLOR tags
8
+ # Supports:
9
+ # - [color=red]text[/color]
10
+ # - [color=#FF0000]text[/color]
11
+ class ColorHandler < BaseHandler
12
+ def initialize
13
+ @element_class = AST::Color
14
+ end
15
+
16
+ def on_open(token:, context:, registry:, tokens: nil)
17
+ color = token.attrs[:color] || token.attrs[:option]
18
+ element = AST::Color.new(color:)
19
+ context.push(element, token:)
20
+ end
21
+
22
+ def auto_closeable?
23
+ true
24
+ end
25
+
26
+ attr_reader :element_class
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module Handlers
7
+ # Handler for EMAIL tags
8
+ # Similar to UrlHandler but for email addresses
9
+ class EmailHandler < BaseHandler
10
+ def initialize
11
+ @element_class = AST::Email
12
+ end
13
+
14
+ def on_open(token:, context:, registry:, tokens: nil)
15
+ address = token.attrs[:email] || token.attrs[:address] || token.attrs[:option]
16
+ element = AST::Email.new(address:)
17
+ context.push(element, token:)
18
+ end
19
+
20
+ attr_reader :element_class
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module Handlers
7
+ # Handler for IMG tags
8
+ # Supports:
9
+ # - [img]url[/img]
10
+ # - [img=100x100]url[/img]
11
+ # - [img width=100]url[/img]
12
+ # - [img width=100 height=50]url[/img]
13
+ class ImageHandler < RawHandler
14
+ def initialize
15
+ super(AST::Image)
16
+ end
17
+
18
+ private
19
+
20
+ def create_element(token:, content:)
21
+ # Extract dimensions from attributes or option
22
+ width = sanitize_dimension(token.attrs[:width])
23
+ height = sanitize_dimension(token.attrs[:height])
24
+
25
+ # Parse option for WIDTHxHEIGHT format (e.g., [img=100x200])
26
+ if token.attrs[:option]&.match?(/^\d+x\d+$/i)
27
+ dimensions = token.attrs[:option].split("x", 2)
28
+ width = sanitize_dimension(dimensions[0])
29
+ height = sanitize_dimension(dimensions[1])
30
+ elsif token.attrs[:option]&.match?(/^\d+$/)
31
+ # Just a number means width
32
+ width = sanitize_dimension(token.attrs[:option])
33
+ end
34
+
35
+ # Content is the URL
36
+ AST::Image.new(src: content, width:, height:)
37
+ end
38
+
39
+ # Convert dimension to positive integer or nil
40
+ # Handles string input from BBCode attributes
41
+ def sanitize_dimension(value)
42
+ return nil if value.nil?
43
+
44
+ dim = value.to_i
45
+ dim.positive? ? dim : nil
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module Handlers
7
+ # Handler for list tags (list, ul, ol, etc.)
8
+ class ListHandler < BaseHandler
9
+ def initialize
10
+ @element_class = AST::List
11
+ end
12
+
13
+ def on_open(token:, context:, registry:, tokens: nil)
14
+ # Check if ordered: explicit ol/olist tag, or type=1, or option=1
15
+ ordered =
16
+ %w[ol olist].include?(token.tag) || token.attrs[:type] == "1" ||
17
+ token.attrs[:option] == "1"
18
+
19
+ element = AST::List.new(ordered:)
20
+ context.push(element, token:)
21
+ end
22
+
23
+ def on_close(token:, context:, registry:, tokens: nil)
24
+ # Auto-close open list item before closing list
25
+ context.pop if context.current.is_a?(AST::ListItem)
26
+
27
+ # Then use default closing behavior
28
+ super
29
+ end
30
+
31
+ attr_reader :element_class
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module Handlers
7
+ # Handler for list item tags (*, li, .)
8
+ class ListItemHandler < BaseHandler
9
+ def initialize
10
+ @element_class = AST::ListItem
11
+ end
12
+
13
+ def on_open(token:, context:, registry:, tokens: nil)
14
+ # Auto-close previous list item if opening a new one
15
+ context.pop if context.current.is_a?(AST::ListItem)
16
+
17
+ element = AST::ListItem.new
18
+ context.push(element, token:)
19
+ end
20
+
21
+ attr_reader :element_class
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module Handlers
7
+ # Handler for QUOTE tags
8
+ # Supports:
9
+ # - [quote]text[/quote]
10
+ # - [quote=author]text[/quote]
11
+ # - [quote="author"]text[/quote]
12
+ # - [quote author=username]text[/quote]
13
+ # - [quote="username, post:123, topic:456"]text[/quote] (Discourse format)
14
+ class QuoteHandler < BaseHandler
15
+ def initialize
16
+ @element_class = AST::Quote
17
+ end
18
+
19
+ def on_open(token:, context:, registry:, tokens: nil)
20
+ # Extract quote attributes
21
+ author = nil
22
+ post = nil
23
+ topic = nil
24
+ username = nil
25
+
26
+ # Check for author attribute or option
27
+ if token.attrs[:author]
28
+ author = token.attrs[:author]
29
+ elsif token.attrs[:option]
30
+ # Parse Discourse-style quote: "username, post:123, topic:456"
31
+ option = token.attrs[:option]
32
+ if option.match?(/,\s*post:\d+/)
33
+ # Discourse format with post/topic
34
+ parts = option.split(",").map(&:strip)
35
+ username = parts[0]
36
+ parts[1..].each do |part|
37
+ if part =~ /^post:(\d+)$/
38
+ post = ::Regexp.last_match(1)
39
+ elsif part =~ /^topic:(\d+)$/
40
+ topic = ::Regexp.last_match(1)
41
+ end
42
+ end
43
+ author = username
44
+ else
45
+ # Simple author attribution
46
+ author = option
47
+ end
48
+ end
49
+
50
+ # Check for explicit username, post, topic attributes (override option if present)
51
+ username = token.attrs[:username] if token.attrs[:username]
52
+ post = token.attrs[:post] if token.attrs[:post]
53
+ topic = token.attrs[:topic] if token.attrs[:topic]
54
+
55
+ element = AST::Quote.new(author:, post:, topic:, username:)
56
+ context.push(element, token:)
57
+ end
58
+
59
+ attr_reader :element_class
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module Handlers
7
+ # Handler for raw/preformatted tags that preserve content as-is
8
+ # Uses RawContentCollector strategy to consume tokens until closing tag
9
+ # without parsing nested BBCode
10
+ class RawHandler < BaseHandler
11
+ def initialize(element_class, collector: RawContentCollector.new)
12
+ @element_class = element_class
13
+ @collector = collector
14
+ end
15
+
16
+ def on_open(token:, context:, registry:, tokens:)
17
+ result = @collector.collect(token.tag, tokens)
18
+
19
+ # Track unclosed raw tags for diagnostics
20
+ context.mark_unclosed_raw!(token.tag) if result.unclosed?
21
+
22
+ element = create_element(token:, content: result.content)
23
+ context.add_child(element)
24
+ end
25
+
26
+ # RawHandler doesn't push to stack, so on_close should do nothing
27
+ def on_close(token:, context:, registry:, tokens: nil)
28
+ # Raw content was already consumed by collector
29
+ # Closing tag was consumed by collector, so this shouldn't be called
30
+ # If it is called, treat as text
31
+ context.add_child(AST::Text.new(token.source))
32
+ end
33
+
34
+ attr_reader :element_class
35
+
36
+ private
37
+
38
+ def create_element(token:, content:)
39
+ language = token.attrs[:lang] || token.attrs[:option]
40
+ element = @element_class.new(language:)
41
+ element << AST::Text.new(content) unless content.empty?
42
+ element
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module Handlers
7
+ # Handler for self-closing tags (br, hr, etc.)
8
+ class SelfClosingHandler < BaseHandler
9
+ def initialize(element_class)
10
+ @element_class = element_class
11
+ end
12
+
13
+ def on_open(token:, context:, registry:, tokens: nil)
14
+ element = @element_class.new
15
+ context.add_child(element)
16
+ end
17
+
18
+ def on_close(token:, context:, registry:, tokens: nil)
19
+ # Treat unexpected closing tag as text
20
+ context.add_child(AST::Text.new(token.source))
21
+ end
22
+
23
+ attr_reader :element_class
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end