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
@@ -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. collapses runs of 3+ newlines down to two,
14
- # 3. clears whitespace-only lines,
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 = text.gsub(TRAILING_INVISIBLE_RE, "") if @strip_trailing_invisibles
43
- text
44
- .gsub(/\n{3,}/, "\n\n") # Max 2 consecutive newlines
45
- .gsub(/^[ \t]+$/, "") # Remove whitespace-only lines
46
- .strip # Trim leading/trailing whitespace
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
- def initialize(parents = [], html_mode: false, parent: nil, element: nil)
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
- @parent_context = parent
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 = (parent ? parent.depth : 0) + 1
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 context && (parent = context.element)
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 context && (parent = context.element)
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 context && (parent = context.element)
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 context && (parent = context.element)
140
+ while (parent = context.element)
115
141
  return true if parent.is_a?(klass)
116
142
  context = context.parent_context
117
143
  end