markbridge 0.2.0 → 0.3.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 +4 -4
- data/lib/markbridge/ast/quote.rb +45 -15
- data/lib/markbridge/ast/text.rb +7 -4
- data/lib/markbridge/ast/url.rb +15 -0
- data/lib/markbridge/parsers/bbcode/handler_registry.rb +27 -1
- data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +32 -9
- data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +8 -4
- data/lib/markbridge/parsers/bbcode/parser.rb +6 -3
- data/lib/markbridge/parsers/bbcode/scanner.rb +133 -53
- data/lib/markbridge/parsers/html/handler_registry.rb +27 -1
- data/lib/markbridge/parsers/html/parser.rb +54 -13
- data/lib/markbridge/parsers/media_wiki/inline_parser.rb +95 -57
- data/lib/markbridge/parsers/media_wiki/inline_tag_registry.rb +24 -1
- data/lib/markbridge/parsers/media_wiki/parser.rb +5 -2
- data/lib/markbridge/parsers/text_formatter/handler_registry.rb +23 -1
- data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +31 -2
- data/lib/markbridge/parsers/text_formatter/parser.rb +10 -3
- data/lib/markbridge/renderers/discourse/markdown_escaper.rb +12 -1
- data/lib/markbridge/renderers/discourse/render_context.rb +74 -11
- data/lib/markbridge/renderers/discourse/renderer.rb +63 -13
- data/lib/markbridge/renderers/discourse/rendering_interface.rb +27 -2
- data/lib/markbridge/renderers/discourse/tag_library.rb +20 -0
- data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +8 -6
- data/lib/markbridge/renderers/discourse/tags/url_tag.rb +36 -2
- data/lib/markbridge/version.rb +1 -1
- data/lib/markbridge.rb +0 -1
- metadata +1 -10
- data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +0 -218
- data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +0 -63
- data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +0 -64
- data/lib/markbridge/processors/discourse_markdown/detectors/mention.rb +0 -57
- data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +0 -52
- data/lib/markbridge/processors/discourse_markdown/detectors/upload.rb +0 -98
- data/lib/markbridge/processors/discourse_markdown/scanner.rb +0 -194
- data/lib/markbridge/processors/discourse_markdown.rb +0 -16
- data/lib/markbridge/processors.rb +0 -8
|
@@ -22,7 +22,7 @@ module Markbridge
|
|
|
22
22
|
if block_given?
|
|
23
23
|
HandlerRegistry.build_from_default(&block)
|
|
24
24
|
else
|
|
25
|
-
handlers || HandlerRegistry.
|
|
25
|
+
handlers || HandlerRegistry.shared_default
|
|
26
26
|
end
|
|
27
27
|
@unknown_tags = Hash.new(0)
|
|
28
28
|
end
|
|
@@ -64,6 +64,12 @@ module Markbridge
|
|
|
64
64
|
# Create root AST document
|
|
65
65
|
document = AST::Document.new
|
|
66
66
|
|
|
67
|
+
# Whitespace preservation is tracked during descent instead of
|
|
68
|
+
# walking node.ancestors per text node; seed the counter from
|
|
69
|
+
# the parse root's real ancestry so pre-parsed subtrees inside
|
|
70
|
+
# a <pre> keep their semantics.
|
|
71
|
+
@preserve_depth = initial_preserve_depth(doc)
|
|
72
|
+
|
|
67
73
|
# Process all nodes
|
|
68
74
|
children.each { |node| process_node(node, document) }
|
|
69
75
|
trim_trailing_whitespace(document)
|
|
@@ -92,16 +98,29 @@ module Markbridge
|
|
|
92
98
|
end
|
|
93
99
|
end
|
|
94
100
|
|
|
101
|
+
# Anything WHITESPACE_RUN would actually change: a non-space
|
|
102
|
+
# whitespace character, or two-plus consecutive spaces. Prose with
|
|
103
|
+
# only single spaces passes through without the gsub copy.
|
|
104
|
+
COLLAPSIBLE_WHITESPACE = /[\t\r\n\f]| {2}/
|
|
105
|
+
private_constant :COLLAPSIBLE_WHITESPACE
|
|
106
|
+
|
|
107
|
+
# Anything String#rstrip would remove from the end (ASCII
|
|
108
|
+
# whitespace or NUL), or the empty string.
|
|
109
|
+
TRAILING_STRIPPABLE = /[\0\t\n\v\f\r ]\z|\A\z/
|
|
110
|
+
private_constant :TRAILING_STRIPPABLE
|
|
111
|
+
|
|
95
112
|
# Process a text node
|
|
96
113
|
# @param node [Nokogiri::XML::Text]
|
|
97
114
|
# @param parent [AST::Element]
|
|
98
115
|
def process_text_node(node, parent)
|
|
99
|
-
|
|
100
|
-
|
|
116
|
+
text = node.text
|
|
117
|
+
|
|
118
|
+
if @preserve_depth.positive?
|
|
119
|
+
parent << AST::Text.new(text)
|
|
101
120
|
return
|
|
102
121
|
end
|
|
103
122
|
|
|
104
|
-
text =
|
|
123
|
+
text = text.gsub(WHITESPACE_RUN, " ") if text.match?(COLLAPSIBLE_WHITESPACE)
|
|
105
124
|
# Drop leading whitespace at the start of an element's content,
|
|
106
125
|
# matching the browser rule that whitespace at the beginning of a
|
|
107
126
|
# block (or before any inline content) is collapsed away.
|
|
@@ -125,6 +144,21 @@ module Markbridge
|
|
|
125
144
|
# still collapse the whitespace before them.
|
|
126
145
|
trim_trailing_whitespace(parent) if @handlers.block_level_tags.include?(tag_name)
|
|
127
146
|
|
|
147
|
+
preserving = @handlers.whitespace_preserving_tags.include?(tag_name)
|
|
148
|
+
@preserve_depth += 1 if preserving
|
|
149
|
+
|
|
150
|
+
dispatch_element(node, tag_name, parent, preserving)
|
|
151
|
+
|
|
152
|
+
@preserve_depth -= 1 if preserving
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Dispatch an element to its handler (or the unknown-tag path) and
|
|
156
|
+
# process its children.
|
|
157
|
+
# @param node [Nokogiri::XML::Element]
|
|
158
|
+
# @param tag_name [String]
|
|
159
|
+
# @param parent [AST::Element]
|
|
160
|
+
# @param preserving [Boolean] whether this tag preserves whitespace
|
|
161
|
+
def dispatch_element(node, tag_name, parent, preserving)
|
|
128
162
|
handler = @handlers[tag_name]
|
|
129
163
|
return handle_unknown_tag(node, parent) unless handler
|
|
130
164
|
|
|
@@ -134,9 +168,7 @@ module Markbridge
|
|
|
134
168
|
return unless ast_element
|
|
135
169
|
|
|
136
170
|
process_children(node, ast_element)
|
|
137
|
-
unless
|
|
138
|
-
trim_trailing_whitespace(ast_element)
|
|
139
|
-
end
|
|
171
|
+
trim_trailing_whitespace(ast_element) unless preserving
|
|
140
172
|
end
|
|
141
173
|
|
|
142
174
|
# Handle unknown tag by tracking it and ignoring the wrapper
|
|
@@ -148,12 +180,16 @@ module Markbridge
|
|
|
148
180
|
process_children(node, parent)
|
|
149
181
|
end
|
|
150
182
|
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
183
|
+
# Number of whitespace-preserving elements enclosing the parse
|
|
184
|
+
# root, the root itself included. Computed once per parse; from
|
|
185
|
+
# there the counter is maintained during descent. Documents and
|
|
186
|
+
# fragments count themselves harmlessly — their #name
|
|
187
|
+
# ("document", "#document-fragment") never matches a tag set.
|
|
188
|
+
# @param root [Nokogiri::XML::Node]
|
|
189
|
+
# @return [Integer]
|
|
190
|
+
def initial_preserve_depth(root)
|
|
191
|
+
[root, *root.ancestors].count do |node|
|
|
192
|
+
@handlers.whitespace_preserving_tags.include?(node.name)
|
|
157
193
|
end
|
|
158
194
|
end
|
|
159
195
|
|
|
@@ -174,6 +210,11 @@ module Markbridge
|
|
|
174
210
|
last = element.children.last
|
|
175
211
|
return unless last.instance_of?(AST::Text)
|
|
176
212
|
|
|
213
|
+
# Fast exit when there is nothing to strip: no trailing byte
|
|
214
|
+
# rstrip would remove (ASCII whitespace or NUL), and not the
|
|
215
|
+
# empty string (which the pop below drops entirely).
|
|
216
|
+
return unless last.text.match?(TRAILING_STRIPPABLE)
|
|
217
|
+
|
|
177
218
|
trimmed = last.text.rstrip
|
|
178
219
|
element.children.pop
|
|
179
220
|
element << AST::Text.new(trimmed) unless trimmed.empty?
|
|
@@ -7,6 +7,14 @@ module Markbridge
|
|
|
7
7
|
# Handles bold ('''), italic (''), links ([[...]]), external links ([...]),
|
|
8
8
|
# and HTML inline tags via an InlineTagRegistry.
|
|
9
9
|
#
|
|
10
|
+
# The parser works in *byte* offsets (byteindex/byteslice/getbyte):
|
|
11
|
+
# character indices are O(pos) on multibyte input in CRuby, and
|
|
12
|
+
# per-character probes allocate a String each. The main loop jumps
|
|
13
|
+
# between interesting bytes with a single regex search and copies
|
|
14
|
+
# the skipped span in one slice. Invariant: +@pos+ always sits on a
|
|
15
|
+
# character boundary — jumps land on ASCII matches and advances
|
|
16
|
+
# step over ASCII bytes or whole matches.
|
|
17
|
+
#
|
|
10
18
|
# @example With custom registry
|
|
11
19
|
# registry = InlineTagRegistry.build_from_default do |r|
|
|
12
20
|
# r.register("mark", :formatting, AST::Bold)
|
|
@@ -15,6 +23,19 @@ module Markbridge
|
|
|
15
23
|
class InlineParser
|
|
16
24
|
MAX_INLINE_DEPTH = 20
|
|
17
25
|
|
|
26
|
+
# Bytes the main loop reacts to: apostrophes ('), link openers ([)
|
|
27
|
+
# and HTML tag openers (<). ASCII-only, so a byteindex match always
|
|
28
|
+
# lands on a character boundary.
|
|
29
|
+
INTERESTING = /['\[<]/
|
|
30
|
+
APOSTROPHE = 39 # '
|
|
31
|
+
BRACKET_OPEN = 91 # [
|
|
32
|
+
private_constant :INTERESTING, :APOSTROPHE, :BRACKET_OPEN
|
|
33
|
+
|
|
34
|
+
# Matches an HTML-like tag at the search position (\G with
|
|
35
|
+
# byteindex anchors at the offset).
|
|
36
|
+
HTML_TAG_AT_CURSOR = %r{\G<(/?)([a-z]+)(?: [^>]*)?\s*(/?)>}i
|
|
37
|
+
private_constant :HTML_TAG_AT_CURSOR
|
|
38
|
+
|
|
18
39
|
# @return [Hash{String => Integer}] tag-name → occurrence count for
|
|
19
40
|
# HTML-like inline tags whose names are not registered. Shared
|
|
20
41
|
# with nested InlineParser instances so depth-recursive parses
|
|
@@ -22,7 +43,7 @@ module Markbridge
|
|
|
22
43
|
attr_reader :unknown_tags
|
|
23
44
|
|
|
24
45
|
def initialize(handlers: nil, depth: 0, unknown_tags: nil)
|
|
25
|
-
@registry = handlers || InlineTagRegistry.
|
|
46
|
+
@registry = handlers || InlineTagRegistry.shared_default
|
|
26
47
|
@depth = depth
|
|
27
48
|
@unknown_tags = unknown_tags || Hash.new(0)
|
|
28
49
|
end
|
|
@@ -34,56 +55,66 @@ module Markbridge
|
|
|
34
55
|
def parse(text, parent:)
|
|
35
56
|
@input = text
|
|
36
57
|
@pos = 0
|
|
37
|
-
@length = text.
|
|
58
|
+
@length = text.bytesize
|
|
38
59
|
@parent = parent
|
|
39
60
|
@text_buffer = +""
|
|
40
61
|
|
|
41
|
-
while
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
flush_text
|
|
49
|
-
@input[@pos + 1] == "[" ? parse_internal_link : parse_external_link
|
|
50
|
-
when "<"
|
|
51
|
-
flush_text
|
|
52
|
-
parse_html_tag
|
|
53
|
-
else
|
|
54
|
-
append_literal(char)
|
|
55
|
-
end
|
|
62
|
+
while (span_end = @input.byteindex(INTERESTING, @pos))
|
|
63
|
+
# Unconditional on purpose: when the interesting byte sits at
|
|
64
|
+
# @pos the slice is empty and both lines are no-ops.
|
|
65
|
+
@text_buffer << @input.byteslice(@pos, span_end - @pos)
|
|
66
|
+
@pos = span_end
|
|
67
|
+
|
|
68
|
+
dispatch_interesting_byte
|
|
56
69
|
end
|
|
57
70
|
|
|
71
|
+
# Trailing text after the last interesting byte; appending the
|
|
72
|
+
# empty slice at end-of-input is a no-op.
|
|
73
|
+
@text_buffer << @input.byteslice(@pos, @length - @pos)
|
|
58
74
|
flush_text
|
|
59
75
|
end
|
|
60
76
|
|
|
61
77
|
private
|
|
62
78
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
@pos
|
|
79
|
+
# Precondition: the byte at +@pos+ matched INTERESTING.
|
|
80
|
+
def dispatch_interesting_byte
|
|
81
|
+
case @input.getbyte(@pos)
|
|
82
|
+
when APOSTROPHE
|
|
83
|
+
if consecutive_apostrophes_at(@pos) >= 2
|
|
84
|
+
parse_bold_italic
|
|
85
|
+
else
|
|
86
|
+
@text_buffer << "'"
|
|
87
|
+
@pos += 1
|
|
88
|
+
end
|
|
89
|
+
when BRACKET_OPEN
|
|
90
|
+
flush_text
|
|
91
|
+
@input.getbyte(@pos + 1) == BRACKET_OPEN ? parse_internal_link : parse_external_link
|
|
92
|
+
else # "<" — the only remaining INTERESTING byte
|
|
93
|
+
flush_text
|
|
94
|
+
parse_html_tag
|
|
95
|
+
end
|
|
66
96
|
end
|
|
67
97
|
|
|
68
|
-
# Precondition: caller has verified @
|
|
98
|
+
# Precondition: caller has verified at least two apostrophes at @pos.
|
|
69
99
|
def parse_bold_italic
|
|
70
|
-
start = @pos
|
|
71
100
|
count = [consecutive_apostrophes_at(@pos), 5].min
|
|
72
101
|
flush_text
|
|
73
102
|
@pos += count
|
|
74
|
-
parse_apostrophe_formatting(count
|
|
103
|
+
parse_apostrophe_formatting(count)
|
|
75
104
|
end
|
|
76
105
|
|
|
77
106
|
# Parse apostrophe-delimited formatting (bold, italic, or bold+italic).
|
|
107
|
+
# Entered with @pos just past the opening apostrophes.
|
|
78
108
|
#
|
|
79
109
|
# @param apostrophe_count [Integer] number of apostrophes (2, 3, or 5)
|
|
80
|
-
|
|
81
|
-
def parse_apostrophe_formatting(apostrophe_count, start)
|
|
110
|
+
def parse_apostrophe_formatting(apostrophe_count)
|
|
82
111
|
content = collect_until_apostrophes(apostrophe_count)
|
|
83
112
|
|
|
84
113
|
unless content
|
|
114
|
+
# @pos already sits just past the opening apostrophes
|
|
115
|
+
# (start + apostrophe_count): the caller advanced it, and
|
|
116
|
+
# collect_until_apostrophes leaves it untouched on failure.
|
|
85
117
|
@text_buffer << ("'" * apostrophe_count)
|
|
86
|
-
@pos = start + apostrophe_count
|
|
87
118
|
return
|
|
88
119
|
end
|
|
89
120
|
|
|
@@ -124,29 +155,36 @@ module Markbridge
|
|
|
124
155
|
).parse(content, parent:)
|
|
125
156
|
end
|
|
126
157
|
|
|
127
|
-
# Collect text until we find n consecutive apostrophes
|
|
128
|
-
#
|
|
158
|
+
# Collect text until we find n consecutive apostrophes, hopping
|
|
159
|
+
# from apostrophe run to apostrophe run instead of scanning per
|
|
160
|
+
# position. Returns the collected content string, or nil (the
|
|
161
|
+
# exhausted while loop) if no closing run exists.
|
|
129
162
|
#
|
|
130
163
|
# @param count [Integer] number of consecutive apostrophes to match
|
|
131
164
|
# @return [String, nil]
|
|
132
165
|
def collect_until_apostrophes(count)
|
|
133
166
|
start = @pos
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
167
|
+
probe = @pos
|
|
168
|
+
while (index = @input.byteindex("'", probe))
|
|
169
|
+
run = consecutive_apostrophes_at(index)
|
|
170
|
+
if run >= count
|
|
171
|
+
content = @input.byteslice(start, index - start)
|
|
172
|
+
@pos = index + count
|
|
138
173
|
return content
|
|
139
174
|
end
|
|
140
|
-
|
|
175
|
+
probe = index + run
|
|
141
176
|
end
|
|
142
177
|
end
|
|
143
178
|
|
|
144
179
|
# Count consecutive apostrophes starting at position.
|
|
180
|
+
# getbyte past end-of-input returns nil, which ends the run.
|
|
145
181
|
#
|
|
146
182
|
# @param pos [Integer]
|
|
147
183
|
# @return [Integer]
|
|
148
184
|
def consecutive_apostrophes_at(pos)
|
|
149
|
-
|
|
185
|
+
count = 0
|
|
186
|
+
count += 1 while @input.getbyte(pos + count) == APOSTROPHE
|
|
187
|
+
count
|
|
150
188
|
end
|
|
151
189
|
|
|
152
190
|
# Parse [[internal link]] or [[target|display text]].
|
|
@@ -154,14 +192,13 @@ module Markbridge
|
|
|
154
192
|
@pos += 2 # skip [[
|
|
155
193
|
start = @pos
|
|
156
194
|
|
|
157
|
-
|
|
158
|
-
close_pos = @input.index("]]", @pos)
|
|
195
|
+
close_pos = @input.byteindex("]]", @pos)
|
|
159
196
|
unless close_pos
|
|
160
197
|
@text_buffer << "[["
|
|
161
198
|
return
|
|
162
199
|
end
|
|
163
200
|
|
|
164
|
-
content = @input
|
|
201
|
+
content = @input.byteslice(start, close_pos - start)
|
|
165
202
|
@pos = close_pos + 2
|
|
166
203
|
|
|
167
204
|
target, display = content.split("|", 2)
|
|
@@ -178,14 +215,13 @@ module Markbridge
|
|
|
178
215
|
@pos += 1 # skip [
|
|
179
216
|
start = @pos
|
|
180
217
|
|
|
181
|
-
|
|
182
|
-
close_pos = @input.index("]", @pos)
|
|
218
|
+
close_pos = @input.byteindex("]", @pos)
|
|
183
219
|
unless close_pos
|
|
184
220
|
@text_buffer << "["
|
|
185
221
|
return
|
|
186
222
|
end
|
|
187
223
|
|
|
188
|
-
content = @input
|
|
224
|
+
content = @input.byteslice(start, close_pos - start)
|
|
189
225
|
@pos = close_pos + 1
|
|
190
226
|
|
|
191
227
|
# Split on first space: URL followed by optional display text
|
|
@@ -199,13 +235,13 @@ module Markbridge
|
|
|
199
235
|
end
|
|
200
236
|
|
|
201
237
|
def parse_html_tag
|
|
202
|
-
|
|
203
|
-
unless tag_match
|
|
238
|
+
unless @input.byteindex(HTML_TAG_AT_CURSOR, @pos)
|
|
204
239
|
@text_buffer << "<"
|
|
205
240
|
@pos += 1
|
|
206
241
|
return
|
|
207
242
|
end
|
|
208
243
|
|
|
244
|
+
tag_match = Regexp.last_match
|
|
209
245
|
full_match = tag_match[0]
|
|
210
246
|
closing = !tag_match[1].empty?
|
|
211
247
|
self_closing = !tag_match[3].empty?
|
|
@@ -238,7 +274,7 @@ module Markbridge
|
|
|
238
274
|
when :formatting
|
|
239
275
|
handle_paired_tag(tag_name, full_match, entry.element_class)
|
|
240
276
|
when :self_closing
|
|
241
|
-
@pos += full_match.
|
|
277
|
+
@pos += full_match.bytesize
|
|
242
278
|
@parent << entry.element_class.new
|
|
243
279
|
end
|
|
244
280
|
end
|
|
@@ -246,17 +282,17 @@ module Markbridge
|
|
|
246
282
|
# Advance position and buffer the match as literal text.
|
|
247
283
|
def advance_as_text(full_match)
|
|
248
284
|
@text_buffer << full_match
|
|
249
|
-
@pos += full_match.
|
|
285
|
+
@pos += full_match.bytesize
|
|
250
286
|
end
|
|
251
287
|
|
|
252
288
|
# Handle <nowiki>...</nowiki> - preserves content as literal text.
|
|
253
289
|
def handle_nowiki_tag(full_match)
|
|
254
|
-
@pos += full_match.
|
|
255
|
-
close_pos = @input.
|
|
290
|
+
@pos += full_match.bytesize
|
|
291
|
+
close_pos = @input.byteindex("</nowiki>", @pos)
|
|
256
292
|
|
|
257
293
|
if close_pos
|
|
258
|
-
@text_buffer << @input
|
|
259
|
-
@pos = close_pos + "</nowiki>".
|
|
294
|
+
@text_buffer << @input.byteslice(@pos, close_pos - @pos)
|
|
295
|
+
@pos = close_pos + "</nowiki>".bytesize
|
|
260
296
|
else
|
|
261
297
|
@text_buffer << full_match
|
|
262
298
|
end
|
|
@@ -265,15 +301,15 @@ module Markbridge
|
|
|
265
301
|
# Handle paired raw tags like <code>...</code> and <pre>...</pre>.
|
|
266
302
|
# Content inside is not parsed for wiki markup.
|
|
267
303
|
def handle_paired_raw_tag(tag_name, full_match, element_class)
|
|
268
|
-
@pos += full_match.
|
|
304
|
+
@pos += full_match.bytesize
|
|
269
305
|
close_tag = "</#{tag_name}>"
|
|
270
|
-
close_pos = @input.
|
|
306
|
+
close_pos = @input.byteindex(close_tag, @pos)
|
|
271
307
|
|
|
272
308
|
if close_pos
|
|
273
309
|
element = element_class.new
|
|
274
|
-
element << AST::Text.new(@input
|
|
310
|
+
element << AST::Text.new(@input.byteslice(@pos, close_pos - @pos))
|
|
275
311
|
@parent << element
|
|
276
|
-
@pos = close_pos + close_tag.
|
|
312
|
+
@pos = close_pos + close_tag.bytesize
|
|
277
313
|
else
|
|
278
314
|
@text_buffer << full_match
|
|
279
315
|
end
|
|
@@ -282,26 +318,28 @@ module Markbridge
|
|
|
282
318
|
# Handle paired formatting tags like <s>, <u>, <sup>, <sub>.
|
|
283
319
|
# Content inside IS parsed for wiki markup.
|
|
284
320
|
def handle_paired_tag(tag_name, full_match, element_class)
|
|
285
|
-
@pos += full_match.
|
|
321
|
+
@pos += full_match.bytesize
|
|
286
322
|
close_tag = "</#{tag_name}>"
|
|
287
|
-
close_pos = @input.
|
|
323
|
+
close_pos = @input.byteindex(close_tag, @pos)
|
|
288
324
|
|
|
289
325
|
if close_pos
|
|
290
326
|
element = element_class.new
|
|
291
|
-
parse_inner_content(@input
|
|
327
|
+
parse_inner_content(@input.byteslice(@pos, close_pos - @pos), parent: element)
|
|
292
328
|
@parent << element
|
|
293
|
-
@pos = close_pos + close_tag.
|
|
329
|
+
@pos = close_pos + close_tag.bytesize
|
|
294
330
|
else
|
|
295
331
|
@text_buffer << full_match
|
|
296
332
|
end
|
|
297
333
|
end
|
|
298
334
|
|
|
299
335
|
# Flush accumulated text buffer to the parent as a Text node.
|
|
336
|
+
# AST::Text copies the (mutable) buffer, so clearing it afterwards
|
|
337
|
+
# reuses the allocated capacity for the next span.
|
|
300
338
|
def flush_text
|
|
301
339
|
return if @text_buffer.empty?
|
|
302
340
|
|
|
303
341
|
@parent << AST::Text.new(@text_buffer)
|
|
304
|
-
@text_buffer
|
|
342
|
+
@text_buffer.clear
|
|
305
343
|
end
|
|
306
344
|
end
|
|
307
345
|
end
|
|
@@ -44,7 +44,11 @@ module Markbridge
|
|
|
44
44
|
# @param tag_name [String]
|
|
45
45
|
# @return [Entry, nil]
|
|
46
46
|
def [](tag_name)
|
|
47
|
-
|
|
47
|
+
# Keys are always downcased and the inline parser downcases tag
|
|
48
|
+
# names before lookup, so the fetch hits directly on the hot
|
|
49
|
+
# path; the block normalizes only mixed-case lookups instead of
|
|
50
|
+
# allocating a downcased copy per probe.
|
|
51
|
+
@entries.fetch(tag_name) { @entries[tag_name.downcase] }
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
# Check if a tag name is registered.
|
|
@@ -88,6 +92,25 @@ module Markbridge
|
|
|
88
92
|
registry
|
|
89
93
|
end
|
|
90
94
|
|
|
95
|
+
# Shared, deep-frozen default registry for the no-customization
|
|
96
|
+
# fast path. Built once per process; {InlineParser} falls back to
|
|
97
|
+
# it when no +handlers:+ are given, skipping the full registry
|
|
98
|
+
# construction on every parse. Entries are immutable Data objects,
|
|
99
|
+
# so sharing is safe across parsers and threads.
|
|
100
|
+
#
|
|
101
|
+
# @return [InlineTagRegistry] the same frozen instance on every call
|
|
102
|
+
def self.shared_default
|
|
103
|
+
@shared_default ||= default.freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Freeze the registry together with its internal Hash so that
|
|
107
|
+
# registration on a shared instance fails loudly instead of
|
|
108
|
+
# silently mutating state visible to every parser.
|
|
109
|
+
def freeze
|
|
110
|
+
@entries.freeze
|
|
111
|
+
super
|
|
112
|
+
end
|
|
113
|
+
|
|
91
114
|
private
|
|
92
115
|
|
|
93
116
|
VALID_TYPES = %i[raw formatting self_closing].freeze
|
|
@@ -56,12 +56,15 @@ module Markbridge
|
|
|
56
56
|
|
|
57
57
|
private
|
|
58
58
|
|
|
59
|
+
LINE_ENDING_RE = /\r\n?|[\u2028\u2029]+/
|
|
60
|
+
private_constant :LINE_ENDING_RE
|
|
61
|
+
|
|
59
62
|
# Normalize line endings (CR, CRLF, and Unicode separators).
|
|
60
63
|
#
|
|
61
64
|
# @param input [String]
|
|
62
|
-
# @return [String]
|
|
65
|
+
# @return [String] the input itself when already normalized (LF-only)
|
|
63
66
|
def normalize_line_endings(input)
|
|
64
|
-
input.gsub(
|
|
67
|
+
input.match?(LINE_ENDING_RE) ? input.gsub(LINE_ENDING_RE, "\n") : input
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
# Process all lines of input.
|
|
@@ -32,6 +32,25 @@ module Markbridge
|
|
|
32
32
|
default.tap { |registry| yield registry if block_given? }
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Shared, deep-frozen default registry for the no-customization
|
|
36
|
+
# fast path. Built once per process; {Parser} falls back to it
|
|
37
|
+
# when no +handlers:+ are given, skipping the full registry
|
|
38
|
+
# construction on every parse. Handlers are stateless after
|
|
39
|
+
# construction, so sharing is safe across parsers and threads.
|
|
40
|
+
#
|
|
41
|
+
# @return [HandlerRegistry] the same frozen instance on every call
|
|
42
|
+
def self.shared_default
|
|
43
|
+
@shared_default ||= default.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Freeze the registry together with its internal Hash so that
|
|
47
|
+
# registration on a shared instance fails loudly instead of
|
|
48
|
+
# silently mutating state visible to every parser.
|
|
49
|
+
def freeze
|
|
50
|
+
@mappings.freeze
|
|
51
|
+
super
|
|
52
|
+
end
|
|
53
|
+
|
|
35
54
|
def initialize
|
|
36
55
|
@mappings = {}
|
|
37
56
|
end
|
|
@@ -47,7 +66,10 @@ module Markbridge
|
|
|
47
66
|
# @param element_name [String]
|
|
48
67
|
# @return [#process, nil]
|
|
49
68
|
def [](element_name)
|
|
50
|
-
|
|
69
|
+
# Keys are always upcased and s9e/TextFormatter stores its tag
|
|
70
|
+
# names in uppercase, so the fetch hits directly on the hot
|
|
71
|
+
# path; the block normalizes only unusual lookups.
|
|
72
|
+
@mappings.fetch(element_name) { @mappings[element_name.upcase] }
|
|
51
73
|
end
|
|
52
74
|
|
|
53
75
|
# Replace the handler bound to one or more element names by
|
|
@@ -5,6 +5,20 @@ module Markbridge
|
|
|
5
5
|
module TextFormatter
|
|
6
6
|
module Handlers
|
|
7
7
|
# Handler for QUOTE elements in s9e/TextFormatter XML
|
|
8
|
+
#
|
|
9
|
+
# Maps phpBB-style attribution attributes: +post_id+ and
|
|
10
|
+
# +user_id+ are database ids and land in {AST::Quote#post_id} /
|
|
11
|
+
# {AST::Quote#user_id} — deliberately not in
|
|
12
|
+
# {AST::Quote#post_number}, which carries Discourse post-number
|
|
13
|
+
# semantics that s9e exports don't provide.
|
|
14
|
+
#
|
|
15
|
+
# A bare +topic+ attribute feeds {AST::Quote#topic_id} (a topic
|
|
16
|
+
# reference is an id in every dialect we know). A bare +post+
|
|
17
|
+
# attribute is deliberately NOT mapped: without knowing the
|
|
18
|
+
# exporting platform it is undecidable whether it holds a post
|
|
19
|
+
# id or a post number, and guessing wrong produces attributions
|
|
20
|
+
# that link the wrong post. Register a custom handler when your
|
|
21
|
+
# export carries one and you know its semantics.
|
|
8
22
|
class QuoteHandler < BaseHandler
|
|
9
23
|
def initialize
|
|
10
24
|
@element_class = AST::Quote
|
|
@@ -15,9 +29,10 @@ module Markbridge
|
|
|
15
29
|
node =
|
|
16
30
|
AST::Quote.new(
|
|
17
31
|
author: attrs[:author],
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
post_id: integer_or_nil(attrs[:post_id]),
|
|
33
|
+
topic_id: integer_or_nil(attrs[:topic_id] || attrs[:topic]),
|
|
20
34
|
username: attrs[:username],
|
|
35
|
+
user_id: integer_or_nil(attrs[:user_id]),
|
|
21
36
|
)
|
|
22
37
|
parent << node
|
|
23
38
|
|
|
@@ -26,6 +41,20 @@ module Markbridge
|
|
|
26
41
|
end
|
|
27
42
|
|
|
28
43
|
attr_reader :element_class
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Coerce an attribute value to Integer; nil and non-numeric
|
|
48
|
+
# values (which would make a useless attribution anyway)
|
|
49
|
+
# become nil. Base 10 is explicit so leading zeros ("099")
|
|
50
|
+
# don't trip Integer()'s octal mode and prefix forms ("0x1A")
|
|
51
|
+
# don't smuggle in surprise values. The result is an unbounded
|
|
52
|
+
# Ruby Integer — a runaway digit string parses to a bignum,
|
|
53
|
+
# so consumers binding these into fixed-width storage (int64
|
|
54
|
+
# columns) need their own bounds check.
|
|
55
|
+
def integer_or_nil(value)
|
|
56
|
+
Integer(value, 10, exception: false)
|
|
57
|
+
end
|
|
29
58
|
end
|
|
30
59
|
end
|
|
31
60
|
end
|
|
@@ -33,7 +33,7 @@ module Markbridge
|
|
|
33
33
|
if block_given?
|
|
34
34
|
HandlerRegistry.build_from_default(&block)
|
|
35
35
|
else
|
|
36
|
-
handlers || HandlerRegistry.
|
|
36
|
+
handlers || HandlerRegistry.shared_default
|
|
37
37
|
end
|
|
38
38
|
@unknown_tags = Hash.new(0)
|
|
39
39
|
end
|
|
@@ -75,6 +75,13 @@ module Markbridge
|
|
|
75
75
|
AST::Document.new << AST::Text.new(input)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# <s>/<e> hold the original BBCode markup for unparsing; <t>/<r>
|
|
79
|
+
# are the plain-text/rich-text roots. Frozen constants — the
|
|
80
|
+
# membership checks run once per element node.
|
|
81
|
+
MARKUP_PRESERVATION_TAGS = %w[s e].freeze
|
|
82
|
+
ROOT_TAGS = %w[t r].freeze
|
|
83
|
+
private_constant :MARKUP_PRESERVATION_TAGS, :ROOT_TAGS
|
|
84
|
+
|
|
78
85
|
# Process children of an XML element (public for handler access)
|
|
79
86
|
# @param element [Nokogiri::XML::Element]
|
|
80
87
|
# @param ast_parent [AST::Element]
|
|
@@ -102,10 +109,10 @@ module Markbridge
|
|
|
102
109
|
tag_name = element.name
|
|
103
110
|
|
|
104
111
|
# Skip markup preservation elements and their content (used for unparsing)
|
|
105
|
-
return if
|
|
112
|
+
return if MARKUP_PRESERVATION_TAGS.include?(tag_name)
|
|
106
113
|
|
|
107
114
|
# Handle root nodes
|
|
108
|
-
return process_children(element, ast_parent) if
|
|
115
|
+
return process_children(element, ast_parent) if ROOT_TAGS.include?(tag_name)
|
|
109
116
|
|
|
110
117
|
# Handle line breaks
|
|
111
118
|
if tag_name == "br"
|
|
@@ -178,6 +178,12 @@ module Markbridge
|
|
|
178
178
|
end
|
|
179
179
|
|
|
180
180
|
def escape_text(text)
|
|
181
|
+
# Single-line fast path (the common case for inline text nodes):
|
|
182
|
+
# skip the split and its Array + line-String allocations. A lone
|
|
183
|
+
# `\r` without `\n` stays on the line either way — `/\r?\n/`
|
|
184
|
+
# needs the `\n` — so `include?("\n")` alone decides correctly.
|
|
185
|
+
return escape_line(text, false) unless text.include?("\n")
|
|
186
|
+
|
|
181
187
|
# On CRLF input, consume `\r` as part of the line terminator instead
|
|
182
188
|
# of leaving it on the line. A trailing `\r` breaks line-end anchored
|
|
183
189
|
# regexes (e.g. SETEXT_UNDERLINE_*) and the `ws_end >= line_length`
|
|
@@ -186,7 +192,6 @@ module Markbridge
|
|
|
186
192
|
# LF-only fast path on a string split (regex split is ~20% slower
|
|
187
193
|
# on the indented-code hot path).
|
|
188
194
|
lines = text.include?("\r") ? text.split(/\r?\n/, -1) : text.split("\n", -1)
|
|
189
|
-
return escape_line(lines[0], false) if lines.size == 1
|
|
190
195
|
|
|
191
196
|
# Pre-allocate result buffer
|
|
192
197
|
bytesize = text.bytesize
|
|
@@ -231,6 +236,12 @@ module Markbridge
|
|
|
231
236
|
result = String.new(encoding: line.encoding)
|
|
232
237
|
result << line[0, indent_len] << escaped
|
|
233
238
|
result
|
|
239
|
+
elsif escaped.equal?(line)
|
|
240
|
+
# Nothing needed escaping, so `escaped` IS the input line: its
|
|
241
|
+
# encoding is already right, and since escape_text's
|
|
242
|
+
# single-line fast path that input can be the caller's own
|
|
243
|
+
# (possibly frozen) string — force_encoding would raise.
|
|
244
|
+
line
|
|
234
245
|
else
|
|
235
246
|
escaped.force_encoding(line.encoding)
|
|
236
247
|
end
|