markbridge 0.4.0 → 0.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d958f4d4c04108de2f92388f6a62ac37ab126700f535af2c6cf38f832928e20
4
- data.tar.gz: 86510bc530a71fefb9f30e350a7800d159dd9e5fa77231f2f6259253373c11b4
3
+ metadata.gz: fa422d5e0a05eee7f32524e42823d02a552111132025fd9ede1fc5045a79412e
4
+ data.tar.gz: cc7af6319a7c9dde74a764dc9edaab186ef2cca92bc6fabd842a147216adaec0
5
5
  SHA512:
6
- metadata.gz: 828a0d60a75ac730dd882e7825e2ea2e0fec53ca6b2efea6329cac03030157fba4ea086118476445ea9505835ab28031221ed786be526bdfb1b2340e2c570c56
7
- data.tar.gz: 3df501c15e4050edcbd2225d356440e4672d30a5feb8d85a3b225af0daa4cd66f09f743044ac41b7e75b01a8378951aceac7784078833facb4d42cefcfc3aa24
6
+ metadata.gz: 2eb57814326cf0fbaf2e356290d962a0a9e8bf2b9c38aa4e41ad7526d9494574162fc5384473b15c833b4e34bd7f322bf464098337bf9d535662f615590c19e4
7
+ data.tar.gz: d4fc22c47cc54ab91a0d29b140bf69317c5c10aae3358758b0036b213f96ddca9318d723016854819a8a80dcf03ea5f66180eaccc8df2d550d06ab69b8f25a87
@@ -4,78 +4,30 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Builders
7
- # Builder for list item formatting
8
- # Handles complex multi-line formatting with proper indentation
9
- # and preservation of blank lines and nested list items
7
+ # Formats one list item: the marker goes in front of the first
8
+ # line, every other line moves right by the width of the marker.
9
+ # The content is indented as one unit, so nested lists, code
10
+ # fences and HTML blocks inside it keep the shape they already
11
+ # have. Blank lines stay blank; the postprocessor clears
12
+ # whitespace-only lines anyway.
10
13
  class ListItemBuilder
11
- # Build a formatted list item string
12
14
  # @param content [String] the item content
13
15
  # @param marker [String] the list marker ("- " or "1. ")
14
- # @param indent [String] the indentation string
15
16
  # @return [String]
16
- def build(content, marker:, indent:)
17
+ # @example
18
+ # builder.build("a\nb", marker: "- ") # => "- a\n b\n"
19
+ def build(content, marker:)
17
20
  lines = content.split("\n")
18
- first_line = "#{indent}#{marker}#{lines.first}"
19
-
21
+ first_line = "#{marker}#{lines.first}"
20
22
  return "#{first_line}\n" if lines.size < 2
21
23
 
22
- format_multiline(lines, first_line, indent)
23
- end
24
-
25
- private
26
-
27
- # Format multi-line content with proper indentation
28
- # @param lines [Array<String>] content lines
29
- # @param first_line [String] the formatted first line
30
- # @param indent [String] base indentation
31
- # @return [String]
32
- def format_multiline(lines, first_line, indent)
33
- continuation_indent = "#{indent} "
34
- continuation_lines = lines[1..]
35
-
36
- rest =
37
- continuation_lines.each_with_index.filter_map do |line, idx|
38
- format_continuation_line(line, idx, continuation_lines, continuation_indent)
39
- end
40
-
24
+ indent = " " * marker.length
25
+ # An empty line maps to nil, and join turns that back into
26
+ # an empty line. Indenting it instead would leave a line
27
+ # with nothing but spaces on it.
28
+ rest = lines[1..].map { |line| "#{indent}#{line}" unless line.empty? }
41
29
  "#{([first_line] + rest).join("\n")}\n"
42
30
  end
43
-
44
- # Format a single continuation line
45
- # @param line [String] the line to format
46
- # @param idx [Integer] index in continuation_lines array
47
- # @param continuation_lines [Array<String>] all continuation lines
48
- # @param continuation_indent [String] indent for continuation
49
- # @return [String, nil] formatted line or nil to skip
50
- def format_continuation_line(line, idx, continuation_lines, continuation_indent)
51
- # Handle empty lines
52
- return handle_empty_line(idx, continuation_lines, continuation_indent) if line.empty?
53
-
54
- # Check if line is already a list item (has indentation + marker)
55
- if line.match?(/\A\s*(?:-|\d+\.)\s/)
56
- # Already a list item - don't add extra indentation
57
- line
58
- else
59
- # Regular continuation line - add indentation
60
- "#{continuation_indent}#{line}"
61
- end
62
- end
63
-
64
- # Handle empty lines in continuation. Caller (format_continuation_line)
65
- # only invokes this when `line.empty?`, and `content.split("\n")`
66
- # trims trailing empty strings, so the LAST continuation line is
67
- # never empty — `idx + 1` is always in bounds when we get here.
68
- # @param idx [Integer] index in continuation_lines
69
- # @param continuation_lines [Array<String>] all continuation lines
70
- # @param continuation_indent [String] indent for continuation
71
- # @return [String, nil] formatted line or nil to skip
72
- def handle_empty_line(idx, continuation_lines, continuation_indent)
73
- # Skip empty lines that come before nested list items (structural blanks)
74
- return nil if continuation_lines[idx + 1].match?(/\A\s*(?:-|\d+\.)\s/)
75
-
76
- # Preserve empty lines within text content (paragraph breaks) with indentation
77
- continuation_indent
78
- end
79
31
  end
80
32
  end
81
33
  end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Renderers
5
+ module Discourse
6
+ # The one place that knows how CommonMark HTML blocks (spec §4.6)
7
+ # and Markdown meet in the rendered output.
8
+ #
9
+ # Inside an HTML block the content passes through as raw HTML.
10
+ # Markdown is parsed again only across blank lines. A tag that
11
+ # renders into such a block therefore picks one of two forms:
12
+ #
13
+ # 1. raw HTML — an HTML equivalent of the tag's Markdown, spliced
14
+ # into the block as it is;
15
+ # 2. a Markdown island — the tag's normal Markdown surrounded by
16
+ # blank lines, which end the block and start it again:
17
+ #
18
+ # <div align="center">
19
+ #
20
+ # [a link](https://example.com)
21
+ #
22
+ # </div>
23
+ module HtmlBlock
24
+ # Tag names that start a type 6 HTML block when a line begins
25
+ # with `<name` or `</name` followed by a space, a tab, `>`, `/>`
26
+ # or the end of the line.
27
+ BLOCK_TAGS = %w[
28
+ address
29
+ article
30
+ aside
31
+ base
32
+ basefont
33
+ blockquote
34
+ body
35
+ caption
36
+ center
37
+ col
38
+ colgroup
39
+ dd
40
+ details
41
+ dialog
42
+ dir
43
+ div
44
+ dl
45
+ dt
46
+ fieldset
47
+ figcaption
48
+ figure
49
+ footer
50
+ form
51
+ frame
52
+ frameset
53
+ h1
54
+ h2
55
+ h3
56
+ h4
57
+ h5
58
+ h6
59
+ head
60
+ header
61
+ hr
62
+ html
63
+ iframe
64
+ legend
65
+ li
66
+ link
67
+ main
68
+ menu
69
+ menuitem
70
+ nav
71
+ noframes
72
+ ol
73
+ optgroup
74
+ option
75
+ p
76
+ param
77
+ search
78
+ section
79
+ summary
80
+ table
81
+ tbody
82
+ td
83
+ tfoot
84
+ th
85
+ thead
86
+ title
87
+ tr
88
+ track
89
+ ul
90
+ ].freeze
91
+
92
+ # The spec allows up to three leading spaces. We match any amount
93
+ # of leading whitespace instead, because the lines this runs on
94
+ # are item content that the list builder has not indented yet.
95
+ OPENER = %r{\A[ \t]*</?(?:#{Regexp.union(BLOCK_TAGS).source})(?:[ \t>]|/>|\z)}i
96
+
97
+ # Markdown sigils that would surface as literal text inside an
98
+ # HTML block: emphasis (`*`, `_`, `~`) and link middles (`](`).
99
+ MARKDOWN_SIGILS = /[*_~]|\]\(/
100
+
101
+ # Blank lines at the start or at the end of a fragment. The first
102
+ # content line keeps its own indentation, e.g. a nested ` - a`.
103
+ BLANK_EDGES = /\A(?:[ \t]*\n)+|(?:\n[ \t]*)+\z/
104
+
105
+ private_constant :BLOCK_TAGS, :OPENER, :MARKDOWN_SIGILS, :BLANK_EDGES
106
+
107
+ # Whether +line+ starts an HTML block. A closing tag such as
108
+ # `</div>` starts one too. The block runs until the next blank
109
+ # line.
110
+ # @param line [String]
111
+ # @return [Boolean]
112
+ def self.opens?(line)
113
+ OPENER.match?(line)
114
+ end
115
+
116
+ # Wrap Markdown so that CommonMark parses it even inside an HTML
117
+ # block. Blank lines that the fragment already has at its edges
118
+ # are folded into the wrap.
119
+ # @param markdown [String]
120
+ # @return [String]
121
+ # @example
122
+ # HtmlBlock.island("- a\n") # => "\n\n- a\n\n"
123
+ def self.island(markdown)
124
+ "\n\n#{markdown.gsub(BLANK_EDGES, "")}\n\n"
125
+ end
126
+
127
+ # Whether a fragment can be spliced into an HTML block: raw HTML
128
+ # or plain text without Markdown sigils, or an island.
129
+ #
130
+ # Used by the html_mode contract check that ships in
131
+ # +markbridge/rspec+ and by this repo's own contract spec.
132
+ # @param output [String] a tag's html_mode render result
133
+ # @return [Boolean]
134
+ def self.safe?(output)
135
+ return true if output.start_with?("\n\n") && output.end_with?("\n\n")
136
+
137
+ !output.match?(MARKDOWN_SIGILS)
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -76,7 +76,6 @@ module Markbridge
76
76
  FENCED_CODE_BACKTICK = /\A`{3,}[^`]*$/
77
77
  FENCED_CODE_TILDE = /\A~{3,}/
78
78
  SETEXT_UNDERLINE_EQUALS = /\A=+[ \t]*$/
79
- SETEXT_UNDERLINE_DASH = /\A-+[ \t]*$/
80
79
  # Indented code: 4+ spaces, tab at start, or space+tab reaching column 4+
81
80
  INDENTED_CODE = /\A(?: {4}|\t| {1,3}\t)/
82
81
 
@@ -182,11 +181,11 @@ module Markbridge
182
181
  # skip the split and its Array + line-String allocations. A lone
183
182
  # `\r` without `\n` stays on the line either way — `/\r?\n/`
184
183
  # needs the `\n` — so `include?("\n")` alone decides correctly.
185
- return escape_line(text, false) unless text.include?("\n")
184
+ return escape_line(text) unless text.include?("\n")
186
185
 
187
186
  # On CRLF input, consume `\r` as part of the line terminator instead
188
187
  # of leaving it on the line. A trailing `\r` breaks line-end anchored
189
- # regexes (e.g. SETEXT_UNDERLINE_*) and the `ws_end >= line_length`
188
+ # regexes (e.g. SETEXT_UNDERLINE_EQUALS) and the `ws_end >= line_length`
190
189
  # early-out in escape_indented_code, leaking NBSPs onto
191
190
  # whitespace-only CRLF lines. The `include?` guard keeps the
192
191
  # LF-only fast path on a string split (regex split is ~20% slower
@@ -196,22 +195,19 @@ module Markbridge
196
195
  # Pre-allocate result buffer
197
196
  bytesize = text.bytesize
198
197
  result = String.new(capacity: bytesize + bytesize / 3, encoding: text.encoding)
199
- prev_was_paragraph = false
200
198
  first = true
201
199
 
202
200
  lines.each do |line|
203
201
  result << "\n" unless first
204
202
  first = false
205
203
 
206
- escaped = escape_line(line, prev_was_paragraph)
207
- result << escaped
208
- prev_was_paragraph = paragraph_line?(line)
204
+ result << escape_line(line)
209
205
  end
210
206
 
211
207
  result
212
208
  end
213
209
 
214
- def escape_line(line, prev_was_paragraph)
210
+ def escape_line(line)
215
211
  # No `line.empty?` early-return: it's redundant with the
216
212
  # `line.getbyte(indent_len).nil?` guard below, which catches both
217
213
  # empty and whitespace-only lines while also preserving object
@@ -229,7 +225,7 @@ module Markbridge
229
225
  has_indent = indent_len > 0
230
226
  content = has_indent ? line[indent_len..] : line
231
227
 
232
- escaped, skip_inline = escape_block_level(content, prev_was_paragraph)
228
+ escaped, skip_inline = escape_block_level(content)
233
229
  escaped = escape_inline(escaped) unless skip_inline
234
230
 
235
231
  if has_indent
@@ -276,7 +272,7 @@ module Markbridge
276
272
  "#{nbsp_indent}#{escape_inline(content)}"
277
273
  end
278
274
 
279
- def escape_block_level(content, prev_was_paragraph)
275
+ def escape_block_level(content)
280
276
  first_byte = content.getbyte(0)
281
277
 
282
278
  case first_byte
@@ -289,7 +285,7 @@ module Markbridge
289
285
  return pass_first_char_inline(content) if @allow.include?(:block_quote)
290
286
  return escape_first_char_inline(content, "\\>")
291
287
  when DASH
292
- return escape_block_dash(content, prev_was_paragraph)
288
+ return escape_block_dash(content)
293
289
  when PLUS
294
290
  if BULLET_LIST.match?(content)
295
291
  return pass_first_char_inline(content) if @allow.include?(:bullet_list)
@@ -302,7 +298,17 @@ module Markbridge
302
298
  return escape_all_chars(content, UNDERSCORE, "\\_"), true
303
299
  end
304
300
  when EQUALS
305
- if prev_was_paragraph && SETEXT_UNDERLINE_EQUALS.match?(content)
301
+ # A line of only `=` is a setext heading underline when a
302
+ # paragraph line comes before it. The escaper sees a single
303
+ # text fragment, and the renderer can put that fragment
304
+ # right after a paragraph line — after a line break, or
305
+ # after inline markup like `[b]Body[/b]\n===` — so the
306
+ # previous line is not visible here. The line is therefore
307
+ # escaped in every position, like every other block
308
+ # construct. Where no paragraph line comes before it,
309
+ # Discourse renders `\=` as a literal `=`, so the result
310
+ # looks the same.
311
+ if SETEXT_UNDERLINE_EQUALS.match?(content)
306
312
  return escape_all_chars(content, EQUALS, "\\="), true
307
313
  end
308
314
  when BACKTICK
@@ -327,11 +333,8 @@ module Markbridge
327
333
  ["#{escaped_char}#{escape_inline(content[1..])}", true]
328
334
  end
329
335
 
330
- def escape_block_dash(content, prev_was_paragraph)
331
- if THEMATIC_BREAK_DASH.match?(content) ||
332
- (prev_was_paragraph && SETEXT_UNDERLINE_DASH.match?(content))
333
- return escape_all_chars(content, DASH, "\\-"), true
334
- end
336
+ def escape_block_dash(content)
337
+ return escape_all_chars(content, DASH, "\\-"), true if THEMATIC_BREAK_DASH.match?(content)
335
338
  if BULLET_LIST.match?(content)
336
339
  return pass_first_char_inline(content) if @allow.include?(:bullet_list)
337
340
  return escape_first_char_inline(content, "\\-")
@@ -562,56 +565,6 @@ module Markbridge
562
565
  1
563
566
  end
564
567
  end
565
-
566
- def paragraph_line?(line)
567
- pos = 0
568
- line_len = line.bytesize
569
- pos += 1 while pos < line_len && line.getbyte(pos) == SPACE
570
- first_non_space = pos
571
-
572
- # Empty or whitespace-only lines: getbyte past the end returns nil.
573
- return false if line.getbyte(first_non_space).nil?
574
-
575
- # Indented code (4+ spaces or any leading \t) is not a paragraph.
576
- # INDENTED_CODE also catches lines where first_non_space > 3, so no
577
- # separate numeric boundary check is needed.
578
- return false if INDENTED_CODE.match?(line)
579
-
580
- content = first_non_space == 0 ? line : line[first_non_space..]
581
-
582
- # Lines starting with [ are paragraph content (the escaper rewrites [
583
- # to \[). block_construct? has no BRACKET_OPEN case arm, so such
584
- # lines naturally fall through and !block_construct?(content) == true.
585
- !block_construct?(content)
586
- end
587
-
588
- # Checks whether content starts with a block-level markdown construct.
589
- # Used by both escape_block_level (to decide what to escape) and
590
- # paragraph_line? (to decide if setext underlines can follow).
591
- def block_construct?(content)
592
- case content.getbyte(0)
593
- when HASH
594
- ATX_HEADING.match?(content)
595
- when GT
596
- true
597
- when DASH
598
- BULLET_LIST.match?(content) || THEMATIC_BREAK_DASH.match?(content)
599
- when STAR
600
- BULLET_LIST.match?(content) || THEMATIC_BREAK_STAR.match?(content)
601
- when PLUS
602
- BULLET_LIST.match?(content)
603
- when UNDERSCORE
604
- THEMATIC_BREAK_UNDERSCORE.match?(content)
605
- when BACKTICK
606
- FENCED_CODE_BACKTICK.match?(content)
607
- when TILDE
608
- FENCED_CODE_TILDE.match?(content)
609
- when DIGIT_0..DIGIT_9
610
- ORDERED_LIST.match?(content)
611
- else
612
- false
613
- end
614
- end
615
568
  end
616
569
  end
617
570
  end
@@ -87,19 +87,38 @@ module Markbridge
87
87
  end
88
88
 
89
89
  # Render all children of a node
90
+ #
91
+ # With a block, the block runs at every join point, right before
92
+ # a non-empty child output is appended. It receives the buffer
93
+ # built so far and the child about to be rendered into it, and
94
+ # may change the buffer in place. A tag uses this to adjust the
95
+ # text in front of a specific child without iterating the
96
+ # children itself, which would lose the emphasis-boundary rule
97
+ # below.
98
+ #
90
99
  # @param node [AST::Element]
91
100
  # @param context [RenderContext] rendering context
101
+ # @yieldparam result [String] the buffer built so far
102
+ # @yieldparam child [AST::Node] the child about to be appended
92
103
  # @return [String]
104
+ # @example Attach a nested list directly to the text in front of it
105
+ # interface.render_children(item, context:) do |buffer, child|
106
+ # buffer.rstrip! if child.is_a?(AST::List)
107
+ # end
93
108
  def render_children(node, context:)
94
109
  result = +""
95
110
  node.children.each do |child|
96
111
  part = render(child, context:)
97
112
  next if part.empty?
98
113
 
114
+ yield(result, child) if block_given?
115
+
99
116
  # Integer-byte check avoids allocating substrings for the
100
117
  # per-child adjacency probe. EMPHASIS_DELIMITER_BYTES.include?
101
- # over a 4-element Set is O(1).
102
- if !result.empty? && (last_byte = result.getbyte(-1)) == part.getbyte(0) &&
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) &&
103
122
  EMPHASIS_DELIMITER_BYTES.include?(last_byte)
104
123
  result << EMPHASIS_BOUNDARY
105
124
  end
@@ -168,7 +187,7 @@ module Markbridge
168
187
  # parses the content as Markdown before the closing tags reopen another
169
188
  # HTML block.
170
189
  def render_markdown_text(node, context)
171
- context.html_mode? ? "\n\n#{node.text}\n\n" : node.text
190
+ context.html_mode? ? HtmlBlock.island(node.text) : node.text
172
191
  end
173
192
 
174
193
  def render_text(node, context)
@@ -26,8 +26,10 @@ module Markbridge
26
26
  @renderer.render_default(node, context:)
27
27
  end
28
28
 
29
- def render_children(element, context: @context)
30
- @renderer.render_children(element, context:)
29
+ # An optional block runs at every join point, before a non-empty
30
+ # child output is appended — see Renderer#render_children.
31
+ def render_children(element, context: @context, &block)
32
+ @renderer.render_children(element, context:, &block)
31
33
  end
32
34
 
33
35
  # Context operations
@@ -20,10 +20,30 @@ module Markbridge
20
20
 
21
21
  return content unless ALLOWED_ALIGNMENTS.include?(element.alignment)
22
22
 
23
- wrapper = %(<div align="#{element.alignment}">#{content}</div>)
24
- # Skip the blank-line bracketing in html_mode: a blank line would
25
- # terminate the surrounding HTML block (e.g. an enclosing <table>).
26
- interface.html_mode? ? wrapper : "\n\n#{wrapper}\n\n"
23
+ return html_block_form(element, content) if interface.html_mode?
24
+
25
+ # Keeps consecutive aligned blocks from merging.
26
+ "\n\n#{markdown_island_form(element, content)}\n\n"
27
+ end
28
+
29
+ private
30
+
31
+ # Children already render as raw HTML here, and a blank line
32
+ # would terminate the enclosing block (e.g. a <table>).
33
+ def html_block_form(element, content)
34
+ %(<div align="#{element.alignment}">#{content}</div>)
35
+ end
36
+
37
+ # A `<div>` opens an HTML block (CommonMark §4.6), and Markdown
38
+ # inside one is only parsed across blank lines. Without them a
39
+ # link in the content shows up as its own source text. The blank
40
+ # lines do mean CommonMark wraps the content in a `<p>`, so
41
+ # inline content picks up a paragraph margin.
42
+ def markdown_island_form(element, content)
43
+ open_tag = %(<div align="#{element.alignment}">)
44
+ return "#{open_tag}</div>" if content.match?(/\A\s*\z/)
45
+
46
+ "#{open_tag}#{HtmlBlock.island(content)}</div>"
27
47
  end
28
48
  end
29
49
  end
@@ -4,7 +4,13 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Tag for rendering list items
7
+ # Tag for rendering list items.
8
+ #
9
+ # An item does not indent itself. It renders its content at
10
+ # column zero and hands it to the builder, which moves the whole
11
+ # content right by the width of the marker. A nested list then
12
+ # lands at the content column of the item that holds it, whatever
13
+ # the nesting depth is.
8
14
  class ListItemTag < Tag
9
15
  def initialize
10
16
  @builder = Builders::ListItemBuilder.new
@@ -12,50 +18,49 @@ module Markbridge
12
18
 
13
19
  def render(element, interface)
14
20
  child_context = interface.with_parent(element)
15
- content = interface.render_children(element, context: child_context).strip
16
- return "" if content.empty?
21
+ return render_html(element, interface, child_context) if interface.html_mode?
17
22
 
18
- return "<li>#{content}</li>" if interface.html_mode?
23
+ content = render_content(element, interface, child_context)
24
+ return "" if content.empty?
19
25
 
20
26
  parent_list = interface.find_parent(AST::List)
21
- @builder.build(
22
- content,
23
- marker: determine_marker(parent_list),
24
- indent: calculate_indent(interface),
25
- )
27
+ @builder.build(content, marker: determine_marker(parent_list))
26
28
  end
27
29
 
28
30
  private
29
31
 
30
- # @param parent_list [AST::List, nil]
31
32
  # @return [String]
32
- def determine_marker(parent_list)
33
- parent_list&.ordered? ? "1. " : "- "
33
+ def render_html(element, interface, child_context)
34
+ content = interface.render_children(element, context: child_context).strip
35
+ content.empty? ? "" : "<li>#{content}</li>"
34
36
  end
35
37
 
36
- # Each ancestor List (excluding the direct parent) contributes
37
- # indentation matching its own marker width:
38
- # ordered = " " (3 chars for "1. "), unordered = " " (2 chars for "- ").
39
- # @param interface [RenderingInterface]
38
+ # A nested list is attached directly to the content in front of
39
+ # it. A blank line there would make the whole list loose, and
40
+ # CommonMark then wraps every item in a <p>. Blank lines inside
41
+ # the output of a child are never touched.
40
42
  # @return [String]
41
- def calculate_indent(interface)
42
- list_count = interface.count_parents(AST::List)
43
- # Top-level list — no ancestor lists contribute indent. The
44
- # common case; short-circuit before any array allocations.
45
- return "" if list_count <= 1
43
+ def render_content(element, interface, child_context)
44
+ content =
45
+ interface.render_children(element, context: child_context) do |buffer, child|
46
+ tighten(buffer) if child.is_a?(AST::List)
47
+ end
48
+ content.strip
49
+ end
50
+
51
+ # Drops the blank lines in front of a nested list, but keeps one
52
+ # when the line before it opens an HTML block (for example a
53
+ # `</table>`), because only a blank line ends such a block.
54
+ def tighten(buffer)
55
+ buffer.rstrip!
56
+ last_line = buffer[(buffer.rindex("\n") || -1) + 1..]
57
+ buffer << "\n" if HtmlBlock.opens?(last_line)
58
+ end
46
59
 
47
- indent = +""
48
- found = 0
49
- interface.context.parents.each do |parent|
50
- next unless parent.instance_of?(AST::List)
51
- found += 1
52
- # The immediate parent List is the LAST one in parents; its
53
- # marker width is already accounted for by the item's own
54
- # marker, so it doesn't contribute to indent.
55
- break if found == list_count
56
- indent << (parent.ordered? ? " " : " ")
57
- end
58
- indent
60
+ # @param parent_list [AST::List, nil]
61
+ # @return [String]
62
+ def determine_marker(parent_list)
63
+ parent_list&.ordered? ? "1. " : "- "
59
64
  end
60
65
  end
61
66
  end
@@ -7,7 +7,7 @@ require_relative "discourse/rendering_interface"
7
7
  require_relative "discourse/markdown_escaper"
8
8
  require_relative "discourse/identity_escaper"
9
9
  require_relative "discourse/html_escaper"
10
- require_relative "discourse/html_block_safety"
10
+ require_relative "discourse/html_block"
11
11
  require_relative "discourse/postprocessor"
12
12
 
13
13
  # Builders
@@ -39,7 +39,7 @@ RSpec.shared_examples "an html_mode safe tag" do
39
39
  it "renders html_mode output that is safe inside an HTML block" do
40
40
  output = tag.render(element, markbridge_html_mode_interface)
41
41
 
42
- expect(Markbridge::Renderers::Discourse::HtmlBlockSafety.safe?(output)).to be(true),
42
+ expect(Markbridge::Renderers::Discourse::HtmlBlock.safe?(output)).to be(true),
43
43
  "Expected #{tag.class} to render raw HTML or a \\n\\n-wrapped " \
44
44
  "Markdown island in html_mode, got: #{output.inspect}"
45
45
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Markbridge
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: markbridge
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Discourse Team
@@ -138,7 +138,7 @@ files:
138
138
  - lib/markbridge/parsers/text_formatter/parser.rb
139
139
  - lib/markbridge/renderers/discourse.rb
140
140
  - lib/markbridge/renderers/discourse/builders/list_item_builder.rb
141
- - lib/markbridge/renderers/discourse/html_block_safety.rb
141
+ - lib/markbridge/renderers/discourse/html_block.rb
142
142
  - lib/markbridge/renderers/discourse/html_escaper.rb
143
143
  - lib/markbridge/renderers/discourse/identity_escaper.rb
144
144
  - lib/markbridge/renderers/discourse/markdown_escaper.rb
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Renderers
5
- module Discourse
6
- # Decides whether a rendered fragment is safe to splice into a
7
- # CommonMark HTML block (spec §4.6). Inside such a block the
8
- # content passes through as raw HTML; Markdown is only parsed
9
- # again across blank lines. Safe output is therefore: a raw HTML
10
- # or plain-text fragment without Markdown sigils, or a
11
- # +\n\n…\n\n+ wrap — a deliberate Markdown island.
12
- #
13
- # Used by the html_mode contract check that ships in
14
- # +markbridge/rspec+ and by this repo's own contract spec.
15
- module HtmlBlockSafety
16
- # Markdown sigils that would surface as literal text inside an
17
- # HTML block: emphasis (`*`, `_`, `~`) and link middles (`](`).
18
- MARKDOWN_SIGILS = /[*_~]|\]\(/
19
- private_constant :MARKDOWN_SIGILS
20
-
21
- # @param output [String] a tag's html_mode render result
22
- # @return [Boolean]
23
- def self.safe?(output)
24
- return true if output.start_with?("\n\n") && output.end_with?("\n\n")
25
-
26
- !output.match?(MARKDOWN_SIGILS)
27
- end
28
- end
29
- end
30
- end
31
- end