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
@@ -5,6 +5,8 @@ module Markbridge
5
5
  module Discourse
6
6
  # Renders AST to Discourse-flavored Markdown in-memory.
7
7
  class Renderer
8
+ # @return [TagLibrary] the library the renderer renders with
9
+ attr_reader :tag_library
8
10
  attr_reader :postprocessor
9
11
 
10
12
  def initialize(tag_library: nil, escaper: nil, html_escaper: nil, postprocessor: nil)
@@ -12,49 +14,30 @@ module Markbridge
12
14
  @escaper = escaper || MarkdownEscaper.new
13
15
  @html_escaper = html_escaper || HtmlEscaper
14
16
  @postprocessor = postprocessor || Postprocessor::DEFAULT
15
- # @interface_cache, @resolved_tags, and @resolved_default_tags
16
- # are lazily initialized during a top-level #render /
17
- # #render_default call and reset to nil after the call completes.
17
+ @serializer = Inline::Serializer.new(escaper: @escaper, html_escaper: @html_escaper)
18
+ # @interface_cache and @resolved_tags are set up by a top-level
19
+ # call and reset when it completes (see #reset_caches);
20
+ # @resolved_default_tags stays for the life of the renderer.
18
21
  end
19
22
 
20
- # Render a node to Markdown
23
+ # Renders a node to Markdown.
24
+ #
21
25
  # @param node [AST::Node]
22
26
  # @param context [RenderContext] rendering context with parent chain
23
27
  # @return [String]
24
28
  # @raise [TypeError] when the tag bound to the node's class returns
25
- # something other than a String (a nil from a custom tag would
26
- # otherwise surface as an inscrutable concatenation error deep
27
- # inside render_children)
29
+ # something other than a String or inline tokens (a nil from a
30
+ # custom tag would otherwise surface as an inscrutable
31
+ # concatenation error deep inside render_children)
28
32
  def render(node, context: RenderContext.new)
29
- root_call = @interface_cache.nil?
30
- @interface_cache = {} if root_call
31
-
32
- # Exact-class hit first (a single Hash lookup, the common case),
33
- # then the memoized ancestry fallback so a subclass without its
34
- # own tag renders through its nearest ancestor's tag.
35
- tag = @tag_library[node.class] || resolved_tag(node.class)
36
- if tag
37
- result = tag.render(node, interface_for(context))
38
- unless result.is_a?(String)
39
- raise TypeError,
40
- "#{tag.class} rendered #{node.class} to " \
41
- "#{result.inspect} — tags must return a String " \
42
- "(use interface.render_default(node) to fall back " \
43
- "to the stock rendering)"
44
- end
45
- return result
46
- end
33
+ root_call = enter
47
34
 
48
- render_without_tag(node, context)
35
+ serialize(render_output(node, context), context)
49
36
  ensure
50
- if root_call
51
- @interface_cache = nil
52
- @resolved_tags = nil
53
- @resolved_default_tags = nil
54
- end
37
+ reset_caches if root_call
55
38
  end
56
39
 
57
- # Render a node with the stock tag for its class, ignoring any
40
+ # Renders a node with the stock tag for its class, ignoring any
58
41
  # override registered in this renderer's tag library. Lets a
59
42
  # custom tag intercept only the nodes it cares about and delegate
60
43
  # the rest:
@@ -71,30 +54,93 @@ module Markbridge
71
54
  # @param context [RenderContext] rendering context with parent chain
72
55
  # @return [String]
73
56
  def render_default(node, context: RenderContext.new)
74
- root_call = @interface_cache.nil?
75
- @interface_cache = {} if root_call
57
+ root_call = enter
76
58
 
77
59
  tag = default_tag_library[node.class] || resolved_default_tag(node.class)
78
- return tag.render(node, interface_for(context)) if tag
79
-
80
- render_without_tag(node, context)
60
+ output =
61
+ tag ? tag.render(node, interface_for(context)) : render_without_tag(node, context)
62
+ serialize(output, context)
81
63
  ensure
82
- if root_call
83
- @interface_cache = nil
84
- @resolved_tags = nil
85
- @resolved_default_tags = nil
64
+ reset_caches if root_call
65
+ end
66
+
67
+ # Writes inline tokens as Markdown, or as HTML in html_mode. A
68
+ # String is returned as it is.
69
+ #
70
+ # @param output [String, Array<Array>] what a tag returned
71
+ # @param context [RenderContext]
72
+ # @return [String]
73
+ def serialize(output, context)
74
+ if output.instance_of?(String)
75
+ output
76
+ else
77
+ @serializer.write(output, html_mode: context.html_mode?)
86
78
  end
87
79
  end
88
80
 
89
- # Render all children of a node
81
+ # The output of a node as its tag returns it: finished Markdown,
82
+ # or inline tokens (see Inline::Tokens) that the serializer writes
83
+ # together with the output of the node's siblings.
84
+ #
85
+ # @param node [AST::Node]
86
+ # @param context [RenderContext]
87
+ # @return [String, Array<Array>]
88
+ def render_output(node, context)
89
+ # Exact-class hit first (a single Hash lookup, the common case),
90
+ # then the memoized ancestry fallback so a subclass without its
91
+ # own tag renders through its nearest ancestor's tag.
92
+ tag = @tag_library[node.class] || resolved_tag(node.class)
93
+ return render_without_tag(node, context) unless tag
94
+
95
+ output = tag.render(node, interface_for(context))
96
+ unless output.instance_of?(String) || output.instance_of?(Array)
97
+ raise TypeError,
98
+ "#{tag.class} rendered #{node.class} to #{output.inspect} — tags must return " \
99
+ "a String or inline tokens (use interface.render_default(node) to fall " \
100
+ "back to the stock rendering)"
101
+ end
102
+ output
103
+ end
104
+
105
+ # The output of the children of a node as inline tokens, for a tag
106
+ # that returns tokens itself. Finished Markdown from a child is a
107
+ # token as it is.
108
+ #
109
+ # @param node [AST::Element]
110
+ # @param context [RenderContext]
111
+ # @return [Array<Array>]
112
+ def render_tokens(node, context:)
113
+ root_call = enter
114
+ outer_parent = @child_parent
115
+ outer_index = @child_index
116
+ outer_list = @previous_list
117
+ @child_parent = node
118
+ @previous_list = continued_list(node, context, outer_list)
119
+ tokens = []
120
+ index = -1
121
+ node.children.each do |child|
122
+ @child_index = (index += 1)
123
+ output = render_child(child, context)
124
+ output.instance_of?(String) ? tokens << output : tokens.concat(output)
125
+ end
126
+ @child_parent = outer_parent
127
+ @child_index = outer_index
128
+ @previous_list = outer_list
129
+ tokens
130
+ ensure
131
+ reset_caches if root_call
132
+ end
133
+
134
+ # Renders all children of a node and writes their output one after
135
+ # the other with the Inline::Serializer, which knows what has to
136
+ # happen where two outputs meet (see Inline::Joins).
90
137
  #
91
138
  # With a block, the block runs at every join point, right before
92
139
  # a non-empty child output is appended. It receives the buffer
93
140
  # built so far and the child about to be rendered into it, and
94
141
  # may change the buffer in place. A tag uses this to adjust the
95
142
  # text in front of a specific child without iterating the
96
- # children itself, which would lose the emphasis-boundary rule
97
- # below.
143
+ # children itself, which would lose the join rules.
98
144
  #
99
145
  # @param node [AST::Element]
100
146
  # @param context [RenderContext] rendering context
@@ -105,45 +151,223 @@ module Markbridge
105
151
  # interface.render_children(item, context:) do |buffer, child|
106
152
  # buffer.rstrip! if child.is_a?(AST::List)
107
153
  # end
108
- def render_children(node, context:)
109
- result = +""
110
- node.children.each do |child|
111
- part = render(child, context:)
112
- next if part.empty?
113
-
114
- yield(result, child) if block_given?
115
-
116
- # Integer-byte check avoids allocating substrings for the
117
- # per-child adjacency probe. EMPHASIS_DELIMITER_BYTES.include?
118
- # over a 4-element Set is O(1). On an empty buffer getbyte
119
- # returns nil, which matches no byte of a non-empty part, so
120
- # the first child needs no extra guard.
121
- if (last_byte = result.getbyte(-1)) == part.getbyte(0) &&
122
- EMPHASIS_DELIMITER_BYTES.include?(last_byte)
123
- result << EMPHASIS_BOUNDARY
154
+ def render_children(node, context:, &block)
155
+ root_call = enter
156
+ outer_parent = @child_parent
157
+ outer_index = @child_index
158
+ outer_list = @previous_list
159
+ @child_parent = node
160
+ @previous_list = continued_list(node, context, outer_list)
161
+ index = -1
162
+ result =
163
+ @serializer.write_groups(
164
+ node.children,
165
+ html_mode: context.html_mode?,
166
+ before: block,
167
+ ) do |child|
168
+ @child_index = (index += 1)
169
+ render_child(child, context)
124
170
  end
125
- result << part
126
- end
171
+ @child_parent = outer_parent
172
+ @child_index = outer_index
173
+ @previous_list = outer_list
127
174
  result
175
+ ensure
176
+ reset_caches if root_call
177
+ end
178
+
179
+ # Renders one child of the element whose children are being
180
+ # rendered, and remembers the last list among them for
181
+ # #previous_list: a list, or a child whose output ends with one
182
+ # (Tag#trailing_list). Text after it with something other than
183
+ # whitespace in it keeps two lists apart; a child that renders to
184
+ # nothing does not count.
185
+ def render_child(child, context)
186
+ output = render_output(child, context)
187
+ list = child.is_a?(AST::Text) ? nil : trailing_list(child, context)
188
+ if list
189
+ @previous_list = list unless output.empty?
190
+ elsif @previous_list && visible?(output)
191
+ @previous_list = nil
192
+ end
193
+ output
194
+ end
195
+
196
+ def visible?(output)
197
+ if output.instance_of?(String)
198
+ Inline::Joins.visible?(output)
199
+ else
200
+ Inline::Tokens.visible?(output)
201
+ end
202
+ end
203
+
204
+ # The element whose children #render_children is rendering right
205
+ # now. Unlike the parent chain of the context it also knows an
206
+ # element without a tag of its own, which never goes on the chain.
207
+ # @return [AST::Element, nil]
208
+ attr_reader :child_parent
209
+
210
+ # The position of the child that #render_children is rendering
211
+ # right now, among the children of its parent. A nested call
212
+ # sets it back when it returns, so a tag still finds its own
213
+ # position after it rendered its children. Only a hint for
214
+ # RenderingInterface#sibling, which checks it before use: a node
215
+ # rendered on its own, outside #render_children, sees the
216
+ # position of some other node.
217
+ # @return [Integer, nil]
218
+ attr_reader :child_index
219
+
220
+ # The last list among the siblings in front of +element+, or the
221
+ # sibling whose output ends with a list (Tag#trailing_list), when
222
+ # nothing but whitespace or nodes that rendered to nothing came
223
+ # after it. nil when there is none, or when +element+ is not the
224
+ # child that #render_children is rendering right now.
225
+ # @param element [AST::Node]
226
+ # @return [AST::Node, nil]
227
+ def previous_list(element)
228
+ @previous_list if current_child?(element)
229
+ end
230
+
231
+ # The bullet characters and the ordered delimiters the lists are
232
+ # written with, in the order they are tried.
233
+ BULLET_MARKERS = ["- ", "* "].freeze
234
+ ORDERED_MARKERS = ["1. ", "1) "].freeze
235
+
236
+ # The marker for the items of +node+, a list or a node whose
237
+ # output is a list (a poll without the plugin). Two lists of one
238
+ # kind with the same marker and only whitespace between them cook
239
+ # as one list, a different bullet character or ordered delimiter
240
+ # starts a new list, so the list after #previous_list gets the
241
+ # marker of its kind that list did not use. The marker is
242
+ # remembered for +node+, see #list_marker.
243
+ # @param node [AST::Node] the child #render_children is at;
244
+ # any other node gets the first marker of its kind
245
+ # @param ordered [Boolean]
246
+ # @return [String]
247
+ def choose_list_marker(node, ordered:)
248
+ root_call = enter
249
+ markers = ordered ? ORDERED_MARKERS : BULLET_MARKERS
250
+ # The marker of this kind that the list in front did not use;
251
+ # the first one when none is in front.
252
+ before = @list_markers[previous_list(node)]
253
+ @list_markers[node] = (markers - [before]).first
254
+ ensure
255
+ reset_caches if root_call
256
+ end
257
+
258
+ # The marker #choose_list_marker picked for +node+ during this
259
+ # render call, nil when it picked none. ListItemTag writes it in
260
+ # front of the items, HorizontalRuleTag picks the characters of
261
+ # its rule so they differ from it.
262
+ # @param node [AST::Node]
263
+ # @return [String, nil]
264
+ def list_marker(node)
265
+ @list_markers&.[](node)
266
+ end
267
+
268
+ # Whether +element+ is the child that #render_children is
269
+ # rendering right now. The state above describes that child only.
270
+ # @param element [AST::Node]
271
+ # @return [Boolean]
272
+ def current_child?(element)
273
+ parent = child_parent
274
+ !parent.nil? && parent.children.at(child_index).equal?(element)
275
+ end
276
+
277
+ # The list the output of +node+ starts with, as its tag says (see
278
+ # Tag#leading_list). An element without a tag writes nothing but
279
+ # its children, so its output starts with what the first child
280
+ # with something in it starts with; +children: true+ asks that
281
+ # for an element with a tag, for a tag whose output starts with
282
+ # the children's.
283
+ # @param node [AST::Node]
284
+ # @param context [RenderContext]
285
+ # @param children [Boolean]
286
+ # @return [AST::List, nil]
287
+ def leading_list(node, context, children: false)
288
+ root_call = enter
289
+ unless children
290
+ tag = edge_tag(node)
291
+ return tag.leading_list(node, interface_for(context)) if tag
292
+ end
293
+ first = node.is_a?(AST::Element) && node.children.find { |child| !whitespace?(child) }
294
+ leading_list(first, context) if first
295
+ ensure
296
+ reset_caches if root_call
297
+ end
298
+
299
+ # The list the output of +node+ ends with, see #leading_list.
300
+ # @param node [AST::Node]
301
+ # @param context [RenderContext]
302
+ # @param children [Boolean]
303
+ # @return [AST::List, nil]
304
+ def trailing_list(node, context, children: false)
305
+ root_call = enter
306
+ unless children
307
+ tag = edge_tag(node)
308
+ return tag.trailing_list(node, interface_for(context)) if tag
309
+ end
310
+ last =
311
+ node.is_a?(AST::Element) &&
312
+ node.children.reverse_each.find { |child| !whitespace?(child) }
313
+ trailing_list(last, context) if last
314
+ ensure
315
+ reset_caches if root_call
128
316
  end
129
317
 
130
318
  private
131
319
 
132
- # Inserted between sibling outputs when their adjacent characters
133
- # would merge into a longer Markdown emphasis delimiter run (e.g.
134
- # `***` + `*...` becoming `****...`). The HTML comment is invisible
135
- # in rendered output but breaks the delimiter run during Markdown
136
- # parsing.
137
- EMPHASIS_BOUNDARY = "<!---->"
138
- # Bytes where adjacent runs merge into a single longer run during
139
- # Markdown parsing: emphasis (* _), strikethrough (~), code spans (`).
140
- EMPHASIS_DELIMITER_BYTES = Set[42, 95, 126, 96].freeze
141
- private_constant :EMPHASIS_BOUNDARY, :EMPHASIS_DELIMITER_BYTES
320
+ # Something other than whitespace.
321
+
322
+ # Sets up the caches for a top-level call and says whether this is
323
+ # one: a call from inside a tag finds them in place.
324
+ def enter
325
+ root_call = @interface_cache.nil?
326
+ if root_call
327
+ @interface_cache = {}
328
+ @list_markers = {}.compare_by_identity
329
+ end
330
+ root_call
331
+ end
332
+
333
+ # The caches live for one top-level call: the tag library can
334
+ # change between calls, and an interface is cached by the object
335
+ # id of its context. The default library never changes, so its
336
+ # resolved tags stay.
337
+ def reset_caches
338
+ @interface_cache = nil
339
+ @resolved_tags = nil
340
+ @list_markers = nil
341
+ end
142
342
 
143
343
  def interface_for(context)
144
344
  @interface_cache[context.object_id] ||= RenderingInterface.new(self, context)
145
345
  end
146
346
 
347
+ # The list in front of +node+ that the first list inside +node+
348
+ # continues: the output of +node+ starts with that inner list
349
+ # (Tag#leading_list), so the inner list gets the other marker.
350
+ # nil when +node+ writes something of its own first, or when no
351
+ # list came before it; the question is asked only then, it costs
352
+ # a tag lookup on every element.
353
+ # @param outer_list [AST::Node, nil] the last list in front of
354
+ # +node+, among the children of its parent
355
+ # @return [AST::Node, nil]
356
+ def continued_list(node, context, outer_list)
357
+ outer_list if outer_list && leading_list(node, context)
358
+ end
359
+
360
+ # The tag of +node+ as #render_output finds it.
361
+ def edge_tag(node)
362
+ @tag_library[node.class] || resolved_tag(node.class)
363
+ end
364
+
365
+ # Text with nothing but whitespace writes nothing that keeps two
366
+ # lists apart, see #render_child.
367
+ def whitespace?(child)
368
+ child.instance_of?(AST::Text) && child.text.strip.empty?
369
+ end
370
+
147
371
  # Ancestry fallback for tag dispatch (see TagLibrary#resolve),
148
372
  # memoized per top-level render call — the tag library can change
149
373
  # between calls, so the cache must not outlive one call (it is
@@ -156,7 +380,7 @@ module Markbridge
156
380
  end
157
381
 
158
382
  # Same as {#resolved_tag}, against the default library backing
159
- # #render_default.
383
+ # #render_default, kept for the life of the renderer.
160
384
  def resolved_default_tag(node_class)
161
385
  cache = @resolved_default_tags ||= {}
162
386
  cache.fetch(node_class) { cache[node_class] = default_tag_library.resolve(node_class) }
@@ -169,9 +393,15 @@ module Markbridge
169
393
  end
170
394
 
171
395
  # The tag-less rendering paths shared by #render and #render_default.
396
+ # An element without a tag does not go on the parent chain (every
397
+ # chain walk would get one step longer, and text nodes walk it
398
+ # several times). The first one becomes the context's root element
399
+ # instead, so top-level children can still ask for their siblings.
400
+ # The Document is the usual case.
172
401
  def render_without_tag(node, context)
173
402
  case node
174
403
  when AST::Element # Document is an Element subclass
404
+ context = context.with_root(node) if context.root.nil?
175
405
  render_children(node, context:)
176
406
  when AST::MarkdownText
177
407
  render_markdown_text(node, context)
@@ -182,32 +412,46 @@ module Markbridge
182
412
  end
183
413
  end
184
414
 
185
- # In html_mode, surround pre-formatted Markdown with blank lines so that
186
- # CommonMark terminates the enclosing HTML block (e.g. <table>) and
187
- # parses the content as Markdown before the closing tags reopen another
188
- # HTML block.
415
+ # In html_mode, pre-formatted Markdown is a Markdown island in a
416
+ # `<div>`: the blank lines end the enclosing HTML block (e.g. a
417
+ # <table>) so that CommonMark parses the content as Markdown, and
418
+ # the `</div>` line opens the block again for the rest of the cell.
189
419
  def render_markdown_text(node, context)
190
- context.html_mode? ? HtmlBlock.island(node.text) : node.text
420
+ context.html_mode? ? HtmlBlock.enclosed_island(node.text) : node.text
191
421
  end
192
422
 
423
+ # Text nodes are the most frequent nodes, so this walks the parent
424
+ # chain once and takes the first answer it can: in html_mode the
425
+ # chain does not matter at all, and under a Code ancestor nothing
426
+ # after it does. The checks are spelled out instead of going
427
+ # through a block, and the walk builds no array — every step
428
+ # of the chain costs.
429
+ #
430
+ # A link label is where `]` is structural and must be escaped: a
431
+ # link, a mail link, and the alt text of an image or an upload
432
+ # (ImageTag and UploadTag render it as a Text node under their
433
+ # element). The tags write their own brackets around it.
193
434
  def render_text(node, context)
194
435
  # In html_mode even inside a code block we must HTML-escape, otherwise a
195
- # stray `<` in a code cell would break the surrounding <td>.
196
- if context.has_parent?(AST::Code)
197
- context.html_mode? ? @html_escaper.escape(node.text) : node.text
198
- elsif context.html_mode?
199
- @html_escaper.escape(node.text)
200
- else
201
- @escaper.escape(node.text, in_link_label: in_link_label?(context))
436
+ # stray `<` in a code cell would break the surrounding <td>. A
437
+ # blank line would end the block, like the serializer's text
438
+ # it is written as the entity.
439
+ if context.html_mode?
440
+ return HtmlBlock.without_blank_lines(@html_escaper.escape(node.text))
441
+ end
442
+
443
+ in_link_label = false
444
+
445
+ # The chain always ends in a root context whose element is nil.
446
+ while (parent = context.element)
447
+ return node.text if parent.is_a?(AST::Code)
448
+
449
+ in_link_label = true if parent.is_a?(AST::Url) || parent.is_a?(AST::Email) ||
450
+ parent.is_a?(AST::Image) || parent.is_a?(AST::Upload)
451
+ context = context.parent_context
202
452
  end
203
- end
204
453
 
205
- # `]` is structural inside a Markdown link label, so any plain text
206
- # rendered under an Url/Email ancestor must escape it. Tags that emit
207
- # their own bracketed markup (ImageTag, UploadTag, etc.) skip this
208
- # path entirely, so their structural brackets are preserved.
209
- def in_link_label?(context)
210
- context.has_parent?(AST::Url) || context.has_parent?(AST::Email)
454
+ @escaper.escape(node.text, in_link_label:)
211
455
  end
212
456
  end
213
457
  end