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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/lib/markbridge/all.rb +9 -0
- data/lib/markbridge/ast/align.rb +24 -0
- data/lib/markbridge/ast/attachment.rb +42 -0
- data/lib/markbridge/ast/bold.rb +13 -0
- data/lib/markbridge/ast/code.rb +27 -0
- data/lib/markbridge/ast/color.rb +25 -0
- data/lib/markbridge/ast/document.rb +27 -0
- data/lib/markbridge/ast/element.rb +47 -0
- data/lib/markbridge/ast/email.rb +27 -0
- data/lib/markbridge/ast/event.rb +59 -0
- data/lib/markbridge/ast/heading.rb +23 -0
- data/lib/markbridge/ast/horizontal_rule.rb +12 -0
- data/lib/markbridge/ast/image.rb +35 -0
- data/lib/markbridge/ast/italic.rb +13 -0
- data/lib/markbridge/ast/line_break.rb +12 -0
- data/lib/markbridge/ast/list.rb +52 -0
- data/lib/markbridge/ast/list_item.rb +13 -0
- data/lib/markbridge/ast/markdown_text.rb +37 -0
- data/lib/markbridge/ast/mention.rb +29 -0
- data/lib/markbridge/ast/node.rb +19 -0
- data/lib/markbridge/ast/paragraph.rb +13 -0
- data/lib/markbridge/ast/poll.rb +74 -0
- data/lib/markbridge/ast/quote.rb +46 -0
- data/lib/markbridge/ast/size.rb +25 -0
- data/lib/markbridge/ast/spoiler.rb +27 -0
- data/lib/markbridge/ast/strikethrough.rb +13 -0
- data/lib/markbridge/ast/subscript.rb +13 -0
- data/lib/markbridge/ast/superscript.rb +13 -0
- data/lib/markbridge/ast/text.rb +38 -0
- data/lib/markbridge/ast/underline.rb +13 -0
- data/lib/markbridge/ast/upload.rb +74 -0
- data/lib/markbridge/ast/url.rb +27 -0
- data/lib/markbridge/ast.rb +42 -0
- data/lib/markbridge/configuration.rb +11 -0
- data/lib/markbridge/gem_loader.rb +23 -0
- data/lib/markbridge/parsers/bbcode/closing_strategies/base.rb +37 -0
- data/lib/markbridge/parsers/bbcode/closing_strategies/reordering.rb +17 -0
- data/lib/markbridge/parsers/bbcode/closing_strategies/strict.rb +12 -0
- data/lib/markbridge/parsers/bbcode/closing_strategies/tag_reconciler.rb +121 -0
- data/lib/markbridge/parsers/bbcode/errors/max_depth_exceeded_error.rb +13 -0
- data/lib/markbridge/parsers/bbcode/handler_registry.rb +160 -0
- data/lib/markbridge/parsers/bbcode/handlers/align_handler.rb +26 -0
- data/lib/markbridge/parsers/bbcode/handlers/attachment_handler.rb +104 -0
- data/lib/markbridge/parsers/bbcode/handlers/base_handler.rb +44 -0
- data/lib/markbridge/parsers/bbcode/handlers/code_handler.rb +25 -0
- data/lib/markbridge/parsers/bbcode/handlers/color_handler.rb +31 -0
- data/lib/markbridge/parsers/bbcode/handlers/email_handler.rb +25 -0
- data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +51 -0
- data/lib/markbridge/parsers/bbcode/handlers/list_handler.rb +36 -0
- data/lib/markbridge/parsers/bbcode/handlers/list_item_handler.rb +26 -0
- data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +64 -0
- data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +48 -0
- data/lib/markbridge/parsers/bbcode/handlers/self_closing_handler.rb +28 -0
- data/lib/markbridge/parsers/bbcode/handlers/simple_handler.rb +28 -0
- data/lib/markbridge/parsers/bbcode/handlers/size_handler.rb +31 -0
- data/lib/markbridge/parsers/bbcode/handlers/spoiler_handler.rb +28 -0
- data/lib/markbridge/parsers/bbcode/handlers/url_handler.rb +24 -0
- data/lib/markbridge/parsers/bbcode/parser.rb +123 -0
- data/lib/markbridge/parsers/bbcode/parser_state.rb +93 -0
- data/lib/markbridge/parsers/bbcode/peekable_enumerator.rb +126 -0
- data/lib/markbridge/parsers/bbcode/raw_content_collector.rb +35 -0
- data/lib/markbridge/parsers/bbcode/raw_content_result.rb +25 -0
- data/lib/markbridge/parsers/bbcode/scanner.rb +231 -0
- data/lib/markbridge/parsers/bbcode/tokens/tag_end_token.rb +21 -0
- data/lib/markbridge/parsers/bbcode/tokens/tag_start_token.rb +23 -0
- data/lib/markbridge/parsers/bbcode/tokens/text_token.rb +23 -0
- data/lib/markbridge/parsers/bbcode/tokens/token.rb +16 -0
- data/lib/markbridge/parsers/bbcode.rb +56 -0
- data/lib/markbridge/parsers/html/handler_registry.rb +87 -0
- data/lib/markbridge/parsers/html/handlers/base_handler.rb +27 -0
- data/lib/markbridge/parsers/html/handlers/image_handler.rb +40 -0
- data/lib/markbridge/parsers/html/handlers/list_handler.rb +29 -0
- data/lib/markbridge/parsers/html/handlers/list_item_handler.rb +26 -0
- data/lib/markbridge/parsers/html/handlers/paragraph_handler.rb +17 -0
- data/lib/markbridge/parsers/html/handlers/quote_handler.rb +28 -0
- data/lib/markbridge/parsers/html/handlers/raw_handler.rb +33 -0
- data/lib/markbridge/parsers/html/handlers/simple_handler.rb +26 -0
- data/lib/markbridge/parsers/html/handlers/url_handler.rb +27 -0
- data/lib/markbridge/parsers/html/parser.rb +113 -0
- data/lib/markbridge/parsers/html.rb +30 -0
- data/lib/markbridge/parsers/media_wiki/inline_parser.rb +332 -0
- data/lib/markbridge/parsers/media_wiki/parser.rb +279 -0
- data/lib/markbridge/parsers/media_wiki.rb +15 -0
- data/lib/markbridge/parsers/text_formatter/handler_registry.rb +130 -0
- data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +33 -0
- data/lib/markbridge/parsers/text_formatter/handlers/attribute_handler.rb +40 -0
- data/lib/markbridge/parsers/text_formatter/handlers/base_handler.rb +45 -0
- data/lib/markbridge/parsers/text_formatter/handlers/code_handler.rb +28 -0
- data/lib/markbridge/parsers/text_formatter/handlers/email_handler.rb +27 -0
- data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +32 -0
- data/lib/markbridge/parsers/text_formatter/handlers/list_handler.rb +31 -0
- data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +33 -0
- data/lib/markbridge/parsers/text_formatter/handlers/simple_handler.rb +37 -0
- data/lib/markbridge/parsers/text_formatter/handlers/url_handler.rb +29 -0
- data/lib/markbridge/parsers/text_formatter/parser.rb +132 -0
- data/lib/markbridge/parsers/text_formatter.rb +31 -0
- data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +199 -0
- data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +57 -0
- data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +73 -0
- data/lib/markbridge/processors/discourse_markdown/detectors/mention.rb +57 -0
- data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +90 -0
- data/lib/markbridge/processors/discourse_markdown/detectors/upload.rb +123 -0
- data/lib/markbridge/processors/discourse_markdown/scanner.rb +199 -0
- data/lib/markbridge/processors/discourse_markdown.rb +16 -0
- data/lib/markbridge/processors.rb +8 -0
- data/lib/markbridge/renderers/discourse/builders/list_item_builder.rb +83 -0
- data/lib/markbridge/renderers/discourse/markdown_escaper.rb +468 -0
- data/lib/markbridge/renderers/discourse/render_context.rb +80 -0
- data/lib/markbridge/renderers/discourse/renderer.rb +63 -0
- data/lib/markbridge/renderers/discourse/rendering_interface.rb +86 -0
- data/lib/markbridge/renderers/discourse/tag.rb +29 -0
- data/lib/markbridge/renderers/discourse/tag_library.rb +67 -0
- data/lib/markbridge/renderers/discourse/tags/align_tag.rb +24 -0
- data/lib/markbridge/renderers/discourse/tags/attachment_tag.rb +46 -0
- data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +18 -0
- data/lib/markbridge/renderers/discourse/tags/code_tag.rb +54 -0
- data/lib/markbridge/renderers/discourse/tags/color_tag.rb +27 -0
- data/lib/markbridge/renderers/discourse/tags/email_tag.rb +24 -0
- data/lib/markbridge/renderers/discourse/tags/event_tag.rb +49 -0
- data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +21 -0
- data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +16 -0
- data/lib/markbridge/renderers/discourse/tags/image_tag.rb +29 -0
- data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +18 -0
- data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +16 -0
- data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +87 -0
- data/lib/markbridge/renderers/discourse/tags/list_tag.rb +39 -0
- data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +34 -0
- data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +21 -0
- data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +51 -0
- data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +32 -0
- data/lib/markbridge/renderers/discourse/tags/size_tag.rb +27 -0
- data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +24 -0
- data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +18 -0
- data/lib/markbridge/renderers/discourse/tags/subscript_tag.rb +19 -0
- data/lib/markbridge/renderers/discourse/tags/superscript_tag.rb +19 -0
- data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +19 -0
- data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +80 -0
- data/lib/markbridge/renderers/discourse/tags/url_tag.rb +24 -0
- data/lib/markbridge/renderers/discourse.rb +50 -0
- data/lib/markbridge/version.rb +5 -0
- data/lib/markbridge.rb +201 -0
- 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
|