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.
- checksums.yaml +4 -4
- data/lib/markbridge/ast/image.rb +9 -1
- data/lib/markbridge/ast/url.rb +29 -4
- data/lib/markbridge/errors.rb +9 -0
- data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +2 -1
- data/lib/markbridge/parsers/bbcode/handlers/url_handler.rb +26 -2
- data/lib/markbridge/parsers/html/handlers/image_handler.rb +4 -1
- data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +3 -2
- data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +4 -2
- data/lib/markbridge/parsers/text_formatter/parser.rb +107 -14
- data/lib/markbridge/renderers/discourse/html_block.rb +50 -7
- data/lib/markbridge/renderers/discourse/inline/joins.rb +382 -0
- data/lib/markbridge/renderers/discourse/inline/serializer.rb +738 -0
- data/lib/markbridge/renderers/discourse/inline/tokens.rb +182 -0
- data/lib/markbridge/renderers/discourse/link_destination.rb +43 -0
- data/lib/markbridge/renderers/discourse/markdown_escaper.rb +105 -45
- data/lib/markbridge/renderers/discourse/paragraphs.rb +88 -0
- data/lib/markbridge/renderers/discourse/postprocessor.rb +183 -7
- data/lib/markbridge/renderers/discourse/render_context.rb +38 -12
- data/lib/markbridge/renderers/discourse/renderer.rb +336 -92
- data/lib/markbridge/renderers/discourse/rendering_interface.rb +123 -44
- data/lib/markbridge/renderers/discourse/tag.rb +53 -9
- data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +4 -6
- data/lib/markbridge/renderers/discourse/tags/code_tag.rb +48 -10
- data/lib/markbridge/renderers/discourse/tags/color_tag.rb +73 -13
- data/lib/markbridge/renderers/discourse/tags/details_tag.rb +62 -15
- data/lib/markbridge/renderers/discourse/tags/email_tag.rb +8 -9
- data/lib/markbridge/renderers/discourse/tags/event_tag.rb +31 -9
- data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +20 -1
- data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +12 -1
- data/lib/markbridge/renderers/discourse/tags/image_tag.rb +13 -27
- data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +4 -6
- data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +9 -2
- data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +75 -9
- data/lib/markbridge/renderers/discourse/tags/list_tag.rb +29 -7
- data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +12 -0
- data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +13 -6
- data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +52 -9
- data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +26 -1
- data/lib/markbridge/renderers/discourse/tags/size_tag.rb +78 -13
- data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +72 -14
- data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +5 -6
- data/lib/markbridge/renderers/discourse/tags/subscript_tag.rb +11 -5
- data/lib/markbridge/renderers/discourse/tags/superscript_tag.rb +11 -5
- data/lib/markbridge/renderers/discourse/tags/table_tag.rb +48 -28
- data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +3 -10
- data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +90 -43
- data/lib/markbridge/renderers/discourse/tags/url_tag.rb +41 -31
- data/lib/markbridge/renderers/discourse.rb +6 -0
- data/lib/markbridge/rspec.rb +4 -2
- data/lib/markbridge/version.rb +1 -1
- data/lib/markbridge.rb +90 -1
- 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
|
+
# ``, `` or ``.
|
|
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
|
+
"})"
|
|
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
|