markbridge 0.4.3 → 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 (53) 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/handlers/image_handler.rb +2 -1
  6. data/lib/markbridge/parsers/bbcode/handlers/url_handler.rb +26 -2
  7. data/lib/markbridge/parsers/html/handlers/image_handler.rb +4 -1
  8. data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +3 -2
  9. data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +4 -2
  10. data/lib/markbridge/parsers/text_formatter/parser.rb +107 -14
  11. data/lib/markbridge/renderers/discourse/html_block.rb +50 -7
  12. data/lib/markbridge/renderers/discourse/inline/joins.rb +382 -0
  13. data/lib/markbridge/renderers/discourse/inline/serializer.rb +738 -0
  14. data/lib/markbridge/renderers/discourse/inline/tokens.rb +182 -0
  15. data/lib/markbridge/renderers/discourse/link_destination.rb +43 -0
  16. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +105 -45
  17. data/lib/markbridge/renderers/discourse/paragraphs.rb +88 -0
  18. data/lib/markbridge/renderers/discourse/postprocessor.rb +183 -7
  19. data/lib/markbridge/renderers/discourse/render_context.rb +38 -12
  20. data/lib/markbridge/renderers/discourse/renderer.rb +336 -92
  21. data/lib/markbridge/renderers/discourse/rendering_interface.rb +123 -44
  22. data/lib/markbridge/renderers/discourse/tag.rb +53 -9
  23. data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +4 -6
  24. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +48 -10
  25. data/lib/markbridge/renderers/discourse/tags/color_tag.rb +73 -13
  26. data/lib/markbridge/renderers/discourse/tags/details_tag.rb +62 -15
  27. data/lib/markbridge/renderers/discourse/tags/email_tag.rb +8 -9
  28. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +31 -9
  29. data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +20 -1
  30. data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +12 -1
  31. data/lib/markbridge/renderers/discourse/tags/image_tag.rb +13 -27
  32. data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +4 -6
  33. data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +9 -2
  34. data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +75 -9
  35. data/lib/markbridge/renderers/discourse/tags/list_tag.rb +29 -7
  36. data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +12 -0
  37. data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +13 -6
  38. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +52 -9
  39. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +26 -1
  40. data/lib/markbridge/renderers/discourse/tags/size_tag.rb +78 -13
  41. data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +72 -14
  42. data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +5 -6
  43. data/lib/markbridge/renderers/discourse/tags/subscript_tag.rb +11 -5
  44. data/lib/markbridge/renderers/discourse/tags/superscript_tag.rb +11 -5
  45. data/lib/markbridge/renderers/discourse/tags/table_tag.rb +48 -28
  46. data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +3 -10
  47. data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +90 -43
  48. data/lib/markbridge/renderers/discourse/tags/url_tag.rb +41 -31
  49. data/lib/markbridge/renderers/discourse.rb +6 -0
  50. data/lib/markbridge/rspec.rb +4 -2
  51. data/lib/markbridge/version.rb +1 -1
  52. data/lib/markbridge.rb +90 -1
  53. metadata +7 -1
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Renderers
5
+ module Discourse
6
+ module Inline
7
+ # What has to happen where two pieces of inline Markdown meet. The
8
+ # rules are a table read from the class of the byte in front of
9
+ # the join and the byte after it, so the common case, nothing to
10
+ # do, costs four byte reads and no method call.
11
+ #
12
+ # A run of emphasis delimiters at the start of the piece cannot
13
+ # open when a word character stands in front of it and
14
+ # punctuation follows it: `item*\#*` stays literal text
15
+ # (CommonMark 6.2). The mirror image holds for a run at the end
16
+ # of the buffer. An empty HTML comment between the two sides is
17
+ # punctuation, so the run can open or close again. Two runs of
18
+ # the same delimiter would merge into one longer run, the
19
+ # comment keeps them apart. A `!` right in front of a link makes
20
+ # it an image, a backslash keeps it a `!`.
21
+ #
22
+ # Two more joins are what the escaper would escape inside one
23
+ # text, but cannot see across two pieces: a `<` at the end of the
24
+ # buffer with a `!`, `?`, `/` or a letter after it would start raw
25
+ # HTML (a declaration like `<!y` eats the text up to the next
26
+ # `>`), so the `<` gets a backslash; two `-` that meet would be a
27
+ # dash to Discourse's typographer, so the second gets one.
28
+ module Joins
29
+ # Inserted between two pieces. Invisible when cooked, and
30
+ # punctuation to the flanking rules.
31
+ BOUNDARY_COMMENT = "<!---->"
32
+
33
+ # A punctuation character or a symbol right at the search start,
34
+ # which is what CommonMark's flanking rules count as punctuation
35
+ # (ASCII and Unicode alike), as markdown-it does. For a character
36
+ # outside ASCII only; an ASCII byte is answered from the class
37
+ # table without a regex.
38
+ PUNCTUATION_AT = /\A[\p{P}\p{S}]/
39
+ # Whitespace outside ASCII, matched on the one character.
40
+ SPACE_AT = /\A[[:space:]]/
41
+ BACKSLASH = 92
42
+ SPACE = 32
43
+ TAB = 9
44
+ CR = 13
45
+
46
+ # What a byte is to the join rules. WORD: ASCII letters and
47
+ # digits, and every non-ASCII byte. On the side of the join away
48
+ # from a delimiter run, Unicode punctuation then goes through the
49
+ # check like a letter, which at worst costs a comment that
50
+ # changes nothing; on the side of the run the check reads the
51
+ # whole character (.punctuation_at?). PUNCTUATION: ASCII
52
+ # punctuation as CommonMark defines it, the backslash included,
53
+ # which is what makes an escaped character at the edge of an
54
+ # emphasis count as punctuation. The five bytes the rules name
55
+ # get a class of their own, so a class tells them apart without
56
+ # a second comparison; they stay punctuation, which is why they
57
+ # sit above PUNCTUATION. AT is the `@` of a mention: after a word
58
+ # character, or punctuation that an email address may hold, it
59
+ # would read as an email address with the text after it. LT and
60
+ # DASH are the `<` and the `-` that the escaper would escape
61
+ # inside one text, see the module comment.
62
+ OTHER = 0
63
+ WORD = 1
64
+ PUNCTUATION = 2
65
+ BANG = 3
66
+ BRACKET = 4
67
+ BACKTICK = 5
68
+ STAR = 6
69
+ UNDERSCORE = 7
70
+ TILDE = 8
71
+ AT = 9
72
+ LT = 10
73
+ DASH = 11
74
+ CLASS_COUNT = 12
75
+ # A 256-byte string read with getbyte, so classifying a byte is
76
+ # one read instead of a chain of comparisons.
77
+ BYTE_CLASSES =
78
+ Array
79
+ .new(256) do |byte|
80
+ case byte
81
+ when 33
82
+ BANG
83
+ when 42
84
+ STAR
85
+ when 45
86
+ DASH
87
+ when 60
88
+ LT
89
+ when 64
90
+ AT
91
+ when 91
92
+ BRACKET
93
+ when 95
94
+ UNDERSCORE
95
+ when 96
96
+ BACKTICK
97
+ when 126
98
+ TILDE
99
+ else
100
+ if byte >= 128 || byte.between?(48, 57) || byte.between?(65, 90) ||
101
+ byte.between?(97, 122)
102
+ WORD
103
+ elsif byte.between?(33, 47) || byte.between?(58, 64) || byte.between?(91, 96) ||
104
+ byte.between?(123, 126)
105
+ PUNCTUATION
106
+ else
107
+ OTHER
108
+ end
109
+ end
110
+ end
111
+ .pack("C*")
112
+ .freeze
113
+
114
+ # What a join between two pieces has to do. ESCAPE_BEFORE puts a
115
+ # backslash in front of the last byte of the buffer (a `!` or a
116
+ # `<`), ESCAPE_AFTER one in front of the first byte of the piece
117
+ # (a `-`).
118
+ NOTHING = 0
119
+ BOUNDARY = 1
120
+ CHECK_AFTER = 2
121
+ CHECK_BEFORE = 3
122
+ ESCAPE_BEFORE = 4
123
+ ESCAPE_AFTER = 5
124
+ # Runs that merge into a single longer run during Markdown
125
+ # parsing: emphasis (* _), strikethrough (~), code spans (`).
126
+ MERGING_CLASSES = [STAR, UNDERSCORE, TILDE, BACKTICK].freeze
127
+ # Runs that open and close only where the flanking rules allow it.
128
+ FLANKING_CLASSES = [STAR, UNDERSCORE, TILDE].freeze
129
+ # The bytes after a `<` that start raw HTML (CommonMark 6.6): a
130
+ # tag name or `/`, a declaration `!`, a processing instruction
131
+ # `?`. Bytes, not classes: a digit or a non-ASCII byte is a word
132
+ # character too, but starts no tag.
133
+ HTML_START_BYTES = [33, 47, 63, *65..90, *97..122].freeze
134
+
135
+ # The action per join, in CLASS_COUNT rows of 256 bytes (2304 in
136
+ # all), read with JOIN_ACTIONS.getbyte((class of the byte in front
137
+ # << 8) + the byte after). Both sides of a join are a single byte
138
+ # for every rule, so the whole decision fits in the table except
139
+ # for the two cases that have to look past a delimiter run and the
140
+ # one that has to count backslashes.
141
+ #
142
+ # A `_` run never opens after, or closes in front of, a word
143
+ # character, so it needs no look past the run.
144
+ #
145
+ # A tilde counts as a blocking neighbor like a word character:
146
+ # CommonMark reads `~` as punctuation, but cmark-gfm does not
147
+ # treat it as one next to an emphasis delimiter, so strikethrough
148
+ # right next to emphasis with a punctuation edge needs the
149
+ # boundary as well. markdown-it would not need it; the comment
150
+ # changes nothing there.
151
+ JOIN_ACTIONS =
152
+ Array
153
+ .new(CLASS_COUNT * 256) do |index|
154
+ before = index >> 8
155
+ byte = index & 255
156
+ after = BYTE_CLASSES.getbyte(byte)
157
+
158
+ if before == after && MERGING_CLASSES.include?(before)
159
+ BOUNDARY
160
+ elsif before == BANG && after == BRACKET
161
+ ESCAPE_BEFORE
162
+ elsif before == LT && HTML_START_BYTES.include?(byte)
163
+ ESCAPE_BEFORE
164
+ elsif before == DASH && after == DASH
165
+ ESCAPE_AFTER
166
+ elsif FLANKING_CLASSES.include?(after) && (before == WORD || before == TILDE)
167
+ after == UNDERSCORE ? BOUNDARY : CHECK_AFTER
168
+ elsif FLANKING_CLASSES.include?(before) && (after == WORD || after == TILDE)
169
+ before == UNDERSCORE ? BOUNDARY : CHECK_BEFORE
170
+ elsif after == AT && before != OTHER
171
+ BOUNDARY
172
+ else
173
+ NOTHING
174
+ end
175
+ end
176
+ .pack("C*")
177
+ .freeze
178
+ private_constant :PUNCTUATION_AT,
179
+ :SPACE_AT,
180
+ :BACKSLASH,
181
+ :SPACE,
182
+ :TAB,
183
+ :CR,
184
+ :OTHER,
185
+ :WORD,
186
+ :PUNCTUATION,
187
+ :BANG,
188
+ :BRACKET,
189
+ :BACKTICK,
190
+ :STAR,
191
+ :UNDERSCORE,
192
+ :TILDE,
193
+ :AT,
194
+ :LT,
195
+ :DASH,
196
+ :CLASS_COUNT,
197
+ :BYTE_CLASSES,
198
+ :NOTHING,
199
+ :BOUNDARY,
200
+ :CHECK_AFTER,
201
+ :CHECK_BEFORE,
202
+ :ESCAPE_BEFORE,
203
+ :ESCAPE_AFTER,
204
+ :MERGING_CLASSES,
205
+ :FLANKING_CLASSES,
206
+ :HTML_START_BYTES,
207
+ :JOIN_ACTIONS
208
+
209
+ # Appends +piece+ to +buffer+ with what the join between them
210
+ # needs. On an empty buffer getbyte returns nil and there is no
211
+ # byte in front of the join, so the first piece needs no guard
212
+ # of its own.
213
+ #
214
+ # @param buffer [String] the Markdown written so far, changed in place
215
+ # @param piece [String] the next piece, not empty
216
+ # @return [String] +buffer+
217
+ def self.append(buffer, piece)
218
+ last_byte = buffer.getbyte(-1)
219
+ if last_byte
220
+ action =
221
+ JOIN_ACTIONS.getbyte((BYTE_CLASSES.getbyte(last_byte) << 8) + piece.getbyte(0))
222
+ join(buffer, piece, last_byte, action) unless action == NOTHING
223
+ end
224
+ buffer << piece
225
+ end
226
+
227
+ # Appends +piece+ to +buffer+ with the join that +like+ would need
228
+ # in its place: +like+ is the text a later step will most likely
229
+ # put there instead of +piece+ (see Tokens.placeholder). +before+,
230
+ # when given, stands in the same way for the end of the buffer,
231
+ # after a placeholder; then a `!` at its end can't take a
232
+ # backslash, and the boundary comment keeps it from making an
233
+ # image of a link instead.
234
+ #
235
+ # @param buffer [String] changed in place
236
+ # @param piece [String] the next piece, not empty
237
+ # @param like [String, nil] stands in for +piece+, nil for none
238
+ # @param before [String, nil] stands in for the end of +buffer+
239
+ # @return [String] +buffer+
240
+ def self.append_like(buffer, piece, like, before = nil)
241
+ tail = before || buffer
242
+ last_byte = tail.getbyte(-1)
243
+ if last_byte
244
+ after = like || piece
245
+ action =
246
+ JOIN_ACTIONS.getbyte((BYTE_CLASSES.getbyte(last_byte) << 8) + after.getbyte(0))
247
+ join(buffer, after, last_byte, action, tail, !like.nil?) unless action == NOTHING
248
+ end
249
+ buffer << piece
250
+ end
251
+
252
+ # Whether the last character of +string+ is escaped: only an odd
253
+ # run of backslashes in front of it escapes it, `\\!` is an
254
+ # escaped backslash followed by an active `!`. When the run
255
+ # reaches the start of the string the index turns negative and
256
+ # getbyte wraps around to the last character, which is no
257
+ # backslash and stops the loop.
258
+ #
259
+ # @param string [String] not empty
260
+ # @return [Boolean]
261
+ def self.escaped_end?(string)
262
+ escaped = false
263
+ index = string.bytesize - 2
264
+ while string.getbyte(index) == BACKSLASH
265
+ escaped = !escaped
266
+ index -= 1
267
+ end
268
+ escaped
269
+ end
270
+
271
+ # Carries out the action the table picked. NOTHING never gets
272
+ # here, the caller keeps it out, so the last branch is
273
+ # ESCAPE_BEFORE. +tail+ is what the end of the buffer is read
274
+ # as, and +stand_in+ says whether +piece+ stands for the piece
275
+ # that is appended, see .append_like: a stand-in gets no
276
+ # backslash, the comment keeps the two apart instead.
277
+ def self.join(buffer, piece, last_byte, action, tail = buffer, stand_in = false)
278
+ if action == BOUNDARY
279
+ buffer << BOUNDARY_COMMENT
280
+ elsif action == CHECK_AFTER
281
+ buffer << BOUNDARY_COMMENT if punctuation_at?(piece, after_run(piece))
282
+ elsif action == CHECK_BEFORE
283
+ buffer << BOUNDARY_COMMENT if punctuation_at?(tail, before_run(tail, last_byte))
284
+ elsif action == ESCAPE_AFTER
285
+ buffer << (stand_in ? BOUNDARY_COMMENT : "\\")
286
+ elsif !tail.equal?(buffer)
287
+ buffer << BOUNDARY_COMMENT
288
+ elsif !escaped_end?(buffer)
289
+ # Byte offsets: the `!` or `<` is the last byte.
290
+ buffer.bytesplice(buffer.bytesize - 1, 0, "\\")
291
+ end
292
+ end
293
+
294
+ # The byte offset of the first byte after the run of the piece's
295
+ # first byte, nil when the piece is the run alone. A delimiter is
296
+ # ASCII, so the byte after the run starts a character.
297
+ def self.after_run(piece)
298
+ run = piece.getbyte(0)
299
+ index = 1
300
+ index += 1 while piece.getbyte(index) == run
301
+ index if index < piece.bytesize
302
+ end
303
+
304
+ # The byte offset of the character in front of the run that ends
305
+ # the buffer, nil when the buffer is the run alone. The byte in
306
+ # front of the run may be inside a multi-byte character, so the
307
+ # search goes back to its first byte.
308
+ def self.before_run(buffer, run)
309
+ index = buffer.bytesize - 2
310
+ index -= 1 while index >= 0 && buffer.getbyte(index) == run
311
+ return if index.negative?
312
+
313
+ index -= 1 while index.positive? && (buffer.getbyte(index) & 0xC0) == 0x80
314
+ index
315
+ end
316
+
317
+ # Whether the character at byte +index+ of +string+ is punctuation
318
+ # for the flanking rules; nil is no character and no punctuation.
319
+ # An ASCII byte is answered from the class table, a character
320
+ # outside ASCII is matched as a whole.
321
+ def self.punctuation_at?(string, index)
322
+ return false if index.nil?
323
+
324
+ byte = string.getbyte(index)
325
+ return BYTE_CLASSES.getbyte(byte) >= PUNCTUATION if byte < 128
326
+
327
+ string.byteslice(index, char_length(byte)).match?(PUNCTUATION_AT)
328
+ end
329
+
330
+ # Whether the character at byte +index+ of +string+ is whitespace
331
+ # as [[:space:]] reads it; nil is no character. A regex over the
332
+ # whole string would scan it for its encoding first, which costs
333
+ # as much as the string is long.
334
+ def self.space_at?(string, index)
335
+ return false if index.nil?
336
+
337
+ byte = string.getbyte(index)
338
+ return false if byte.nil?
339
+ return byte == SPACE || (byte >= TAB && byte <= CR) if byte < 128
340
+ return false if byte < 0xC2
341
+
342
+ string.byteslice(index, char_length(byte)).match?(SPACE_AT)
343
+ end
344
+
345
+ # The byte offset of the last character of +string+, nil for an
346
+ # empty string.
347
+ def self.last_char_index(string)
348
+ index = string.bytesize - 1
349
+ index -= 1 while index.positive? && (string.getbyte(index) & 0xC0) == 0x80
350
+ index.negative? ? nil : index
351
+ end
352
+
353
+ # Whether +string+ holds a character that is not whitespace.
354
+ def self.visible?(string)
355
+ index = 0
356
+ while (byte = string.getbyte(index))
357
+ return true unless space_at?(string, index)
358
+
359
+ index += byte < 128 ? 1 : char_length(byte)
360
+ end
361
+ false
362
+ end
363
+
364
+ # The length in bytes of the character whose lead byte is +byte+.
365
+ # A slice that reaches into the next character is no valid
366
+ # string, so a slice is cut to the character.
367
+ def self.char_length(byte)
368
+ if byte >= 0xF0
369
+ 4
370
+ elsif byte >= 0xE0
371
+ 3
372
+ else
373
+ 2
374
+ end
375
+ end
376
+
377
+ private_class_method :join, :punctuation_at?, :after_run, :before_run, :char_length
378
+ end
379
+ end
380
+ end
381
+ end
382
+ end