markbridge 0.4.1 → 0.4.3

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: 6bb47b7959df5ce79179a7edbe11ffff2126d6769ed70af335fa12f62467a7f9
4
- data.tar.gz: e959d0e4c9e9f05abf66d2c57e79ac4ffa123b88304addcd2b2cfc18a8d4734d
3
+ metadata.gz: 356955e6ba849a6acb08902465ed0d45c710020183d75eca66d3904b93995f3c
4
+ data.tar.gz: 4c1bf20ea2c024478cdfa6bbea5ceade531de96085a2cc911499a0c8d31d78ad
5
5
  SHA512:
6
- metadata.gz: 49ee985843ccb441d18b680a8e9aa3983f8f3ab37d0831462a71959c356d7cd993495bc5d4b777d3aa5ec58ace53d995d9b57aacda198d6ee95f2d11b97038cc
7
- data.tar.gz: 9a6551d169ac4b2ee9211a7ee460ac6247cc5f9c00ef8fe1fced7139cd1836959c03110da020a7b37f23b735ca348070bb378da1bfc8083246a98718c0116d67
6
+ metadata.gz: 0fbb3282126a07f194e4c833bc2694b91a64b877144e6ea0317ff2f62785a28dd144a969cc5d4a3fc1eda3306fa1416496cc478b6fe51ffe4862dba9f468a288
7
+ data.tar.gz: 0624ec779e99b904de91eb2d519b65ee7c123d2b8e8d51c659b4ce5005afc521bd2290e07517a7f9aaf5ea9e19998354b31e7a6818e27fe55dceb02da22cad41
@@ -32,8 +32,13 @@ module Markbridge
32
32
  def register(tag_names, handler)
33
33
  element_class = handler.element_class
34
34
  Array(tag_names).each { |tag_name| @handlers[tag_name.to_s.downcase] = handler }
35
- @element_handlers[element_class] = handler
35
+ # The conditional write goes first. Both writes are independent and
36
+ # keyed by element_class, so the order makes no difference to a
37
+ # successful register. On a frozen registry it decides which
38
+ # collection raises, and only a write that can be skipped lets a
39
+ # spec reach the one behind it.
36
40
  @auto_closeable_elements << element_class if handler.auto_closeable?
41
+ @element_handlers[element_class] = handler
37
42
  self
38
43
  end
39
44
 
@@ -70,9 +70,21 @@ module Markbridge
70
70
  # a <pre> keep their semantics.
71
71
  @preserve_depth = initial_preserve_depth(doc)
72
72
 
73
+ # Whitespace collapsing follows the browser rule (CSS Text
74
+ # §4.1.1): a collapsible space is dropped at the start of a
75
+ # line, at the end of a line, and right after another
76
+ # collapsible space — wherever the inline boundaries fall, so
77
+ # `<b>a </b> b` keeps one space and `<p><b>x </b></p>` none.
78
+ # A line starts at the parse root and on every block-level tag
79
+ # boundary. @space_tail is the element whose last child is a
80
+ # collapsible Text ending in a space (the candidate for the
81
+ # end-of-line trim), or nil.
82
+ @line_start = true
83
+ @space_tail = nil
84
+
73
85
  # Process all nodes
74
86
  children.each { |node| process_node(node, document) }
75
- trim_trailing_whitespace(document)
87
+ end_line
76
88
 
77
89
  document
78
90
  end
@@ -117,17 +129,27 @@ module Markbridge
117
129
 
118
130
  if @preserve_depth.positive?
119
131
  parent << AST::Text.new(text)
132
+ # Preserved whitespace is content: it neither starts a line
133
+ # nor collapses against a space that follows it.
134
+ @line_start = false
135
+ @space_tail = nil
120
136
  return
121
137
  end
122
138
 
123
- text = text.gsub(WHITESPACE_RUN, " ") if text.match?(COLLAPSIBLE_WHITESPACE)
124
- # Drop leading whitespace at the start of an element's content,
125
- # matching the browser rule that whitespace at the beginning of a
126
- # block (or before any inline content) is collapsed away.
127
- text = text.lstrip if parent.children.empty?
139
+ text = collapse_whitespace(text)
140
+ text = text.lstrip if @line_start || @space_tail
128
141
  return if text.empty?
129
142
 
130
143
  parent << AST::Text.new(text)
144
+ @line_start = false
145
+ @space_tail = (parent if text.end_with?(" "))
146
+ end
147
+
148
+ # Collapse runs of whitespace to a single space.
149
+ # @param text [String]
150
+ # @return [String]
151
+ def collapse_whitespace(text)
152
+ text.match?(COLLAPSIBLE_WHITESPACE) ? text.gsub(WHITESPACE_RUN, " ") : text
131
153
  end
132
154
 
133
155
  # Process an element node
@@ -137,19 +159,21 @@ module Markbridge
137
159
  tag_name = node.name
138
160
  return if IGNORED_TAGS.include?(tag_name)
139
161
 
140
- # Drop whitespace that sits between content and the start of a
141
- # block-level tag, matching browser behavior where such whitespace
142
- # collapses against the block boundary. Applies whether or not a
143
- # handler is registered, so unknown tags like <div> or <section>
144
- # still collapse the whitespace before them.
145
- trim_trailing_whitespace(parent) if @handlers.block_level_tags.include?(tag_name)
162
+ # A block-level tag ends the line before it and the line inside
163
+ # it, matching browser behavior where whitespace collapses
164
+ # against the block boundary. Applies whether or not a handler
165
+ # is registered, so unknown tags like <div> or <section> still
166
+ # end lines.
167
+ block = @handlers.block_level_tags.include?(tag_name)
168
+ end_line if block
146
169
 
147
170
  preserving = @handlers.whitespace_preserving_tags.include?(tag_name)
148
171
  @preserve_depth += 1 if preserving
149
172
 
150
- dispatch_element(node, tag_name, parent, preserving)
173
+ dispatch_element(node, tag_name, parent)
151
174
 
152
175
  @preserve_depth -= 1 if preserving
176
+ end_line if block
153
177
  end
154
178
 
155
179
  # Dispatch an element to its handler (or the unknown-tag path) and
@@ -157,18 +181,22 @@ module Markbridge
157
181
  # @param node [Nokogiri::XML::Element]
158
182
  # @param tag_name [String]
159
183
  # @param parent [AST::Element]
160
- # @param preserving [Boolean] whether this tag preserves whitespace
161
- def dispatch_element(node, tag_name, parent, preserving)
184
+ def dispatch_element(node, tag_name, parent)
162
185
  handler = @handlers[tag_name]
163
186
  return handle_unknown_tag(node, parent) unless handler
164
187
 
165
188
  # Handler returns element if children should be processed, nil otherwise
189
+ size = parent.children.size
166
190
  ast_element = handler.process(element: node, parent:)
167
191
 
168
- return unless ast_element
169
-
170
- process_children(node, ast_element)
171
- trim_trailing_whitespace(ast_element) unless preserving
192
+ if ast_element
193
+ process_children(node, ast_element)
194
+ elsif parent.children.size > size
195
+ # The handler appended a leaf (image, line break, ...), which
196
+ # is content on the current line like a word.
197
+ @line_start = false
198
+ @space_tail = nil
199
+ end
172
200
  end
173
201
 
174
202
  # Handle unknown tag by tracking it and ignoring the wrapper
@@ -180,6 +208,16 @@ module Markbridge
180
208
  process_children(node, parent)
181
209
  end
182
210
 
211
+ # End the current line: the collapsible space that closed it goes,
212
+ # and the next collapsible space starts a line. @space_tail is
213
+ # not cleared here: the trim is idempotent, so a stale tail is
214
+ # harmless until the next text or leaf on the new line resets it,
215
+ # and @line_start already covers the lstrip.
216
+ def end_line
217
+ trim_trailing_whitespace(@space_tail) if @space_tail
218
+ @line_start = true
219
+ end
220
+
183
221
  # Number of whitespace-preserving elements enclosing the parse
184
222
  # root, the root itself included. Computed once per parse; from
185
223
  # there the counter is maintained during descent. Documents and
@@ -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
@@ -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
@@ -28,18 +28,25 @@ module Markbridge
28
28
 
29
29
  if interface.html_mode?
30
30
  %(<a href="#{HtmlEscaper.escape(href)}">#{text}</a>)
31
- elsif element.bare? || text.empty?
31
+ elsif element.bare? || blank?(text)
32
32
  # Url#bare? judges the AST (so label escaping can't confuse
33
33
  # it); the rendered-text check additionally catches labels
34
- # that render to nothing (e.g. an empty formatting child).
34
+ # that render to nothing (e.g. an empty formatting child)
35
+ # or to whitespace only, which wrap_inline would leave
36
+ # unlinked.
35
37
  href
36
38
  else
37
- "[#{text}](#{markdown_destination(href)})"
39
+ interface.wrap_inline(text, "[", "](#{markdown_destination(href)})")
38
40
  end
39
41
  end
40
42
 
41
43
  private
42
44
 
45
+ # Unicode-aware, matching the guard in RenderingInterface#wrap_inline.
46
+ def blank?(text)
47
+ !text.match?(/[^[:space:]]/)
48
+ end
49
+
43
50
  # CommonMark link destinations cannot contain whitespace unless
44
51
  # wrapped in <> — relevant for relative targets like MediaWiki
45
52
  # page names ("Main Page").
@@ -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.1"
4
+ VERSION = "0.4.3"
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.1
4
+ version: 0.4.3
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