markbridge 0.4.2 → 0.5.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/lib/markbridge/ast/image.rb +9 -1
  3. data/lib/markbridge/ast/url.rb +29 -4
  4. data/lib/markbridge/errors.rb +9 -0
  5. data/lib/markbridge/parsers/bbcode/handler_registry.rb +6 -1
  6. data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +2 -1
  7. data/lib/markbridge/parsers/bbcode/handlers/url_handler.rb +26 -2
  8. data/lib/markbridge/parsers/html/handlers/image_handler.rb +4 -1
  9. data/lib/markbridge/parsers/html/parser.rb +57 -19
  10. data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +3 -2
  11. data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +4 -2
  12. data/lib/markbridge/parsers/text_formatter/parser.rb +107 -14
  13. data/lib/markbridge/renderers/discourse/html_block.rb +50 -7
  14. data/lib/markbridge/renderers/discourse/inline/joins.rb +382 -0
  15. data/lib/markbridge/renderers/discourse/inline/serializer.rb +738 -0
  16. data/lib/markbridge/renderers/discourse/inline/tokens.rb +182 -0
  17. data/lib/markbridge/renderers/discourse/link_destination.rb +43 -0
  18. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +105 -45
  19. data/lib/markbridge/renderers/discourse/paragraphs.rb +88 -0
  20. data/lib/markbridge/renderers/discourse/postprocessor.rb +183 -7
  21. data/lib/markbridge/renderers/discourse/render_context.rb +38 -12
  22. data/lib/markbridge/renderers/discourse/renderer.rb +336 -92
  23. data/lib/markbridge/renderers/discourse/rendering_interface.rb +123 -44
  24. data/lib/markbridge/renderers/discourse/tag.rb +53 -9
  25. data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +4 -6
  26. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +48 -10
  27. data/lib/markbridge/renderers/discourse/tags/color_tag.rb +73 -13
  28. data/lib/markbridge/renderers/discourse/tags/details_tag.rb +62 -15
  29. data/lib/markbridge/renderers/discourse/tags/email_tag.rb +8 -9
  30. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +31 -9
  31. data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +20 -1
  32. data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +12 -1
  33. data/lib/markbridge/renderers/discourse/tags/image_tag.rb +13 -27
  34. data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +4 -6
  35. data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +9 -2
  36. data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +75 -9
  37. data/lib/markbridge/renderers/discourse/tags/list_tag.rb +29 -7
  38. data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +12 -0
  39. data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +13 -6
  40. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +52 -9
  41. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +26 -1
  42. data/lib/markbridge/renderers/discourse/tags/size_tag.rb +78 -13
  43. data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +72 -14
  44. data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +5 -6
  45. data/lib/markbridge/renderers/discourse/tags/subscript_tag.rb +11 -5
  46. data/lib/markbridge/renderers/discourse/tags/superscript_tag.rb +11 -5
  47. data/lib/markbridge/renderers/discourse/tags/table_tag.rb +48 -28
  48. data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +3 -10
  49. data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +90 -43
  50. data/lib/markbridge/renderers/discourse/tags/url_tag.rb +43 -26
  51. data/lib/markbridge/renderers/discourse.rb +6 -0
  52. data/lib/markbridge/rspec.rb +4 -2
  53. data/lib/markbridge/version.rb +1 -1
  54. data/lib/markbridge.rb +90 -1
  55. metadata +7 -1
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Renderers
5
+ module Discourse
6
+ module Inline
7
+ # The tokens a tag gives the {Serializer} instead of finished
8
+ # Markdown. A token is a plain Array with its kind first, so
9
+ # building one costs a single allocation, or none for the tokens
10
+ # that carry no value; the constructors here are the only place
11
+ # that knows the layout. Finished Markdown is a token too: a plain
12
+ # String among the tokens is written as it is, and the serializer
13
+ # joins it with its neighbors by its first and last character.
14
+ module Tokens
15
+ # Text the serializer escapes.
16
+ TEXT = :text
17
+ # Finished Markdown from a tag, written as it is. Such a token is
18
+ # the String itself, see {.raw}.
19
+ RAW = :raw
20
+ # The start of a delimiter pair, see {DELIMITERS}.
21
+ OPEN = :open
22
+ # The end of a delimiter pair.
23
+ CLOSE = :close
24
+ # A code span. The serializer picks the backtick run.
25
+ CODE = :code
26
+ # The start of a link with the destination.
27
+ LINK_OPEN = :link_open
28
+ # The end of a link.
29
+ LINK_CLOSE = :link_close
30
+ # A link whose text is its href. The serializer decides from the
31
+ # neighbors whether it can stay bare.
32
+ BARE_URL = :bare_url
33
+ # An image.
34
+ IMAGE = :image
35
+ # A hard line break.
36
+ HARD_BREAK = :hard_break
37
+ # Raw HTML that stays inline HTML in Markdown.
38
+ HTML = :html
39
+ # Text that a later step replaces, with what will most likely
40
+ # take its place, see {.placeholder}.
41
+ PLACEHOLDER = :placeholder
42
+
43
+ # How a text token is escaped: as Markdown text, as the label of
44
+ # a link (also `]`), or not at all (inside a code span).
45
+ ESCAPE = :escape
46
+ LABEL = :label
47
+ VERBATIM = :verbatim
48
+
49
+ # The delimiter pairs {.open} and {.close} name.
50
+ DELIMITERS = %i[strong em s u].freeze
51
+
52
+ # @param string [String]
53
+ # @param mode [Symbol] {ESCAPE}, {LABEL} or {VERBATIM}
54
+ # @return [Array]
55
+ def self.text(string, mode = ESCAPE)
56
+ [TEXT, string, mode]
57
+ end
58
+
59
+ # @param markdown [String]
60
+ # @return [String] the Markdown itself
61
+ def self.raw(markdown)
62
+ markdown
63
+ end
64
+
65
+ OPEN_TOKENS = DELIMITERS.to_h { |delimiter| [delimiter, [OPEN, delimiter].freeze] }.freeze
66
+ CLOSE_TOKENS =
67
+ DELIMITERS.to_h { |delimiter| [delimiter, [CLOSE, delimiter].freeze] }.freeze
68
+ private_constant :OPEN_TOKENS, :CLOSE_TOKENS
69
+
70
+ # @param delimiter [Symbol] one of {DELIMITERS}
71
+ # @return [Array] the same frozen token on every call
72
+ def self.open(delimiter)
73
+ OPEN_TOKENS.fetch(delimiter)
74
+ end
75
+
76
+ # @param delimiter [Symbol] one of {DELIMITERS}
77
+ # @return [Array] the same frozen token on every call
78
+ def self.close(delimiter)
79
+ CLOSE_TOKENS.fetch(delimiter)
80
+ end
81
+
82
+ # @param content [String] the code, not escaped
83
+ # @return [Array]
84
+ def self.code(content)
85
+ [CODE, content]
86
+ end
87
+
88
+ # @param destination [String] the href, not escaped
89
+ # @return [Array]
90
+ def self.link_open(destination)
91
+ [LINK_OPEN, destination]
92
+ end
93
+
94
+ LINK_CLOSE_TOKEN = [LINK_CLOSE].freeze
95
+ private_constant :LINK_CLOSE_TOKEN
96
+
97
+ # @return [Array]
98
+ def self.link_close
99
+ LINK_CLOSE_TOKEN
100
+ end
101
+
102
+ # @param href [String]
103
+ # @return [Array]
104
+ def self.bare_url(href)
105
+ [BARE_URL, href]
106
+ end
107
+
108
+ # @param src [String]
109
+ # @param alt [String, nil]
110
+ # @param width [Integer, nil]
111
+ # @param height [Integer, nil]
112
+ # @return [Array]
113
+ def self.image(src:, alt: nil, width: nil, height: nil)
114
+ [IMAGE, src, alt, width, height]
115
+ end
116
+
117
+ HARD_BREAK_TOKEN = [HARD_BREAK].freeze
118
+ private_constant :HARD_BREAK_TOKEN
119
+
120
+ # @return [Array]
121
+ def self.hard_break
122
+ HARD_BREAK_TOKEN
123
+ end
124
+
125
+ # @param string [String] HTML, already escaped
126
+ # @return [Array]
127
+ def self.html(string)
128
+ [HTML, string]
129
+ end
130
+
131
+ # Text for a later step to replace, like `[link|17]` that an
132
+ # importer swaps for the real URL. The serializer writes +text+
133
+ # as it is, but joins it with its neighbors as if +like+ stood
134
+ # there: whether a marker next to it needs `<!---->`, whether a
135
+ # bare URL in front of it can stay bare, whether a `!` in front
136
+ # of it needs a backslash. In an HTML block +text+ is written as
137
+ # it is. See "Placeholders" in the Extending guide.
138
+ #
139
+ # @param text [String] the placeholder, written as it is
140
+ # @param like [String] Markdown that will most likely replace
141
+ # it, such as `https://example.com/t/17` or `@alice`; its first
142
+ # and its last character decide the joins
143
+ # @return [Array]
144
+ # @raise [ArgumentError] when +like+ is no String or is empty
145
+ # @example
146
+ # Tokens.placeholder("[link|17]", like: "https://example.com/t/17")
147
+ def self.placeholder(text, like:)
148
+ unless like.instance_of?(String) && !like.empty?
149
+ raise ArgumentError, "like: must be a non-empty String, got #{like.inspect}"
150
+ end
151
+
152
+ [PLACEHOLDER, text, like]
153
+ end
154
+
155
+ # Whether the tokens put something on the page: text or Markdown
156
+ # with something other than whitespace in it, code, a link, a
157
+ # bare URL, an image or a placeholder whose +like+ is visible.
158
+ # Delimiters and hard breaks alone do not.
159
+ #
160
+ # @param tokens [Array<Array>]
161
+ # @return [Boolean]
162
+ def self.visible?(tokens)
163
+ tokens.any? do |token|
164
+ next Joins.visible?(token) if token.instance_of?(String)
165
+
166
+ case token.first
167
+ when TEXT, HTML
168
+ Joins.visible?(token.fetch(1))
169
+ when PLACEHOLDER
170
+ Joins.visible?(token.fetch(2))
171
+ when CODE
172
+ !token.fetch(1).empty?
173
+ when LINK_OPEN, BARE_URL, IMAGE
174
+ true
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Renderers
5
+ module Discourse
6
+ # Writes a URL as the destination of a Markdown link or image.
7
+ module LinkDestination
8
+ SPECIAL = /[()\\]/
9
+ WHITESPACE = /\s/
10
+ ANGLE_BRACKETS = /[<>]/
11
+ private_constant :SPECIAL, :WHITESPACE, :ANGLE_BRACKETS
12
+
13
+ # An unbalanced parenthesis ends a CommonMark destination early, so
14
+ # every parenthesis gets a backslash; balanced ones would be fine,
15
+ # but the check is not worth it. A backslash is doubled, or it
16
+ # would escape the parenthesis after it, also the one that closes
17
+ # the destination. A destination with whitespace is wrapped in
18
+ # <>, relevant for relative targets like MediaWiki page names
19
+ # ("Main Page") or image files with a space in the name; a `<` or
20
+ # `>` inside the brackets gets a backslash, or it ends them.
21
+ #
22
+ # The match? in front of the gsub saves the copy that gsub makes
23
+ # also without a match: links are frequent, these characters are
24
+ # rare.
25
+ # @param destination [String, nil]
26
+ # @return [String, nil] nil for nil
27
+ def self.escape(destination)
28
+ return nil if destination.nil?
29
+
30
+ escaped =
31
+ if destination.match?(SPECIAL)
32
+ destination.gsub(SPECIAL) { |char| "\\#{char}" }
33
+ else
34
+ destination
35
+ end
36
+ return escaped unless escaped.match?(WHITESPACE)
37
+
38
+ "<#{escaped.gsub(ANGLE_BRACKETS) { |char| "\\#{char}" }}>"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -17,6 +17,8 @@ module Markbridge
17
17
  # - Fast path returns original string for plain text (no allocations)
18
18
  # - Pre-allocated result buffers with estimated capacity
19
19
  # - Byte-level processing for inline escaping (YJIT-friendly tight loops)
20
+ # - A byte table decides which bytes need the inline dispatch, so an
21
+ # ordinary byte is copied without walking the dispatch arms
20
22
  # - Simplified escaping rules: [ breaks links, so ] doesn't need escaping
21
23
  #
22
24
  # @example Basic escaping
@@ -67,9 +69,10 @@ module Markbridge
67
69
  # Block-level patterns
68
70
  ATX_HEADING = /\A\#{1,6}(?=[ \t]|$)/
69
71
  BLOCK_QUOTE = /\A>/
70
- # List markers followed by space, tab, or end of line
72
+ # List markers followed by space, tab, or end of line. A marker
73
+ # alone on a line is an empty list item.
71
74
  BULLET_LIST = /\A[-+*](?=[ \t]|$)/
72
- ORDERED_LIST = /\A(\d+)([.)])(?=[ \t])/
75
+ ORDERED_LIST = /\A(\d+)([.)])(?=[ \t]|$)/
73
76
  THEMATIC_BREAK_DASH = /\A(?:-[ \t]*){3,}$/
74
77
  THEMATIC_BREAK_STAR = /\A(?:\*[ \t]*){3,}$/
75
78
  THEMATIC_BREAK_UNDERSCORE = /\A(?:_[ \t]*){3,}$/
@@ -88,8 +91,12 @@ module Markbridge
88
91
  # HTML tag pattern (we escape these, but NOT autolinks)
89
92
  # Handles quoted attributes which can contain > characters
90
93
  # Attribute patterns: name="value" | name='value' | name=value | name
91
- HTML_ATTR = /(?:\s+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)/
92
- HTML_TAG = %r{\A</?[a-zA-Z][a-zA-Z0-9-]*#{HTML_ATTR}*\s*/?>}
94
+ # Whitespace as markdown-it's JavaScript regexes read it (\s): the
95
+ # no-break space and the other Unicode spaces count too, so a tag
96
+ # name followed by a no-break space starts an HTML block or tag.
97
+ HTML_ATTR =
98
+ /(?:[[:space:]]+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:[[:space:]]*=[[:space:]]*(?:"[^"]*"|'[^']*'|[^[:space:]"'=<>`]+))?)/
99
+ HTML_TAG = %r{\A</?[a-zA-Z][a-zA-Z0-9-]*#{HTML_ATTR}*[[:space:]]*/?>}
93
100
 
94
101
  # Autolink pattern - we pass these through entirely unchanged
95
102
  # Matches <http://...>, <https://...>, <mailto:...>, and email addresses
@@ -102,7 +109,7 @@ module Markbridge
102
109
  # - Incomplete/multi-line HTML tags: <div followed by attributes on next line
103
110
  # - Custom elements: <my-component>, <responsive-image>
104
111
  # The (?:[\s/]|$) ensures we don't match comparisons like "a < b"
105
- HTML_TAG_START = %r{\A<(?:[?!]|/?\s*[a-zA-Z][a-zA-Z0-9-]*(?:[\s/]|$))}
112
+ HTML_TAG_START = %r{\A<(?:[?!]|/?[[:space:]]*[a-zA-Z][a-zA-Z0-9-]*(?:[[:space:]/]|$))}
106
113
 
107
114
  # Byte constants for inline processing
108
115
  BACKSLASH = 92 # \
@@ -125,6 +132,11 @@ module Markbridge
125
132
  DIGIT_0 = 48
126
133
  DIGIT_9 = 57
127
134
 
135
+ # `DIGIT_0..DIGIT_9` written out in the `when` arm is not a
136
+ # literal range, so Ruby builds a new Range for every line that
137
+ # reaches it. The frozen constant is looked up instead.
138
+ DIGITS = (DIGIT_0..DIGIT_9).freeze
139
+
128
140
  # Escapes markdown special characters in the given text.
129
141
  #
130
142
  # Handles both block-level constructs (headings, lists, code blocks, HTML blocks)
@@ -225,8 +237,13 @@ module Markbridge
225
237
  has_indent = indent_len > 0
226
238
  content = has_indent ? line[indent_len..] : line
227
239
 
228
- escaped, skip_inline = escape_block_level(content)
229
- escaped = escape_inline(escaped) unless skip_inline
240
+ # escape_block_level hands the content back unchanged when the
241
+ # line starts no block construct, and the content then only
242
+ # needs inline escaping. Answering with the same object instead
243
+ # of a [line, skip_inline] pair keeps an Array allocation off
244
+ # every line.
245
+ escaped = escape_block_level(content)
246
+ escaped = escape_inline(content) if escaped.equal?(content)
230
247
 
231
248
  if has_indent
232
249
  result = String.new(encoding: line.encoding)
@@ -272,6 +289,9 @@ module Markbridge
272
289
  "#{nbsp_indent}#{escape_inline(content)}"
273
290
  end
274
291
 
292
+ # @return [String] the escaped line, or +content+ itself when the
293
+ # line starts no block construct and the caller has to
294
+ # inline-escape it.
275
295
  def escape_block_level(content)
276
296
  first_byte = content.getbyte(0)
277
297
 
@@ -295,7 +315,7 @@ module Markbridge
295
315
  return escape_block_star(content)
296
316
  when UNDERSCORE
297
317
  if THEMATIC_BREAK_UNDERSCORE.match?(content)
298
- return escape_all_chars(content, UNDERSCORE, "\\_"), true
318
+ return escape_all_chars(content, UNDERSCORE, "\\_")
299
319
  end
300
320
  when EQUALS
301
321
  # A line of only `=` is a setext heading underline when a
@@ -309,46 +329,54 @@ module Markbridge
309
329
  # Discourse renders `\=` as a literal `=`, so the result
310
330
  # looks the same.
311
331
  if SETEXT_UNDERLINE_EQUALS.match?(content)
312
- return escape_all_chars(content, EQUALS, "\\="), true
332
+ return escape_all_chars(content, EQUALS, "\\=")
313
333
  end
314
334
  when BACKTICK
315
- if FENCED_CODE_BACKTICK.match?(content)
316
- return escape_all_chars(content, BACKTICK, "\\`"), true
317
- end
335
+ return escape_fence(content, BACKTICK, "\\`") if FENCED_CODE_BACKTICK.match?(content)
318
336
  when TILDE
319
- return "\\#{content}", true if FENCED_CODE_TILDE.match?(content)
337
+ return escape_fence(content, TILDE, "\\~") if FENCED_CODE_TILDE.match?(content)
320
338
  when BRACKET_OPEN
321
339
  return escape_first_char_inline(content, "\\[")
322
340
  when PIPE
323
341
  return escape_first_char_inline(content, "\\|")
324
- when DIGIT_0..DIGIT_9
342
+ when DIGITS
325
343
  return escape_block_ordered_list(content)
326
344
  end
327
345
 
328
- [content, false]
346
+ content
347
+ end
348
+
349
+ # Every character of the fence run gets a backslash, so no part of
350
+ # the run can open a code span or strikethrough either, and the
351
+ # info string after the run is inline-escaped like any text.
352
+ def escape_fence(content, byte_val, escaped)
353
+ # The caller matched the fence, so the first byte is one.
354
+ length = 1
355
+ length += 1 while content.getbyte(length) == byte_val
356
+ "#{escaped * length}#{escape_inline(content[length..])}"
329
357
  end
330
358
 
331
359
  # Escape the first character and inline-escape the rest.
332
360
  def escape_first_char_inline(content, escaped_char)
333
- ["#{escaped_char}#{escape_inline(content[1..])}", true]
361
+ "#{escaped_char}#{escape_inline(content[1..])}"
334
362
  end
335
363
 
336
364
  def escape_block_dash(content)
337
- return escape_all_chars(content, DASH, "\\-"), true if THEMATIC_BREAK_DASH.match?(content)
365
+ return escape_all_chars(content, DASH, "\\-") if THEMATIC_BREAK_DASH.match?(content)
338
366
  if BULLET_LIST.match?(content)
339
367
  return pass_first_char_inline(content) if @allow.include?(:bullet_list)
340
368
  return escape_first_char_inline(content, "\\-")
341
369
  end
342
- [content, false]
370
+ content
343
371
  end
344
372
 
345
373
  def escape_block_star(content)
346
- return escape_all_chars(content, STAR, "\\*"), true if THEMATIC_BREAK_STAR.match?(content)
374
+ return escape_all_chars(content, STAR, "\\*") if THEMATIC_BREAK_STAR.match?(content)
347
375
  if BULLET_LIST.match?(content)
348
376
  return pass_first_char_inline(content) if @allow.include?(:bullet_list)
349
377
  return escape_first_char_inline(content, "\\*")
350
378
  end
351
- [content, false]
379
+ content
352
380
  end
353
381
 
354
382
  def escape_block_ordered_list(content)
@@ -356,23 +384,23 @@ module Markbridge
356
384
  rest = content[match[0].length..]
357
385
  return pass_marker_inline(content, match[0].length) if @allow.include?(:ordered_list)
358
386
 
359
- return "#{match[1]}\\#{match[2]}#{escape_inline(rest)}", true
387
+ return "#{match[1]}\\#{match[2]}#{escape_inline(rest)}"
360
388
  end
361
- [content, false]
389
+ content
362
390
  end
363
391
 
364
392
  # Like {#escape_first_char_inline} but the leading character is
365
393
  # preserved verbatim (used when allow: lets a single-byte
366
394
  # marker like `-`, `+`, `*`, or `>` through).
367
395
  def pass_first_char_inline(content)
368
- ["#{content[0]}#{escape_inline(content[1..])}", true]
396
+ "#{content[0]}#{escape_inline(content[1..])}"
369
397
  end
370
398
 
371
399
  # Preserve a multi-byte marker (e.g. `1.`, `99)`, `##`) and
372
400
  # inline-escape the rest. Used when allow: lets ordered lists
373
401
  # or ATX headings through.
374
402
  def pass_marker_inline(content, marker_length)
375
- ["#{content[0, marker_length]}#{escape_inline(content[marker_length..])}", true]
403
+ "#{content[0, marker_length]}#{escape_inline(content[marker_length..])}"
376
404
  end
377
405
 
378
406
  def escape_all_chars(str, byte_val, escaped)
@@ -396,21 +424,56 @@ module Markbridge
396
424
  @inline_len = bytesize
397
425
  pos = 0
398
426
 
399
- # No loop-progress guard: every `dispatch_inline_byte` branch
400
- # returns `pos + N` for N >= 1 by construction, so the loop
401
- # is provably terminating. Mutations that break this
427
+ # No loop-progress guard: the copy branch below advances by
428
+ # one and every `dispatch_inline_byte` branch returns
429
+ # `pos + N` for N >= 1 by construction, so the loop always
430
+ # ends. Mutations that break this
402
431
  # (`while true`, body drops, selector swaps that short-circuit
403
432
  # the dispatch) surface as timeouts rather than alive
404
433
  # mutations, and the inline guard would otherwise cost ~15%
405
434
  # on this hot path per benchmark.
406
435
  while pos < @inline_len
407
436
  byte = @inline_content.getbyte(pos)
408
- pos = dispatch_inline_byte(byte, pos)
437
+ if DISPATCH_BYTE[byte]
438
+ pos = dispatch_inline_byte(byte, pos)
439
+ else
440
+ # An ASCII byte that starts no construct. Writing it here
441
+ # instead of walking the whole case in dispatch_inline_byte
442
+ # saves eleven comparisons and two calls per byte, and most
443
+ # bytes of ordinary text land here.
444
+ @inline_result << byte
445
+ pos += 1
446
+ end
409
447
  end
410
448
 
411
449
  @inline_result
412
450
  end
413
451
 
452
+ # Every byte dispatch_inline_byte has an arm for, plus the lead
453
+ # byte of a multi-byte character (128 and up), which its `else`
454
+ # copies through. A new arm needs its byte in here too, or the
455
+ # arm never runs.
456
+ DISPATCH_BYTE =
457
+ begin
458
+ table = Array.new(256, false)
459
+ [
460
+ BACKSLASH,
461
+ DASH,
462
+ TILDE,
463
+ STAR,
464
+ UNDERSCORE,
465
+ BACKTICK,
466
+ BANG,
467
+ BRACKET_OPEN,
468
+ PIPE,
469
+ LT,
470
+ AMP,
471
+ *(128..255),
472
+ ].each { |byte| table[byte] = true }
473
+ table.freeze
474
+ end
475
+ private_constant :DISPATCH_BYTE
476
+
414
477
  def dispatch_inline_byte(byte, pos)
415
478
  case byte
416
479
  when BACKSLASH
@@ -438,7 +501,7 @@ module Markbridge
438
501
  when AMP
439
502
  escape_amp(pos)
440
503
  else
441
- escape_regular_char(byte, pos)
504
+ copy_multibyte_char(byte, pos)
442
505
  end
443
506
  end
444
507
 
@@ -505,11 +568,10 @@ module Markbridge
505
568
  matched = match[0]
506
569
  @inline_result << matched
507
570
  pos + matched.bytesize
508
- elsif (match = HTML_TAG.match(remaining))
509
- matched = match[0]
510
- @inline_result << "\\" << matched.gsub("`") { "\\`" }
511
- pos + matched.bytesize
512
- elsif HTML_TAG_START.match?(remaining)
571
+ elsif HTML_TAG.match?(remaining) || HTML_TAG_START.match?(remaining)
572
+ # The backslash keeps the tag text. What is inside the tag
573
+ # goes on through the loop like any text: a backslash, a
574
+ # backtick or an entity in an attribute needs its escape too.
513
575
  @inline_result << "\\<"
514
576
  pos + 1
515
577
  else
@@ -536,17 +598,15 @@ module Markbridge
536
598
  @inline_content.byteslice(pos, @inline_len - pos)
537
599
  end
538
600
 
539
- # Handle regular characters including multi-byte UTF-8.
540
- def escape_regular_char(byte, pos)
541
- if byte < 128
542
- @inline_result << byte
543
- pos + 1
544
- else
545
- char_len = utf8_char_length(byte)
546
- end_pos = [pos + char_len, @inline_len].min
547
- @inline_result << @inline_content.byteslice(pos, end_pos - pos)
548
- end_pos
549
- end
601
+ # Copy one multi-byte UTF-8 character through unchanged. The loop
602
+ # in escape_inline sends every byte of 128 and up here. A lead
603
+ # byte that promises more bytes than the content holds needs no
604
+ # clamp: byteslice copies what is left, and the returned
605
+ # position is past the end, which ends the loop.
606
+ def copy_multibyte_char(byte, pos)
607
+ char_len = utf8_char_length(byte)
608
+ @inline_result << @inline_content.byteslice(pos, char_len)
609
+ pos + char_len
550
610
  end
551
611
 
552
612
  def ascii_punctuation?(byte)
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Renderers
5
+ module Discourse
6
+ # Inline markup like `**` or `[u]` can't span a blank line: the blank
7
+ # line ends the paragraph and the markers stay visible as text. Forums
8
+ # allow `[b]` around several paragraphs, so the markup has to be
9
+ # closed before every blank line and opened again after it.
10
+ #
11
+ # The serializer does that for emphasis, strikethrough and underline.
12
+ # A custom tag that writes its own markers into a String can use
13
+ # {.wrap} or {.map} for the same.
14
+ module Paragraphs
15
+ # One or more blank lines.
16
+ BREAK = /\n[[:blank:]]*\n/
17
+ # The same with all the blank lines of a run, captured so that
18
+ # split keeps them.
19
+ SPLIT = /(\n[[:blank:]]*\n(?:[[:blank:]]*\n)*)/
20
+ # Whitespace at the start and at the end, and what is between.
21
+ EDGES = /\A([[:space:]]*)(.*?)([[:space:]]*)\z/m
22
+ private_constant :BREAK, :SPLIT, :EDGES
23
+
24
+ # Whether +content+ has a blank line that markup would have to be
25
+ # closed and opened again around. A blank line inside a code fence
26
+ # belongs to the code, so content with a fence is one paragraph.
27
+ #
28
+ # @param content [String]
29
+ # @return [Boolean]
30
+ def self.many?(content)
31
+ content.match?(BREAK) && !content.include?("```") && !content.include?("~~~")
32
+ end
33
+
34
+ # Yields each paragraph of +content+ on its own and joins the
35
+ # results with the original blank lines. Content without a blank
36
+ # line is yielded as a whole. A blank line at the start or at the
37
+ # end is kept, and the empty String in front of a blank line at
38
+ # the start is yielded like a paragraph.
39
+ #
40
+ # @param content [String]
41
+ # @yieldparam paragraph [String] content without a blank line
42
+ # @yieldparam first [Boolean] whether it is the first paragraph
43
+ # @yieldparam last [Boolean] whether it is the last paragraph
44
+ # @yieldreturn [String] the wrapped paragraph
45
+ # @return [String]
46
+ # @example
47
+ # Paragraphs.map("a\n\nb") { |paragraph| "**#{paragraph}**" }
48
+ # # => "**a**\n\n**b**"
49
+ def self.map(content)
50
+ return yield(content, true, true) unless many?(content)
51
+
52
+ parts = content.split(SPLIT)
53
+ # Even indexes are paragraphs, odd ones the blank lines between.
54
+ last = parts.size - 1
55
+ last -= 1 if last.odd?
56
+ parts
57
+ .each_with_index
58
+ .map { |part, index| index.odd? ? part : yield(part, index.zero?, index == last) }
59
+ .join
60
+ end
61
+
62
+ # Puts +open+ and +close+ around every paragraph of +content+.
63
+ # Whitespace at the edges of a paragraph stays outside, and a
64
+ # paragraph with only whitespace stays as it is.
65
+ #
66
+ # @param content [String]
67
+ # @param open [String]
68
+ # @param close [String]
69
+ # @return [String]
70
+ # @example
71
+ # Paragraphs.wrap("a \n\nb", "[color=red]", "[/color]")
72
+ # # => "[color=red]a[/color] \n\n[color=red]b[/color]"
73
+ def self.wrap(content, open, close)
74
+ map(content) do |paragraph|
75
+ lead, core, trail = paragraph.match(EDGES).captures
76
+ next paragraph if core.empty?
77
+
78
+ # A BBCode tag at the start of a line with a `:` right after
79
+ # it reads as a link reference definition, `[color=red]:x`,
80
+ # and cooks to nothing; the backslash keeps the colon text.
81
+ core = "\\#{core}" if open.start_with?("[") && core.start_with?(":")
82
+ "#{lead}#{open}#{core}#{close}#{trail}"
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end