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.
- 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/handler_registry.rb +6 -1
- 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/html/parser.rb +57 -19
- 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 +43 -26
- 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,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
|
-
|
|
92
|
-
|
|
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<(?:[?!]
|
|
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
|
-
|
|
229
|
-
|
|
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, "\\_")
|
|
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, "\\=")
|
|
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
|
|
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
|
|
342
|
+
when DIGITS
|
|
325
343
|
return escape_block_ordered_list(content)
|
|
326
344
|
end
|
|
327
345
|
|
|
328
|
-
|
|
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
|
-
|
|
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, "\\-")
|
|
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
|
-
|
|
370
|
+
content
|
|
343
371
|
end
|
|
344
372
|
|
|
345
373
|
def escape_block_star(content)
|
|
346
|
-
return escape_all_chars(content, STAR, "\\*")
|
|
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
|
-
|
|
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)}"
|
|
387
|
+
return "#{match[1]}\\#{match[2]}#{escape_inline(rest)}"
|
|
360
388
|
end
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
400
|
-
#
|
|
401
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
#
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|