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
|
@@ -10,10 +10,13 @@ module Markbridge
|
|
|
10
10
|
# ZWNBSP/BOM). Deliberately excludes ASCII space and tab so
|
|
11
11
|
# Markdown's "two trailing spaces = hard line break" rule
|
|
12
12
|
# still works. Off by default.
|
|
13
|
-
# 2.
|
|
14
|
-
# 3.
|
|
13
|
+
# 2. clears whitespace-only lines,
|
|
14
|
+
# 3. collapses runs of 3+ newlines down to two,
|
|
15
15
|
# 4. trims leading/trailing whitespace from the whole document.
|
|
16
16
|
#
|
|
17
|
+
# Steps 1 to 3 leave fenced code blocks alone: inside a fence every
|
|
18
|
+
# line is code, and a blank line or a line of spaces is part of it.
|
|
19
|
+
#
|
|
17
20
|
# Subclass to customize. The +call+ method is the entry point.
|
|
18
21
|
class Postprocessor
|
|
19
22
|
# NBSP plus zero-width format chars. Spelled with explicit
|
|
@@ -29,6 +32,22 @@ module Markbridge
|
|
|
29
32
|
# U+FEFF ZWNBSP/BOM zero-width no-break space / byte-order mark
|
|
30
33
|
TRAILING_INVISIBLE_RE = /[\u{00A0 200B 200C 200D 2060 FEFF}]+$/
|
|
31
34
|
|
|
35
|
+
# A run that may open or close a fence. Found with a plain search
|
|
36
|
+
# over the whole text; the line around it is then checked with the
|
|
37
|
+
# anchored patterns below. An anchored pattern alone is tried at
|
|
38
|
+
# every byte of the text and costs many times more.
|
|
39
|
+
BACKTICK_FENCE = "```"
|
|
40
|
+
TILDE_FENCE = "~~~"
|
|
41
|
+
# A line that opens a fence: optional indentation and list markers
|
|
42
|
+
# (a fence inside a list item is indented, and one that starts an
|
|
43
|
+
# item follows the marker on the same line) and a run of at least
|
|
44
|
+
# three backticks or tildes.
|
|
45
|
+
OPENING_LINE = /\A[ \t]*(?:(?:[-+*]|\d+[.)])[ \t]+)*(`{3,}|~{3,})/
|
|
46
|
+
# A line that closes a fence: a run of backticks or tildes alone,
|
|
47
|
+
# whitespace around it allowed.
|
|
48
|
+
CLOSING_LINE = /\A[ \t]*(`{3,}|~{3,})[ \t]*\z/
|
|
49
|
+
private_constant :BACKTICK_FENCE, :TILDE_FENCE, :OPENING_LINE, :CLOSING_LINE
|
|
50
|
+
|
|
32
51
|
# @param strip_trailing_invisibles [Boolean] when true, strips
|
|
33
52
|
# trailing invisible characters (NBSP and zero-width format
|
|
34
53
|
# chars) from each line before the standard cleanup pass.
|
|
@@ -39,11 +58,168 @@ module Markbridge
|
|
|
39
58
|
# @param text [String]
|
|
40
59
|
# @return [String]
|
|
41
60
|
def call(text)
|
|
42
|
-
text
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
clean_document(text).strip # Trim leading/trailing whitespace
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Text without a fence run takes the plain path; the fence scan gives
|
|
67
|
+
# the same result for it, only slower. The escaper writes a backslash
|
|
68
|
+
# in front of every backtick and tilde in text, so three in a row
|
|
69
|
+
# only occur in a fence or in a code span with a long delimiter.
|
|
70
|
+
def clean_document(text)
|
|
71
|
+
if text.include?(BACKTICK_FENCE) || text.include?(TILDE_FENCE)
|
|
72
|
+
clean_around_fences(text)
|
|
73
|
+
else
|
|
74
|
+
clean(text)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Three or more newlines in a row; the gsub brings them down to two.
|
|
79
|
+
NEWLINE_RUN = /\n{3,}/
|
|
80
|
+
# The shortest run the gsub replaces, as a string for include?.
|
|
81
|
+
NEWLINE_RUN_START = "\n\n\n"
|
|
82
|
+
# A line with nothing but spaces and tabs on it.
|
|
83
|
+
WHITESPACE_LINE = /^[ \t]+$/
|
|
84
|
+
SPACE = 32
|
|
85
|
+
TAB = 9
|
|
86
|
+
NEWLINE = 10
|
|
87
|
+
private_constant :NEWLINE_RUN, :NEWLINE_RUN_START, :WHITESPACE_LINE, :SPACE, :TAB, :NEWLINE
|
|
88
|
+
|
|
89
|
+
# The two checks in front of the gsubs save the copy that gsub makes
|
|
90
|
+
# also when nothing matches. Most text has neither three newlines
|
|
91
|
+
# in a row nor a whitespace-only line.
|
|
92
|
+
def clean(prose)
|
|
93
|
+
prose = prose.gsub(TRAILING_INVISIBLE_RE, "") if @strip_trailing_invisibles
|
|
94
|
+
# Whitespace-only lines are cleared first, so the blank lines
|
|
95
|
+
# they leave are part of the newline run that is collapsed next.
|
|
96
|
+
prose = prose.gsub(WHITESPACE_LINE, "") if whitespace_line?(prose)
|
|
97
|
+
prose = prose.gsub(NEWLINE_RUN, "\n\n") if prose.include?(NEWLINE_RUN_START)
|
|
98
|
+
prose
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Whether +clean+ would change anything in +document+. The segments
|
|
102
|
+
# the fence scan hands to +clean+ each start at the beginning of a
|
|
103
|
+
# line and end at the end of one, so a line of a segment is a line
|
|
104
|
+
# of the document at the same place. A document that holds nothing
|
|
105
|
+
# for +clean+ therefore holds nothing for any of its segments.
|
|
106
|
+
#
|
|
107
|
+
# An instance that strips invisibles says yes without looking at the
|
|
108
|
+
# text: that pattern costs a scan of its own and the option is off
|
|
109
|
+
# by default.
|
|
110
|
+
# @param document [String]
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def needs_cleaning?(document)
|
|
113
|
+
@strip_trailing_invisibles || document.include?(NEWLINE_RUN_START) ||
|
|
114
|
+
document.match?(WHITESPACE_LINE)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Cleans the text between fenced code blocks and copies the blocks
|
|
118
|
+
# as they are. Works with byte offsets and searches over the whole
|
|
119
|
+
# text, so the lines inside a block cost nothing each. A run of
|
|
120
|
+
# newlines in front of a fence is part of the text before it and
|
|
121
|
+
# is collapsed there; the newline that ends a closing fence belongs
|
|
122
|
+
# to the text after the block. Text with nothing to clean is handed
|
|
123
|
+
# back as it is — the scan would put the same bytes into a new
|
|
124
|
+
# string, and a document whose fences are the only reason it comes
|
|
125
|
+
# here is the common one.
|
|
126
|
+
# Whether a line of +prose+ holds nothing but spaces and tabs. A
|
|
127
|
+
# walk over the line starts; the regex would scan the whole text,
|
|
128
|
+
# and scan it once more for its encoding first.
|
|
129
|
+
def whitespace_line?(prose)
|
|
130
|
+
start = 0
|
|
131
|
+
while start
|
|
132
|
+
index = start
|
|
133
|
+
index += 1 while (byte = prose.getbyte(index)) == SPACE || byte == TAB
|
|
134
|
+
return true if index > start && (byte.nil? || byte == NEWLINE)
|
|
135
|
+
|
|
136
|
+
newline = prose.byteindex("\n", index)
|
|
137
|
+
start = newline && newline + 1
|
|
138
|
+
end
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def clean_around_fences(text)
|
|
143
|
+
return text unless needs_cleaning?(text)
|
|
144
|
+
|
|
145
|
+
result = +""
|
|
146
|
+
prose_start = 0
|
|
147
|
+
search_from = 0
|
|
148
|
+
|
|
149
|
+
while (candidate = fence_index(text, from: search_from))
|
|
150
|
+
line_start = (text.byterindex("\n", candidate) || -1) + 1
|
|
151
|
+
line_end = text.byteindex("\n", candidate) || text.bytesize
|
|
152
|
+
run = opening_run(text.byteslice(line_start, line_end - line_start))
|
|
153
|
+
|
|
154
|
+
# A fence starts a line, so a run elsewhere on it opens none.
|
|
155
|
+
# Keep looking on the next line.
|
|
156
|
+
unless run
|
|
157
|
+
search_from = line_end
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
block_end = closing_fence_end(text, from: line_end, run:)
|
|
162
|
+
result << clean(text.byteslice(prose_start, line_start - prose_start))
|
|
163
|
+
result << text.byteslice(line_start, block_end - line_start)
|
|
164
|
+
prose_start = search_from = block_end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
result << clean(text.byteslice(prose_start, text.bytesize - prose_start))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The offset of the next fence run of either kind at or after
|
|
171
|
+
# +from+, else nil. Two string searches beat one regex with an
|
|
172
|
+
# alternation: a string search runs as a byte search, the regex
|
|
173
|
+
# walks the text with the engine.
|
|
174
|
+
# @param text [String]
|
|
175
|
+
# @param from [Integer] byte offset to start at
|
|
176
|
+
# @return [Integer, nil]
|
|
177
|
+
def fence_index(text, from:)
|
|
178
|
+
backtick = text.byteindex(BACKTICK_FENCE, from)
|
|
179
|
+
tilde = text.byteindex(TILDE_FENCE, from)
|
|
180
|
+
return tilde unless backtick
|
|
181
|
+
return backtick unless tilde
|
|
182
|
+
|
|
183
|
+
backtick < tilde ? backtick : tilde
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The run when +line+ opens a fenced code block, else nil. A
|
|
187
|
+
# backtick fence cannot have a backtick after its run (CommonMark
|
|
188
|
+
# 4.5), which keeps an inline code span from opening a fence.
|
|
189
|
+
# @param line [String]
|
|
190
|
+
# @return [String, nil]
|
|
191
|
+
def opening_run(line)
|
|
192
|
+
match = OPENING_LINE.match(line)
|
|
193
|
+
return nil unless match
|
|
194
|
+
|
|
195
|
+
run = match[1]
|
|
196
|
+
run unless run.include?("`") && match.post_match.include?("`")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The byte offset just after the closing fence of the block opened
|
|
200
|
+
# by +run+, searching from +from+; the end of the text when the
|
|
201
|
+
# block never closes. A closing fence is a run of the same
|
|
202
|
+
# character, at least as long as the opening run, alone on its
|
|
203
|
+
# line. Every such line contains +run+ itself, so a string search
|
|
204
|
+
# finds the candidates and the pattern checks the whole line.
|
|
205
|
+
# @param text [String]
|
|
206
|
+
# @param from [Integer] byte offset to search from
|
|
207
|
+
# @param run [String] the opening run
|
|
208
|
+
# @return [Integer]
|
|
209
|
+
def closing_fence_end(text, from:, run:)
|
|
210
|
+
while (candidate = text.byteindex(run, from))
|
|
211
|
+
# +from+ starts at the newline that ends the opening line, so
|
|
212
|
+
# there is always a newline in front of a candidate.
|
|
213
|
+
line_start = text.byterindex("\n", candidate) + 1
|
|
214
|
+
line_end = text.byteindex("\n", candidate) || text.bytesize
|
|
215
|
+
if CLOSING_LINE.match?(text.byteslice(line_start, line_end - line_start))
|
|
216
|
+
return line_end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
from = line_end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
text.bytesize
|
|
47
223
|
end
|
|
48
224
|
|
|
49
225
|
DEFAULT = new
|
|
@@ -20,24 +20,36 @@ module Markbridge
|
|
|
20
20
|
# @return [RenderContext, nil] the enclosing context (nil at root)
|
|
21
21
|
attr_reader :parent_context
|
|
22
22
|
|
|
23
|
+
# @return [AST::Element, nil] the element whose children are
|
|
24
|
+
# rendered at the root, when it has no tag of its own (the
|
|
25
|
+
# Document, as a rule). It is not on the parent chain, so the
|
|
26
|
+
# chain walks stay short; {RenderingInterface#previous_sibling}
|
|
27
|
+
# and {RenderingInterface#next_sibling} fall back to it.
|
|
28
|
+
attr_reader :root
|
|
29
|
+
|
|
23
30
|
# @param parents [Array<AST::Element>] parent elements in document
|
|
24
31
|
# order (outermost first); convenience form for building a
|
|
25
32
|
# context from scratch. Ignored when +element:+ is given.
|
|
26
33
|
# @param html_mode [Boolean] see {#html_mode?}
|
|
27
|
-
# @param parent [RenderContext, nil] enclosing context (chain form)
|
|
34
|
+
# @param parent [RenderContext, nil] enclosing context (chain form);
|
|
35
|
+
# nil gives the element an empty root context as its parent
|
|
28
36
|
# @param element [AST::Element, nil] nearest parent element (chain form)
|
|
29
|
-
|
|
37
|
+
# @param root [AST::Element, nil] see {#root}
|
|
38
|
+
def initialize(parents = [], html_mode: false, parent: nil, element: nil, root: nil)
|
|
30
39
|
@html_mode = html_mode
|
|
40
|
+
@root = root
|
|
31
41
|
if element
|
|
32
|
-
|
|
42
|
+
# Without a parent the chain still ends in a root context, the
|
|
43
|
+
# walks in Renderer#render_text rely on that.
|
|
44
|
+
@parent_context = parent || self.class.new(html_mode:, root:)
|
|
33
45
|
@element = element
|
|
34
|
-
@depth =
|
|
46
|
+
@depth = @parent_context.depth + 1
|
|
35
47
|
elsif parents.empty?
|
|
36
48
|
# Root context: @element and @parent_context stay unset and
|
|
37
49
|
# read as nil.
|
|
38
50
|
@depth = 0
|
|
39
51
|
else
|
|
40
|
-
@parent_context = self.class.new(parents[0, parents.size - 1], html_mode:)
|
|
52
|
+
@parent_context = self.class.new(parents[0, parents.size - 1], html_mode:, root:)
|
|
41
53
|
@element = parents.last
|
|
42
54
|
@depth = parents.size
|
|
43
55
|
end
|
|
@@ -51,7 +63,7 @@ module Markbridge
|
|
|
51
63
|
def parents
|
|
52
64
|
result = []
|
|
53
65
|
context = self
|
|
54
|
-
while
|
|
66
|
+
while (parent = context.element)
|
|
55
67
|
result << parent
|
|
56
68
|
context = context.parent_context
|
|
57
69
|
end
|
|
@@ -63,14 +75,26 @@ module Markbridge
|
|
|
63
75
|
# @param element [AST::Element]
|
|
64
76
|
# @return [RenderContext]
|
|
65
77
|
def with_parent(element)
|
|
66
|
-
self.class.new(html_mode: @html_mode, parent: self, element:)
|
|
78
|
+
self.class.new(html_mode: @html_mode, parent: self, element:, root: @root)
|
|
67
79
|
end
|
|
68
80
|
|
|
69
81
|
# Create new context with html_mode toggled.
|
|
70
82
|
# @param value [Boolean]
|
|
71
83
|
# @return [RenderContext]
|
|
72
84
|
def with_html_mode(value)
|
|
73
|
-
self.class.new(html_mode: value, parent: @parent_context, element: @element)
|
|
85
|
+
self.class.new(html_mode: value, parent: @parent_context, element: @element, root: @root)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Create new context with the root element set, see {#root}.
|
|
89
|
+
# @param element [AST::Element]
|
|
90
|
+
# @return [RenderContext]
|
|
91
|
+
def with_root(element)
|
|
92
|
+
self.class.new(
|
|
93
|
+
html_mode: @html_mode,
|
|
94
|
+
parent: @parent_context,
|
|
95
|
+
element: @element,
|
|
96
|
+
root: element,
|
|
97
|
+
)
|
|
74
98
|
end
|
|
75
99
|
|
|
76
100
|
# @return [Boolean]
|
|
@@ -81,13 +105,15 @@ module Markbridge
|
|
|
81
105
|
# Find closest parent that is_a? klass (handles subclasses).
|
|
82
106
|
# The chain walks are inlined in each query (instead of a shared
|
|
83
107
|
# yielding helper) — these run several times per rendered text
|
|
84
|
-
# node, and a plain while loop avoids the block invocation.
|
|
108
|
+
# node, and a plain while loop avoids the block invocation. Every
|
|
109
|
+
# chain ends in a root context whose element is nil (see
|
|
110
|
+
# #initialize), so the walks stop there without a nil check.
|
|
85
111
|
# @param klass [Class]
|
|
86
112
|
# @return [AST::Element, nil] nil when no parent matches (implicit
|
|
87
113
|
# from the exhausted while loop)
|
|
88
114
|
def find_parent(klass)
|
|
89
115
|
context = self
|
|
90
|
-
while
|
|
116
|
+
while (parent = context.element)
|
|
91
117
|
return parent if parent.is_a?(klass)
|
|
92
118
|
context = context.parent_context
|
|
93
119
|
end
|
|
@@ -99,7 +125,7 @@ module Markbridge
|
|
|
99
125
|
def count_parents(klass)
|
|
100
126
|
count = 0
|
|
101
127
|
context = self
|
|
102
|
-
while
|
|
128
|
+
while (parent = context.element)
|
|
103
129
|
count += 1 if parent.is_a?(klass)
|
|
104
130
|
context = context.parent_context
|
|
105
131
|
end
|
|
@@ -111,7 +137,7 @@ module Markbridge
|
|
|
111
137
|
# @return [Boolean]
|
|
112
138
|
def has_parent?(klass)
|
|
113
139
|
context = self
|
|
114
|
-
while
|
|
140
|
+
while (parent = context.element)
|
|
115
141
|
return true if parent.is_a?(klass)
|
|
116
142
|
context = context.parent_context
|
|
117
143
|
end
|