markbridge 0.2.1 → 0.3.1

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/markbridge/ast/element.rb +28 -0
  3. data/lib/markbridge/ast/quote.rb +45 -15
  4. data/lib/markbridge/ast/text.rb +7 -4
  5. data/lib/markbridge/ast/url.rb +15 -0
  6. data/lib/markbridge/normalizer/report.rb +41 -0
  7. data/lib/markbridge/normalizer/rule_set.rb +89 -0
  8. data/lib/markbridge/normalizer/text_projection.rb +37 -0
  9. data/lib/markbridge/normalizer/walker.rb +250 -0
  10. data/lib/markbridge/normalizer.rb +180 -0
  11. data/lib/markbridge/parsers/bbcode/handler_registry.rb +27 -1
  12. data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +32 -9
  13. data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +8 -4
  14. data/lib/markbridge/parsers/bbcode/parser.rb +6 -3
  15. data/lib/markbridge/parsers/bbcode/scanner.rb +133 -53
  16. data/lib/markbridge/parsers/html/handler_registry.rb +27 -1
  17. data/lib/markbridge/parsers/html/parser.rb +54 -13
  18. data/lib/markbridge/parsers/media_wiki/inline_parser.rb +95 -57
  19. data/lib/markbridge/parsers/media_wiki/inline_tag_registry.rb +24 -1
  20. data/lib/markbridge/parsers/media_wiki/parser.rb +5 -2
  21. data/lib/markbridge/parsers/text_formatter/handler_registry.rb +23 -1
  22. data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +31 -2
  23. data/lib/markbridge/parsers/text_formatter/parser.rb +10 -3
  24. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +12 -1
  25. data/lib/markbridge/renderers/discourse/render_context.rb +74 -11
  26. data/lib/markbridge/renderers/discourse/renderer.rb +63 -13
  27. data/lib/markbridge/renderers/discourse/rendering_interface.rb +27 -2
  28. data/lib/markbridge/renderers/discourse/tag_library.rb +20 -0
  29. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +2 -4
  30. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +2 -4
  31. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +8 -6
  32. data/lib/markbridge/renderers/discourse/tags/url_tag.rb +36 -2
  33. data/lib/markbridge/version.rb +1 -1
  34. data/lib/markbridge.rb +61 -11
  35. metadata +6 -1
@@ -112,7 +112,10 @@ module Markbridge
112
112
  # @param tag_name [String]
113
113
  # @return [BaseHandler, nil]
114
114
  def [](tag_name)
115
- @handlers[tag_name.to_s.downcase]
115
+ # Keys are always downcased strings and Nokogiri's HTML parser
116
+ # yields downcased element names, so the fetch hits directly on
117
+ # the hot path; the block normalizes only unusual lookups.
118
+ @handlers.fetch(tag_name) { @handlers[tag_name.to_s.downcase] }
116
119
  end
117
120
 
118
121
  # Create the default handler registry with common HTML tags
@@ -149,6 +152,29 @@ module Markbridge
149
152
  yield(registry) if block_given?
150
153
  registry
151
154
  end
155
+
156
+ # Shared, deep-frozen default registry for the no-customization
157
+ # fast path. Built once per process; {Parser} falls back to it
158
+ # when no +handlers:+ are given, skipping the full registry
159
+ # construction on every parse. Handlers are stateless after
160
+ # construction, so sharing is safe across parsers and threads.
161
+ # Callers who want to mutate the tag sets must build their own
162
+ # {.default}.
163
+ #
164
+ # @return [HandlerRegistry] the same frozen instance on every call
165
+ def self.shared_default
166
+ @shared_default ||= default.freeze
167
+ end
168
+
169
+ # Freeze the registry together with its internal collections so
170
+ # that mutation of a shared instance fails loudly instead of
171
+ # silently changing state visible to every parser.
172
+ def freeze
173
+ @handlers.freeze
174
+ @block_level_tags.freeze
175
+ @whitespace_preserving_tags.freeze
176
+ super
177
+ end
152
178
  end
153
179
  end
154
180
  end
@@ -22,7 +22,7 @@ module Markbridge
22
22
  if block_given?
23
23
  HandlerRegistry.build_from_default(&block)
24
24
  else
25
- handlers || HandlerRegistry.default
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
- if preserves_whitespace?(node)
100
- parent << AST::Text.new(node.text)
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 = node.text.gsub(WHITESPACE_RUN, " ")
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 @handlers.whitespace_preserving_tags.include?(tag_name)
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
- # Whether `node` is inside a tag that preserves source whitespace.
152
- # @param node [Nokogiri::XML::Node]
153
- # @return [Boolean]
154
- def preserves_whitespace?(node)
155
- node.ancestors.any? do |ancestor|
156
- @handlers.whitespace_preserving_tags.include?(ancestor.name)
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.default
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.length
58
+ @length = text.bytesize
38
59
  @parent = parent
39
60
  @text_buffer = +""
40
61
 
41
- while @pos < @length
42
- char = @input[@pos]
43
-
44
- case char
45
- when "'"
46
- consecutive_apostrophes_at(@pos) >= 2 ? parse_bold_italic : append_literal(char)
47
- when "["
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
- def append_literal(char)
64
- @text_buffer << char
65
- @pos += 1
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 @input[@pos..@pos+1] is "''".
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, start)
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
- # @param start [Integer] position before the opening apostrophes
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
- # Returns the collected content string or nil if not found.
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
- while @pos < @length
135
- if consecutive_apostrophes_at(@pos) >= count
136
- content = @input[start...@pos]
137
- @pos += count
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
- @pos += 1
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
- @input[pos..].each_char.take_while { |c| c == "'" }.length
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
- # Find closing ]]
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[start...close_pos]
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
- # Find closing ]
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[start...close_pos]
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
- tag_match = @input[@pos..].match(%r{\A<(/?)([a-z]+)(?: [^>]*)?\s*(/?)>}i)
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.length
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.length
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.length
255
- close_pos = @input.index("</nowiki>", @pos)
290
+ @pos += full_match.bytesize
291
+ close_pos = @input.byteindex("</nowiki>", @pos)
256
292
 
257
293
  if close_pos
258
- @text_buffer << @input[@pos...close_pos]
259
- @pos = close_pos + "</nowiki>".length
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.length
304
+ @pos += full_match.bytesize
269
305
  close_tag = "</#{tag_name}>"
270
- close_pos = @input.index(close_tag, @pos)
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[@pos...close_pos])
310
+ element << AST::Text.new(@input.byteslice(@pos, close_pos - @pos))
275
311
  @parent << element
276
- @pos = close_pos + close_tag.length
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.length
321
+ @pos += full_match.bytesize
286
322
  close_tag = "</#{tag_name}>"
287
- close_pos = @input.index(close_tag, @pos)
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[@pos...close_pos], parent: element)
327
+ parse_inner_content(@input.byteslice(@pos, close_pos - @pos), parent: element)
292
328
  @parent << element
293
- @pos = close_pos + close_tag.length
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
- @entries[tag_name.downcase]
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(/\r\n?|[\u2028\u2029]+/, "\n")
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
- @mappings[element_name.upcase]
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
- post: attrs[:post_id] || attrs[:post],
19
- topic: attrs[:topic_id] || attrs[:topic],
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.default
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 %w[s e].include?(tag_name)
112
+ return if MARKUP_PRESERVATION_TAGS.include?(tag_name)
106
113
 
107
114
  # Handle root nodes
108
- return process_children(element, ast_parent) if %w[t r].include?(tag_name)
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"