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,738 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Renderers
5
+ module Discourse
6
+ module Inline
7
+ # Writes inline tokens (see {Tokens}) as Markdown, or as HTML inside
8
+ # an HTML block. It is the one place that knows what stands on both
9
+ # sides of a piece of output: which backtick run a code span needs,
10
+ # where the markers of an emphasis go when its content starts with
11
+ # whitespace, whether a bare URL can stay bare or has to be written
12
+ # as a link, and what a join between two pieces needs ({Joins}).
13
+ #
14
+ # Tokens are written in groups, one per child of the element being
15
+ # rendered, so a tag can still change the buffer in front of a
16
+ # child (see {#write_groups}). A child's output that is a String
17
+ # instead of tokens is finished Markdown from a tag and is written
18
+ # as it is.
19
+ class Serializer
20
+ # Per delimiter: the opening and closing marker, the HTML tags for
21
+ # an HTML block, the character whose run makes the marker, and
22
+ # whether a nested pair of that character at one edge of the
23
+ # content is fine. Content with the marker in it, or with the
24
+ # character at an edge where it would join the marker into a
25
+ # longer run, cannot be wrapped and gets the HTML tags instead.
26
+ # For emphasis a nested pair at one edge still works (`***a* b**`
27
+ # is strong), at both edges it does not. For strikethrough three
28
+ # tildes are no marker at all. The BBCode underline has no such
29
+ # character, Discourse reads it with its own rule.
30
+ DELIMITERS = {
31
+ strong: %w[** ** <strong> </strong> *],
32
+ em: %w[* * <em> </em> *],
33
+ s: %w[~~ ~~ <s> </s> ~],
34
+ u: ["[u]", "[/u]", %(<span class="bbcode-u">), "</span>", nil],
35
+ }.freeze
36
+
37
+ # Whitespace and <br> only: nothing a marker could wrap.
38
+ NOTHING_TO_WRAP = /\A(?:[[:space:]]|<br>)*\z/
39
+ # Leading or trailing whitespace, Unicode-aware: CommonMark's
40
+ # flanking rules treat a no-break space as whitespace too. A <br>
41
+ # at an edge is a line break the serializer wrote, and it moves
42
+ # out with the whitespace: `[<br>a](u)` shows the same as
43
+ # `<br>[a](u)`, which keeps the label to the text.
44
+ EDGES = /\A((?:[[:space:]]|<br>)*)(.+?)((?:[[:space:]]|<br>)*)\z/m
45
+ BR = "<br>"
46
+
47
+ # Text after a bare URL glues to it, unless it starts with
48
+ # whitespace or with sentence punctuation that is followed by
49
+ # whitespace or nothing: markdown-it (linkify) leaves one `,`, or
50
+ # a run of `.`, `!` or `?`, out of the link. It keeps a `:` in the
51
+ # link, and the first character of a mixed run like `.)` or `?!`.
52
+ # A `)` may come first, but only when the href has no parenthesis
53
+ # of its own: linkify counts them and keeps a `)` that closes one
54
+ # in the link.
55
+ GLUED_AFTER = /\A(?!(?:\.+|!+|\?+|,)?(?:[[:space:]]|\z))/
56
+ GLUED_AFTER_PAREN_OK = /\A(?!\)?(?:\.+|!+|\?+|,)?(?:[[:space:]]|\z))/
57
+ PARENTHESIS = /[()]/
58
+ # The character in front of a bare URL keeps it from being linked
59
+ # unless it is whitespace or an opening parenthesis.
60
+ NOT_GLUED_BEFORE = /[[:space:](]/
61
+ # What markdown-it's linkify links on its own: a web or FTP URL
62
+ # with its scheme, or a mail address with mailto:. A relative
63
+ # href, an anchor or a protocol-relative URL only links as a
64
+ # Markdown link.
65
+ AUTOLINKABLE = %r{\A(?:https?://|ftps?://|mailto:)}i
66
+ # An href that is no URL but a place on the site: a path, an
67
+ # anchor, a query. Such a bare link is written as a Markdown
68
+ # link. A target that is neither, a wiki page name for example,
69
+ # stays plain text: a link to it would be broken anyway, and a
70
+ # consumer maps such targets to real URLs on the AST.
71
+ PATH_LIKE = %r{\A(?:[/#?]|\.{1,2}/)}
72
+
73
+ BACKTICKS = /`+/
74
+ ASTERISK = "*"
75
+ # Breaks written as <br> at the end of a group, after the newline
76
+ # of the break in front of them.
77
+ TRAILING_BREAKS = /\n<br>(?:\n|<br>)*\z/
78
+ # Spaces and tabs at the start of a piece. Not a no-break space:
79
+ # the escaper writes one where it wants indentation kept.
80
+ LEADING_BLANKS = /\A[ \t]+/
81
+ NEWLINE = 10
82
+ SPACE = 32
83
+ TAB = 9
84
+ LT = 60
85
+ GT = 62
86
+ # Whitespace with a line break in it, see #flat_alt.
87
+ ALT_LINE_BREAK = /[[:space:]]*\n[[:space:]]*/
88
+ # The parts after a `|` in the alt text that Discourse reads as
89
+ # image options: a size (`100x100`, `100x100, 50%`), a media type
90
+ # or `thumbnail`, and a data attribute (`key=value`). Spelled
91
+ # after isKnownImageSuffix in Discourse's markdown-it engine.
92
+ DISCOURSE_OPTION =
93
+ /\A(?:[1-9][0-9]*x[1-9][0-9]*(?:[[:space:]]*,[[:space:]]*x?[1-9][0-9]{0,2}[%x]?)?|video|audio|thumbnail)\z/
94
+ DATA_ATTRIBUTE = /\A[\w\-:.]*=/
95
+
96
+ private_constant :NOTHING_TO_WRAP,
97
+ :EDGES,
98
+ :GLUED_AFTER,
99
+ :GLUED_AFTER_PAREN_OK,
100
+ :PARENTHESIS,
101
+ :NOT_GLUED_BEFORE,
102
+ :AUTOLINKABLE,
103
+ :PATH_LIKE,
104
+ :BACKTICKS,
105
+ :ASTERISK,
106
+ :BR,
107
+ :GT,
108
+ :LEADING_BLANKS,
109
+ :TRAILING_BREAKS,
110
+ :NEWLINE,
111
+ :SPACE,
112
+ :TAB,
113
+ :LT,
114
+ :ALT_LINE_BREAK,
115
+ :DISCOURSE_OPTION,
116
+ :DATA_ATTRIBUTE
117
+
118
+ # @param escaper [#escape] escapes text for Markdown, see
119
+ # {MarkdownEscaper#escape}
120
+ # @param html_escaper [#escape] escapes text for HTML
121
+ def initialize(escaper:, html_escaper: HtmlEscaper)
122
+ @escaper = escaper
123
+ @html_escaper = html_escaper
124
+ # The buffer the last hard break went into as a newline, and
125
+ # its length right after. A piece that starts with a newline
126
+ # follows that break only while nothing else was written to or
127
+ # taken off the buffer since.
128
+ @break_buffer = nil
129
+ @break_end = 0
130
+ # The buffer the last placeholder went into, its length right
131
+ # after and the text that will most likely replace it. The
132
+ # next piece is joined with that text instead of the end of
133
+ # the placeholder.
134
+ @like_buffer = nil
135
+ @like_end = 0
136
+ @like = nil
137
+ end
138
+
139
+ # Writes one run of tokens.
140
+ #
141
+ # @param tokens [Array<Array>]
142
+ # @param html_mode [Boolean] write HTML for an HTML block
143
+ # @return [String]
144
+ def write(tokens, html_mode: false)
145
+ buffer = +""
146
+ pending = write_tokens(buffer, tokens, 0, tokens.size, html_mode, nil)
147
+ join_like(buffer, bare_url_piece(pending, buffer, nil)) if pending
148
+ drop_trailing_breaks(buffer, html_mode)
149
+ end
150
+
151
+ # Writes the output of several children one after the other. The
152
+ # block gives the output of each child, finished Markdown or
153
+ # tokens, as it is reached. +before+ runs right before the first
154
+ # piece of a child's output is appended, with the buffer so far
155
+ # and the child; it may change the buffer in place. A child
156
+ # whose output is empty is skipped.
157
+ #
158
+ # @param children [Array] the children
159
+ # @param html_mode [Boolean]
160
+ # @param before [Proc, nil] called with the buffer and the child
161
+ # @yieldparam child [Object]
162
+ # @yieldreturn [String, Array<Array>] the child's output
163
+ # @return [String]
164
+ def write_groups(children, html_mode: false, before: nil)
165
+ buffer = +""
166
+ # A bare URL at the end of an output waits for the first piece
167
+ # of the next output that has one.
168
+ pending = nil
169
+ children.each do |child|
170
+ output = yield(child)
171
+ if output.instance_of?(String)
172
+ next if output.empty?
173
+
174
+ # The hook may take whitespace off the buffer, so the piece
175
+ # is fitted to the line after it ran, and after a waiting
176
+ # bare URL was written: it follows the URL, not the line.
177
+ pending = settle(buffer, pending, output)
178
+ before&.call(buffer, child)
179
+ output = start_of_line(buffer, output) unless html_mode
180
+ next if output.empty?
181
+
182
+ if html_mode
183
+ buffer << output
184
+ elsif @like_buffer
185
+ join_like(buffer, output)
186
+ else
187
+ Joins.append(buffer, output)
188
+ end
189
+ else
190
+ pending =
191
+ write_tokens(buffer, output, 0, output.size, html_mode, pending, before, child)
192
+ end
193
+ end
194
+ join_like(buffer, bare_url_piece(pending, buffer, nil)) if pending
195
+ drop_trailing_breaks(buffer, html_mode)
196
+ end
197
+
198
+ private
199
+
200
+ # A break written as <br> protected the line it ended against the
201
+ # newline of the next piece. Nothing follows in this group, so at
202
+ # its end the <br> protects nothing and goes, like the whitespace
203
+ # a block tag takes off its content. In an HTML block a break is
204
+ # <br> on its own, and stays.
205
+ def drop_trailing_breaks(buffer, html_mode)
206
+ buffer.sub!(TRAILING_BREAKS, "\n") if !html_mode && buffer.include?("<br>")
207
+ buffer
208
+ end
209
+
210
+ # Writes the tokens from +from+ up to +to+ into +buffer+, one piece
211
+ # at a time, each joined with what stands in front of it. A
212
+ # delimiter pair or a link is one piece with its content written
213
+ # first. A bare URL is not written at once: it waits for the next
214
+ # piece, which decides its form, and is returned as +pending+
215
+ # when the tokens end with it. +before+ runs with +child+ right
216
+ # before the first piece of these tokens is written.
217
+ #
218
+ # @return [String, nil] the href of a bare URL that still waits
219
+ def write_tokens(
220
+ buffer,
221
+ tokens,
222
+ from,
223
+ to,
224
+ html_mode,
225
+ pending,
226
+ before = nil,
227
+ child = nil,
228
+ blank: 0
229
+ )
230
+ index = from
231
+ after_html = false
232
+ while index < to
233
+ token = tokens.fetch(index)
234
+ index += 1
235
+ kind = nil
236
+ like = nil
237
+ moved_break = false
238
+ if token.instance_of?(String)
239
+ piece = token
240
+ else
241
+ kind, value = token
242
+ piece =
243
+ case kind
244
+ when Tokens::TEXT
245
+ text_piece(token, html_mode)
246
+ when Tokens::HTML
247
+ value
248
+ when Tokens::PLACEHOLDER
249
+ like = token.fetch(2)
250
+ value
251
+ when Tokens::OPEN
252
+ close =
253
+ matching_close(tokens, index - 1, to, Tokens::OPEN, Tokens::CLOSE, value)
254
+ content =
255
+ run(tokens, index, close, html_mode, trailing_blank_lines(buffer, blank))
256
+ # A break at the end of the content moves out of the
257
+ # markers with its newline.
258
+ moved_break = after_break?(content)
259
+ nested = nested_pairs(tokens, index, close)
260
+ index = close + 1
261
+ delimiter_piece(value, content, html_mode, nested)
262
+ when Tokens::LINK_OPEN
263
+ close =
264
+ matching_close(tokens, index - 1, to, Tokens::LINK_OPEN, Tokens::LINK_CLOSE)
265
+ content =
266
+ run(tokens, index, close, html_mode, trailing_blank_lines(buffer, blank))
267
+ moved_break = after_break?(content)
268
+ index = close + 1
269
+ link_piece(value, content, html_mode)
270
+ when Tokens::CODE
271
+ code_piece(value, html_mode)
272
+ when Tokens::BARE_URL
273
+ if html_mode
274
+ html_link(value, @html_escaper.escape(value))
275
+ else
276
+ # Settled with the piece after it, or with nothing.
277
+ pending = settle(buffer, pending, token)
278
+ if before
279
+ before.call(buffer, child)
280
+ before = nil
281
+ end
282
+ pending = value
283
+ next
284
+ end
285
+ when Tokens::IMAGE
286
+ image_piece(token, html_mode)
287
+ when Tokens::HARD_BREAK
288
+ html_mode ? "<br>" : hard_break_piece(trailing_blank_lines(buffer, blank))
289
+ when Tokens::CLOSE, Tokens::LINK_CLOSE
290
+ raise ArgumentError, "#{kind} token without an open token in front of it"
291
+ else
292
+ raise ArgumentError, "unknown inline token #{token.inspect}"
293
+ end
294
+ end
295
+ next if piece.empty?
296
+
297
+ pending = settle(buffer, pending, like || piece)
298
+ if before
299
+ before.call(buffer, child)
300
+ before = nil
301
+ end
302
+ piece = start_of_line(buffer, piece, blank, after_html) unless html_mode
303
+ next if piece.empty?
304
+
305
+ if html_mode
306
+ buffer << piece
307
+ elsif like
308
+ join_like(buffer, piece, like)
309
+ @like_buffer = buffer
310
+ @like_end = buffer.bytesize
311
+ @like = like
312
+ elsif @like_buffer
313
+ join_like(buffer, piece)
314
+ else
315
+ # No placeholder written so far, the common case.
316
+ Joins.append(buffer, piece)
317
+ end
318
+ # An HTML tag alone on its line opens an HTML block, so the
319
+ # piece after it must not start with a newline; start_of_line
320
+ # writes <br> for the newline of a break too.
321
+ after_html = kind == Tokens::HTML
322
+ # A break written as a newline, or one that left a run at
323
+ # its end, is where a newline at the start of the next piece
324
+ # would make a blank line.
325
+ if (kind == Tokens::HARD_BREAK || moved_break) && piece.end_with?("\n")
326
+ @break_buffer = buffer
327
+ @break_end = buffer.bytesize
328
+ end
329
+ end
330
+ pending
331
+ end
332
+
333
+ # A piece that starts a line. Spaces at its start after a blank
334
+ # line go: a block ended there, and spaces at the start of the
335
+ # next line would pull the paragraph after them into that block.
336
+ # A newline at its start right after a hard break or an HTML tag
337
+ # becomes <br>: the break would be on a line that holds nothing
338
+ # else, and the tag would be alone on its line, which opens an
339
+ # HTML block; two newlines are a blank line the text brings on
340
+ # purpose. What is left may be nothing, which the caller skips.
341
+ def start_of_line(buffer, piece, blank = 0, after_html = false)
342
+ first = piece.getbyte(0)
343
+ if first == NEWLINE
344
+ return piece if piece.getbyte(1) == NEWLINE || !(after_html || after_break?(buffer))
345
+
346
+ "<br>#{piece.byteslice(1, piece.bytesize - 1)}"
347
+ else
348
+ blanks = first == SPACE || first == TAB
349
+ return piece unless blanks && trailing_blank_lines(buffer, blank) == 2
350
+
351
+ piece.sub(LEADING_BLANKS, "")
352
+ end
353
+ end
354
+
355
+ def after_break?(buffer)
356
+ buffer.equal?(@break_buffer) && buffer.bytesize == @break_end
357
+ end
358
+
359
+ # Nothing but whitespace and <br>, or nothing at all. The regex
360
+ # runs only when the first character lets it match; a regex over
361
+ # the whole content scans it for its encoding first.
362
+ def nothing_to_wrap?(content)
363
+ first = content.getbyte(0)
364
+ return true if first.nil?
365
+ return false unless first == LT || Joins.space_at?(content, 0)
366
+
367
+ content.match?(NOTHING_TO_WRAP)
368
+ end
369
+
370
+ # Whitespace or a <br> at the start or at the end of the content
371
+ # (see EDGES), checked on the two characters alone; the <br> only
372
+ # when the content starts with `<` or ends with `>`.
373
+ def edge_whitespace?(content)
374
+ Joins.space_at?(content, 0) ||
375
+ Joins.space_at?(content, Joins.last_char_index(content)) ||
376
+ (content.getbyte(0) == LT && content.start_with?(BR)) ||
377
+ (content.getbyte(-1) == GT && content.end_with?(BR))
378
+ end
379
+
380
+ # How many of the lines at the end of the buffer hold nothing but
381
+ # spaces and tabs, up to two. +blank+ is the count in front of
382
+ # the buffer: for a nested run, the count of the outer buffer it
383
+ # is written into.
384
+ def trailing_blank_lines(buffer, blank)
385
+ lines = 0
386
+ index = buffer.bytesize - 1
387
+ while index >= 0
388
+ byte = buffer.getbyte(index)
389
+ if byte == NEWLINE
390
+ lines += 1
391
+ return lines if lines == 2
392
+ elsif byte != SPACE && byte != TAB
393
+ return lines
394
+ end
395
+ index -= 1
396
+ end
397
+ [lines + blank, 2].min
398
+ end
399
+
400
+ # A newline. On a line that holds nothing else it is <br>: a
401
+ # newline there would be a blank line, which ends the paragraph
402
+ # and makes a list loose, while <br> keeps the empty line inside
403
+ # the paragraph. After a blank line the break is nothing, the
404
+ # paragraph has ended there already.
405
+ def hard_break_piece(blank_lines)
406
+ case blank_lines
407
+ when 0
408
+ "\n"
409
+ when 1
410
+ "<br>"
411
+ else
412
+ ""
413
+ end
414
+ end
415
+
416
+ # Writes a waiting bare URL now that +following+, the piece after
417
+ # it, is known. Another bare URL as +following+ glues like text.
418
+ # @return [nil]
419
+ def settle(buffer, pending, following)
420
+ join_like(buffer, bare_url_piece(pending, buffer, following)) if pending
421
+ nil
422
+ end
423
+
424
+ # Joins +piece+ with the buffer (see Joins). Right after a
425
+ # placeholder the join reads the text that will most likely
426
+ # replace it instead of the end of the buffer, and +like+ stands
427
+ # in for a placeholder as +piece+. Only needed once a placeholder
428
+ # was written; before that the callers take Joins.append.
429
+ def join_like(buffer, piece, like = nil)
430
+ Joins.append_like(buffer, piece, like, (@like if after_placeholder?(buffer)))
431
+ end
432
+
433
+ def after_placeholder?(buffer)
434
+ buffer.equal?(@like_buffer) && buffer.bytesize == @like_end
435
+ end
436
+
437
+ # The index of the +close_kind+ token that ends the +open_kind+
438
+ # token at +from+. A pair of the same kind, and for delimiters
439
+ # the same name, in between is skipped.
440
+ def matching_close(tokens, from, to, open_kind, close_kind, name = nil)
441
+ depth = 0
442
+ index = from + 1
443
+ while index < to
444
+ kind, value = tokens.fetch(index)
445
+ if name.nil? || value == name
446
+ if kind == open_kind
447
+ depth += 1
448
+ elsif kind == close_kind
449
+ return index if depth.zero?
450
+
451
+ depth -= 1
452
+ end
453
+ end
454
+ index += 1
455
+ end
456
+ raise ArgumentError, "#{open_kind} token without a #{close_kind} token after it"
457
+ end
458
+
459
+ NO_PAIRS = [false, false, false].freeze
460
+ private_constant :NO_PAIRS
461
+
462
+ # Whether the content between +from+ and +to+ starts with a
463
+ # nested delimiter pair, ends with one, and whether that is one
464
+ # pair spanning the whole content.
465
+ def nested_pairs(tokens, from, to)
466
+ first = tokens.fetch(from)
467
+ last = tokens.fetch(to - 1)
468
+ at_start = !first.instance_of?(String) && first.first == Tokens::OPEN
469
+ at_end = !last.instance_of?(String) && last.first == Tokens::CLOSE
470
+ return NO_PAIRS unless at_start || at_end
471
+
472
+ spanning =
473
+ at_start && at_end &&
474
+ matching_close(tokens, from, to, Tokens::OPEN, Tokens::CLOSE, first.fetch(1)) ==
475
+ to - 1
476
+ [at_start, at_end, spanning]
477
+ end
478
+
479
+ # The content between an open and a close token, written on
480
+ # its own. Its pieces are joined among themselves; what stands
481
+ # outside the pair is not known here, as a tag that returned a
482
+ # String would not know it either.
483
+ def run(tokens, from, to, html_mode, blank)
484
+ single_string(tokens, from, to) || joined(tokens, from, to, html_mode, blank)
485
+ end
486
+
487
+ def joined(tokens, from, to, html_mode, blank)
488
+ buffer = +""
489
+ pending = write_tokens(buffer, tokens, from, to, html_mode, nil, blank:)
490
+ join_like(buffer, bare_url_piece(pending, buffer, nil)) if pending
491
+ buffer
492
+ end
493
+
494
+ # One String between the tokens is the whole content, the common
495
+ # case: nothing to join, no buffer to fill. nil otherwise.
496
+ def single_string(tokens, from, to)
497
+ return unless to - from == 1
498
+
499
+ token = tokens.fetch(from)
500
+ token if token.instance_of?(String)
501
+ end
502
+
503
+ def text_piece(token, html_mode)
504
+ _, string, mode = token
505
+ return HtmlBlock.without_blank_lines(@html_escaper.escape(string)) if html_mode
506
+
507
+ case mode
508
+ when Tokens::ESCAPE
509
+ @escaper.escape(string)
510
+ when Tokens::LABEL
511
+ @escaper.escape(string, in_link_label: true)
512
+ else
513
+ string
514
+ end
515
+ end
516
+
517
+ # A blank line ends the paragraph, and markers around it would
518
+ # stay visible as text, so every paragraph of the content gets
519
+ # its own markers (see {Paragraphs}). A nested pair at the start
520
+ # of the content stands at the start of the first paragraph, one
521
+ # at the end at the end of the last. The escaper escapes a `*` in
522
+ # text, so a `*` at the other edges of a paragraph comes from a
523
+ # nested pair that was split there. A `~` in text is not escaped,
524
+ # and a nested strikethrough brings its marker into the content
525
+ # anyway. A nested pair that spans the content was split the same
526
+ # way and spans every paragraph. +nested+ says whether a nested
527
+ # pair stands at the start, at the end, and whether that is one
528
+ # pair spanning the content.
529
+ def delimiter_piece(name, content, html_mode, nested)
530
+ return content if nothing_to_wrap?(content)
531
+
532
+ delimiter = DELIMITERS.fetch(name)
533
+ _, _, html_open, html_close, char = delimiter
534
+ return "#{html_open}#{content}#{html_close}" if html_mode
535
+
536
+ pair_start, pair_end, spanning = nested
537
+ split_pair = char == ASTERISK
538
+ Paragraphs.map(content) do |paragraph, first, last|
539
+ paragraph_piece(
540
+ delimiter,
541
+ paragraph,
542
+ first ? pair_start : split_pair,
543
+ last ? pair_end : split_pair,
544
+ spanning,
545
+ )
546
+ end
547
+ end
548
+
549
+ # Content with nothing visible in it keeps no markers. Whitespace
550
+ # at the edges stays outside the markers and outside the HTML
551
+ # tags: a marker next to whitespace does not open or close, and
552
+ # an HTML tag alone on its line opens an HTML block. A BBCode tag
553
+ # keeps the whitespace inside, Discourse has no such rule.
554
+ # Content that would break the marker gets the HTML tags.
555
+ # +pair_start+, +pair_end+ and +spanning+ say whether a nested
556
+ # pair stands at the start, at the end, and whether that is one
557
+ # pair spanning the content.
558
+ def paragraph_piece(delimiter, content, pair_start, pair_end, spanning)
559
+ open, close, html_open, html_close, char = delimiter
560
+ return content if nothing_to_wrap?(content)
561
+
562
+ if char && edge_whitespace?(content)
563
+ match = content.match(EDGES)
564
+ lead = match[1]
565
+ core = match[2]
566
+ trail = match[3]
567
+ else
568
+ lead = trail = ""
569
+ core = content
570
+ end
571
+
572
+ if char && breaks_marker?(core, delimiter, pair_start, pair_end, spanning)
573
+ "#{lead}#{html_open}#{core}#{html_close}#{trail}"
574
+ else
575
+ core = escape_colon(core) if open.start_with?("[")
576
+ "#{lead}#{open}#{keep_backslash(core)}#{close}#{trail}"
577
+ end
578
+ end
579
+
580
+ # The marker in the content, or its character at an edge where
581
+ # it would join the marker into a longer run. For emphasis the
582
+ # character may come from a nested pair at that edge, and one
583
+ # pair may span the content (`***a* b**` and `***a***` are
584
+ # strong), but two pairs at the two edges are not
585
+ # (`***a*<!---->*.***` does not cook as strong). For
586
+ # strikethrough any tilde at an edge breaks the marker: a nested
587
+ # strikethrough brings the `~~` marker itself into the content.
588
+ # An escaped character at the end does not count: `\\*` is text.
589
+ def breaks_marker?(core, delimiter, pair_start, pair_end, spanning)
590
+ marker, _, _, _, char = delimiter
591
+ return true if core.include?(marker)
592
+
593
+ at_start = core.start_with?(char)
594
+ at_end = core.end_with?(char) && !Joins.escaped_end?(core)
595
+ (at_start && !pair_start) || (at_end && !pair_end) || (at_start && at_end && !spanning)
596
+ end
597
+
598
+ # A BBCode tag at the start of a line with a `:` right after it
599
+ # reads as a link reference definition (`[u]:x`), the backslash
600
+ # keeps the colon text.
601
+ def escape_colon(core)
602
+ core.start_with?(":") ? "\\#{core}" : core
603
+ end
604
+
605
+ # An odd run of backslashes at the end of the content would
606
+ # escape the closing marker, one more backslash keeps the run
607
+ # even.
608
+ def keep_backslash(core)
609
+ core.end_with?("\\") && !Joins.escaped_end?(core) ? "#{core}\\" : core
610
+ end
611
+
612
+ # Content without whitespace at its edges, the common case, takes
613
+ # the plain interpolation.
614
+ def place_markers(content, open, close)
615
+ return "#{open}#{keep_backslash(content)}#{close}" unless edge_whitespace?(content)
616
+
617
+ match = content.match(EDGES)
618
+ "#{match[1]}#{open}#{keep_backslash(match[2])}#{close}#{match[3]}"
619
+ end
620
+
621
+ def link_piece(destination, content, html_mode)
622
+ return html_link(destination, content) if html_mode
623
+ return content unless Joins.visible?(content)
624
+
625
+ place_markers(content, "[", "](#{LinkDestination.escape(destination)})")
626
+ end
627
+
628
+ def html_link(destination, content)
629
+ %(<a href="#{@html_escaper.escape(destination)}">#{content}</a>)
630
+ end
631
+
632
+ # A bare URL is linked by the Markdown parser on its own only when
633
+ # it has a scheme linkify knows, whitespace (or the start of the
634
+ # line) stands in front of it and nothing sticks to its end.
635
+ # Otherwise it is written as a Markdown link with the URL as its
636
+ # text, which links everywhere, unless the href is no URL and no
637
+ # path (see PATH_LIKE). The label is the href escaped for a link
638
+ # label: a raw `]` in it would end the label early.
639
+ def bare_url_piece(href, buffer, following)
640
+ if AUTOLINKABLE.match?(href)
641
+ return href unless glued_before?(buffer) || glued_after?(following, href)
642
+ elsif !PATH_LIKE.match?(href)
643
+ return href
644
+ end
645
+
646
+ "[#{@escaper.escape(href, in_link_label: true)}](#{LinkDestination.escape(href)})"
647
+ end
648
+
649
+ # Whether the buffer ends in something other than whitespace, an
650
+ # opening parenthesis or a <br>. markdown-it reads the <br> as an
651
+ # HTML tag of its own, so linkify sees the URL at the start of the
652
+ # text after it.
653
+ def glued_before?(buffer)
654
+ tail = after_placeholder?(buffer) ? @like : buffer
655
+ last = tail[-1]
656
+ !last.nil? && !last.match?(NOT_GLUED_BEFORE) && !tail.end_with?(BR)
657
+ end
658
+
659
+ # Another bare URL right after this one is written as a link
660
+ # itself and glues like any text.
661
+ def glued_after?(following, href)
662
+ return false if following.nil?
663
+ return true unless following.instance_of?(String)
664
+
665
+ following.match?(href.match?(PARENTHESIS) ? GLUED_AFTER : GLUED_AFTER_PAREN_OK)
666
+ end
667
+
668
+ # The delimiter is one backtick longer than the longest backtick
669
+ # run in the content. A space on each side keeps a backtick at
670
+ # the edge of the content away from the delimiter. Content that
671
+ # starts and ends with a space gets the same padding, because
672
+ # CommonMark strips one space from each end of such a span.
673
+ def code_piece(content, html_mode)
674
+ return "" if content.empty?
675
+ return "<code>#{@html_escaper.escape(content)}</code>" if html_mode
676
+
677
+ delimiter = "`" * (longest_run(content) + 1)
678
+ padding = " " if needs_padding?(content)
679
+ "#{delimiter}#{padding}#{content}#{padding}#{delimiter}"
680
+ end
681
+
682
+ def longest_run(content)
683
+ content.scan(BACKTICKS).map(&:length).max || 0
684
+ end
685
+
686
+ def needs_padding?(content)
687
+ return true if content.start_with?("`") || content.end_with?("`")
688
+
689
+ # A span of spaces only is not stripped by CommonMark, so it
690
+ # needs no padding.
691
+ content.start_with?(" ") && content.end_with?(" ") &&
692
+ content.count(" ") < content.length
693
+ end
694
+
695
+ # `![alt|WIDTHxHEIGHT](src)`, `![alt|WIDTH](src)` or `![alt](src)`.
696
+ # A height alone means nothing to Discourse.
697
+ def image_piece(token, html_mode)
698
+ _, src, alt, width, height = token
699
+ alt = flat_alt(alt)
700
+ return image_html(src, alt, width, height) if html_mode
701
+
702
+ size = ("#{width}x#{height}" if width && height) || width
703
+ label = @escaper.escape(alt, in_link_label: true)
704
+ "![#{label}#{"|" if ends_in_option?(alt)}#{"|#{size}" if size}](#{LinkDestination.escape(src)})"
705
+ end
706
+
707
+ # A blank line in the alt text would end the paragraph, and with
708
+ # it the image. The line breaks go, the words stay apart.
709
+ def flat_alt(alt)
710
+ alt&.gsub(ALT_LINE_BREAK, " ")
711
+ end
712
+
713
+ # Discourse walks the `|` parts of the alt text from the right and
714
+ # takes every part that looks like an option as one, up to the
715
+ # first part that does not. A backslash does not stop that, the
716
+ # alt text is unescaped first. When the alt text itself ends in
717
+ # such a part, an empty part after it stops the walk there: the
718
+ # options we write still apply, the alt text keeps its last part
719
+ # and gets a `|` at its end.
720
+ def ends_in_option?(alt)
721
+ index = alt&.rindex("|")
722
+ return false if index.nil?
723
+
724
+ last = alt[(index + 1)..]
725
+ DISCOURSE_OPTION.match?(last) || DATA_ATTRIBUTE.match?(last)
726
+ end
727
+
728
+ def image_html(src, alt, width, height)
729
+ attrs = %(src="#{@html_escaper.escape(src)}" alt="#{@html_escaper.escape(alt)}")
730
+ attrs << %( width="#{width}") if width
731
+ attrs << %( height="#{height}") if height
732
+ "<img #{attrs}>"
733
+ end
734
+ end
735
+ end
736
+ end
737
+ end
738
+ end