markbridge 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) 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/handler_registry.rb +6 -1
  6. data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +2 -1
  7. data/lib/markbridge/parsers/bbcode/handlers/url_handler.rb +26 -2
  8. data/lib/markbridge/parsers/html/handlers/image_handler.rb +4 -1
  9. data/lib/markbridge/parsers/html/parser.rb +57 -19
  10. data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +3 -2
  11. data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +4 -2
  12. data/lib/markbridge/parsers/text_formatter/parser.rb +107 -14
  13. data/lib/markbridge/renderers/discourse/html_block.rb +50 -7
  14. data/lib/markbridge/renderers/discourse/inline/joins.rb +382 -0
  15. data/lib/markbridge/renderers/discourse/inline/serializer.rb +738 -0
  16. data/lib/markbridge/renderers/discourse/inline/tokens.rb +182 -0
  17. data/lib/markbridge/renderers/discourse/link_destination.rb +43 -0
  18. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +105 -45
  19. data/lib/markbridge/renderers/discourse/paragraphs.rb +88 -0
  20. data/lib/markbridge/renderers/discourse/postprocessor.rb +183 -7
  21. data/lib/markbridge/renderers/discourse/render_context.rb +38 -12
  22. data/lib/markbridge/renderers/discourse/renderer.rb +336 -92
  23. data/lib/markbridge/renderers/discourse/rendering_interface.rb +123 -44
  24. data/lib/markbridge/renderers/discourse/tag.rb +53 -9
  25. data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +4 -6
  26. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +48 -10
  27. data/lib/markbridge/renderers/discourse/tags/color_tag.rb +73 -13
  28. data/lib/markbridge/renderers/discourse/tags/details_tag.rb +62 -15
  29. data/lib/markbridge/renderers/discourse/tags/email_tag.rb +8 -9
  30. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +31 -9
  31. data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +20 -1
  32. data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +12 -1
  33. data/lib/markbridge/renderers/discourse/tags/image_tag.rb +13 -27
  34. data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +4 -6
  35. data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +9 -2
  36. data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +75 -9
  37. data/lib/markbridge/renderers/discourse/tags/list_tag.rb +29 -7
  38. data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +12 -0
  39. data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +13 -6
  40. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +52 -9
  41. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +26 -1
  42. data/lib/markbridge/renderers/discourse/tags/size_tag.rb +78 -13
  43. data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +72 -14
  44. data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +5 -6
  45. data/lib/markbridge/renderers/discourse/tags/subscript_tag.rb +11 -5
  46. data/lib/markbridge/renderers/discourse/tags/superscript_tag.rb +11 -5
  47. data/lib/markbridge/renderers/discourse/tags/table_tag.rb +48 -28
  48. data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +3 -10
  49. data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +90 -43
  50. data/lib/markbridge/renderers/discourse/tags/url_tag.rb +43 -26
  51. data/lib/markbridge/renderers/discourse.rb +6 -0
  52. data/lib/markbridge/rspec.rb +4 -2
  53. data/lib/markbridge/version.rb +1 -1
  54. data/lib/markbridge.rb +90 -1
  55. metadata +7 -1
@@ -32,6 +32,12 @@ module Markbridge
32
32
  @renderer.render_children(element, context:, &block)
33
33
  end
34
34
 
35
+ # The output of the children as inline tokens, for a tag that
36
+ # returns tokens itself — see Renderer#render_tokens.
37
+ def render_tokens(element, context: @context)
38
+ @renderer.render_tokens(element, context:)
39
+ end
40
+
35
41
  # Context operations
36
42
  def with_parent(element)
37
43
  @context.with_parent(element)
@@ -45,6 +51,30 @@ module Markbridge
45
51
  @context.html_mode?
46
52
  end
47
53
 
54
+ # Markdown that Discourse has to read as Markdown because it has
55
+ # no HTML form: the BBCode of a plugin (`[poll]`, `[event]`), an
56
+ # upload (`![a|64x64](upload://…)`), or a placeholder the
57
+ # importer replaces with one of them. Outside an HTML block it is
58
+ # the Markdown itself, with a blank line on both sides when
59
+ # +block+ is true, so a poll or an event stands alone. Inside an
60
+ # HTML block (see #html_mode?) it is a Markdown island in a
61
+ # `<div>`, block or not: the blank lines end the HTML block, so
62
+ # Discourse cooks the Markdown, and the `</div>` line opens the
63
+ # block again for the rest of the cell. See
64
+ # HtmlBlock.enclosed_island. A tag that returns a String writes
65
+ # such Markdown through this method only; the html_mode contract
66
+ # check rejects a bare blank line inside an HTML block.
67
+ # @param markdown [String]
68
+ # @param block [Boolean] whether the Markdown is a block of its own
69
+ # @return [String]
70
+ # @example A placeholder for a poll
71
+ # interface.markdown_island("[poll|3]", block: true)
72
+ def markdown_island(markdown, block:)
73
+ return HtmlBlock.enclosed_island(markdown) if html_mode?
74
+
75
+ block ? HtmlBlock.island(markdown) : markdown
76
+ end
77
+
48
78
  def find_parent(klass)
49
79
  @context.find_parent(klass)
50
80
  end
@@ -61,6 +91,99 @@ module Markbridge
61
91
  @context.root?
62
92
  end
63
93
 
94
+ # The node in front of +element+ among the children of its parent.
95
+ # nil when +element+ is the first child, or when the parent is not
96
+ # known.
97
+ # @param element [AST::Node]
98
+ # @return [AST::Node, nil]
99
+ def previous_sibling(element)
100
+ sibling(element, offset: -1)
101
+ end
102
+
103
+ # The node after +element+ among the children of its parent.
104
+ # @param element [AST::Node]
105
+ # @return [AST::Node, nil]
106
+ def next_sibling(element)
107
+ sibling(element, offset: 1)
108
+ end
109
+
110
+ # The node +offset+ positions away from +element+ among the
111
+ # children of its parent (negative: in front of it). nil when
112
+ # there is no node at that position, or when the parent is not
113
+ # known.
114
+ #
115
+ # The renderer knows the element whose children it renders and
116
+ # the position of the child it renders right now, so the usual
117
+ # call costs no search, and an element without a tag (which is
118
+ # not on the parent chain) is found as the parent too. For any
119
+ # other element the parent is the element on top of the parent
120
+ # chain, or the root element when the chain is empty, and its
121
+ # children are searched.
122
+ # @param element [AST::Node]
123
+ # @param offset [Integer]
124
+ # @return [AST::Node, nil]
125
+ def sibling(element, offset:)
126
+ if @renderer.current_child?(element)
127
+ parent = @renderer.child_parent
128
+ index = @renderer.child_index
129
+ else
130
+ parent = @context.element || @context.root
131
+ return nil if parent.nil?
132
+
133
+ index = parent.children.index { |child| child.equal?(element) }
134
+ return nil if index.nil?
135
+ end
136
+
137
+ target = index + offset
138
+ parent.children.at(target) unless target.negative?
139
+ end
140
+
141
+ # The last list among the siblings in front of +element+ with
142
+ # nothing but whitespace, or nodes that rendered to nothing,
143
+ # after it. See Renderer#previous_list.
144
+ # @param element [AST::Node]
145
+ # @return [AST::List, nil]
146
+ def previous_list(element)
147
+ @renderer.previous_list(element)
148
+ end
149
+
150
+ # The marker for the items of +node+, a list or a node whose
151
+ # output is a list: the one the list in front did not use, so the
152
+ # two stay apart. See Renderer#choose_list_marker.
153
+ # @param node [AST::Node]
154
+ # @param ordered [Boolean]
155
+ # @return [String] "- " or "* ", "1. " or "1) "
156
+ def choose_list_marker(node, ordered:)
157
+ @renderer.choose_list_marker(node, ordered:)
158
+ end
159
+
160
+ # The marker chosen for +node+ in this render call, nil when none
161
+ # was. See Renderer#list_marker.
162
+ # @param node [AST::Node]
163
+ # @return [String, nil]
164
+ def list_marker(node)
165
+ @renderer.list_marker(node)
166
+ end
167
+
168
+ # The list the output of +node+ starts with, as its tag says, or
169
+ # nil. With +children: true+ the answer of the first child with
170
+ # something in it, for a tag whose output starts with the
171
+ # children's; see Tag#leading_list and Renderer#leading_list.
172
+ # @param node [AST::Node]
173
+ # @param children [Boolean]
174
+ # @return [AST::List, nil]
175
+ def leading_list(node, children: false)
176
+ @renderer.leading_list(node, @context, children:)
177
+ end
178
+
179
+ # The list the output of +node+ ends with, see #leading_list.
180
+ # @param node [AST::Node]
181
+ # @param children [Boolean]
182
+ # @return [AST::List, nil]
183
+ def trailing_list(node, children: false)
184
+ @renderer.trailing_list(node, @context, children:)
185
+ end
186
+
64
187
  # Check if element should be rendered in block context
65
188
  # @param node [AST::Node] container node or leaf like HorizontalRule
66
189
  # @return [Boolean]
@@ -76,50 +199,6 @@ module Markbridge
76
199
  # Check if content has newlines
77
200
  node.children.any? { |c| c.instance_of?(AST::Text) && c.text.include?("\n") }
78
201
  end
79
-
80
- # Leading or trailing whitespace (Unicode-aware, matching the
81
- # flanking-preservation sub in #wrap_inline).
82
- EDGE_WHITESPACE = /\A[[:space:]]|[[:space:]]\z/m
83
- private_constant :EDGE_WHITESPACE
84
-
85
- # Helper: wrap inline content with markers
86
- # Handles edge cases like existing markers and whitespace
87
- def wrap_inline(content, open_marker, close_marker = nil)
88
- close_marker ||= open_marker
89
- return content unless content.match?(/[^[:space:]]/)
90
-
91
- # Handle conflicts with existing markers
92
- if content.include?(open_marker) || content.include?(close_marker)
93
- # Use HTML fallback for common cases
94
- case open_marker
95
- when "**"
96
- return "<strong>#{content}</strong>"
97
- when "*"
98
- return "<em>#{content}</em>"
99
- when "~~"
100
- return "<s>#{content}</s>"
101
- end
102
- end
103
-
104
- apply_markers(content, open_marker, close_marker)
105
- end
106
-
107
- private
108
-
109
- # Wrap content in markers, keeping leading/trailing whitespace
110
- # outside the markers (Unicode-aware, since CommonMark's flanking
111
- # rules treat e.g. nbsp as whitespace).
112
- def apply_markers(content, open_marker, close_marker)
113
- # Fast path: no edge whitespace to preserve (the common case), so
114
- # plain interpolation replaces the capture-group sub below and its
115
- # MatchData + capture allocations.
116
- return "#{open_marker}#{content}#{close_marker}" unless content.match?(EDGE_WHITESPACE)
117
-
118
- content.sub(/\A([[:space:]]*)(.+?)([[:space:]]*)\z/m) do
119
- match = Regexp.last_match
120
- "#{match[1]}#{open_marker}#{match[2]}#{close_marker}#{match[3]}"
121
- end
122
- end
123
202
  end
124
203
  end
125
204
  end
@@ -19,12 +19,14 @@ module Markbridge
19
19
  # and is not re-parsed for Markdown except across blank lines.
20
20
  # Every tag must pick one of two contracts:
21
21
  #
22
- # 1. Emit raw HTML (e.g. `<strong>` for `**`).
23
- # 2. Wrap Markdown output in `\n\n…\n\n` so the blank lines close
24
- # the HTML block, CommonMark parses the content as a Markdown
25
- # island, then re-opens. Visible: blank-line wrapping forces a
26
- # `<p>` margin around inline content, so prefer (1) when the
27
- # tag has an HTML form.
22
+ # 1. Emit raw HTML (e.g. `<strong>` for `**`), with no blank line
23
+ # in it.
24
+ # 2. Write the Markdown through `interface.markdown_island`, which
25
+ # puts it between blank lines inside a `<div>`: the blank lines
26
+ # close the HTML block, CommonMark parses the content as a
27
+ # Markdown island, and the `</div>` line re-opens the block.
28
+ # Visible: blank-line wrapping forces a `<p>` margin around
29
+ # inline content, so prefer (1) when the tag has an HTML form.
28
30
  #
29
31
  # @param element [AST::Node] the node to render
30
32
  # @param interface [RenderingInterface] the rendering interface
@@ -37,15 +39,57 @@ module Markbridge
37
39
  end
38
40
  end
39
41
 
42
+ # The list the output of this tag starts with, when it starts with
43
+ # one. Two lists of the same kind with only whitespace between
44
+ # them cook as one list, so the renderer keeps track of the last
45
+ # list it wrote (see RenderingInterface#previous_list) and ListTag
46
+ # puts a separator in front of a list that would continue it. A
47
+ # tag whose output starts with the output of a child, like the
48
+ # plain form of DetailsTag, answers with that child's list:
49
+ # +interface.leading_list(element, children: true)+. ListTag
50
+ # answers with the list itself. The default is nil: the output
51
+ # starts with something of its own, or with nothing.
52
+ #
53
+ # @param element [AST::Node]
54
+ # @param interface [RenderingInterface]
55
+ # @return [AST::List, nil]
56
+ def leading_list(_element, _interface)
57
+ end
58
+
59
+ # The list the output of this tag ends with, when it ends with
60
+ # one, see #leading_list. +interface.trailing_list(element,
61
+ # children: true)+ is the answer of the last child with something
62
+ # in it.
63
+ #
64
+ # @param element [AST::Node]
65
+ # @param interface [RenderingInterface]
66
+ # @return [AST::List, nil]
67
+ def trailing_list(_element, _interface)
68
+ end
69
+
40
70
  # A tag that renders only the element's children, with the element
41
71
  # pushed on the parent chain. Tag dispatch matches by ancestry, so
42
72
  # a subclass normally inherits its base class tag; register
43
73
  # PASSTHROUGH for the subclass to opt out — the exact-class hit
44
74
  # wins and the base tag is not used.
45
75
  PASSTHROUGH =
46
- new do |element, interface|
47
- interface.render_children(element, context: interface.with_parent(element))
48
- end.freeze
76
+ Class
77
+ .new(self) do
78
+ def render(element, interface)
79
+ interface.render_children(element, context: interface.with_parent(element))
80
+ end
81
+
82
+ # The output is the children's, so its edges are theirs.
83
+ def leading_list(element, interface)
84
+ interface.leading_list(element, children: true)
85
+ end
86
+
87
+ def trailing_list(element, interface)
88
+ interface.trailing_list(element, children: true)
89
+ end
90
+ end
91
+ .new
92
+ .freeze
49
93
  end
50
94
  end
51
95
  end
@@ -4,14 +4,12 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Tag for rendering bold text
7
+ # Strong emphasis. The serializer writes the markers, or the HTML
8
+ # tags where the markers would not work.
8
9
  class BoldTag < Tag
9
10
  def render(element, interface)
10
- child_context = interface.with_parent(element)
11
- content = interface.render_children(element, context: child_context)
12
- return "<strong>#{content}</strong>" if interface.html_mode?
13
-
14
- interface.wrap_inline(content, "**")
11
+ tokens = interface.render_tokens(element, context: interface.with_parent(element))
12
+ tokens.unshift(Inline::Tokens.open(:strong)) << Inline::Tokens.close(:strong)
15
13
  end
16
14
  end
17
15
  end
@@ -5,13 +5,22 @@ module Markbridge
5
5
  module Discourse
6
6
  module Tags
7
7
  class CodeTag < Tag
8
+ BACKTICKS = /`+/
9
+ TILDES = /~+/
10
+ private_constant :BACKTICKS, :TILDES
11
+
8
12
  def render(element, interface)
9
13
  child_context = interface.with_parent(element)
10
14
  content = interface.render_children(element, context: child_context)
11
15
 
12
16
  # An empty element renders to nothing — a bare `` pair or an
13
- # empty fence would only add noise to the output.
14
- return "" if content.empty?
17
+ # empty fence would only add noise to the output. An empty code
18
+ # block is still a block, so a blank line keeps the text in
19
+ # front and after it apart, except in an HTML block, which a
20
+ # blank line would end.
21
+ if content.empty?
22
+ return interface.block_context?(element) && !interface.html_mode? ? "\n\n" : ""
23
+ end
15
24
 
16
25
  if interface.block_context?(element)
17
26
  if interface.html_mode?
@@ -22,14 +31,34 @@ module Markbridge
22
31
  elsif interface.html_mode?
23
32
  "<code>#{content}</code>"
24
33
  else
34
+ # A pipe in a code span still splits the cell of a Markdown
35
+ # table, the escaped pipe does not and reads back as a pipe.
36
+ content = content.gsub("|", "\\|") if interface.has_parent?(AST::TableCell)
25
37
  render_inline(content)
26
38
  end
27
39
  end
28
40
 
29
41
  private
30
42
 
43
+ # The delimiter is one backtick longer than the longest backtick
44
+ # run in the content. A space on each side keeps a backtick at
45
+ # the edge of the content away from the delimiter. Content that
46
+ # starts and ends with a space gets the same padding, because
47
+ # CommonMark strips one space from each end of such a span.
31
48
  def render_inline(content)
32
- "`#{content}`"
49
+ delimiter = "`" * (longest_run(content, BACKTICKS) + 1)
50
+ padding = " " if needs_padding?(content)
51
+
52
+ "#{delimiter}#{padding}#{content}#{padding}#{delimiter}"
53
+ end
54
+
55
+ def needs_padding?(content)
56
+ return true if content.start_with?("`") || content.end_with?("`")
57
+
58
+ # A span of spaces only is not stripped by CommonMark, so it
59
+ # needs no padding.
60
+ content.start_with?(" ") && content.end_with?(" ") &&
61
+ content.count(" ") < content.length
33
62
  end
34
63
 
35
64
  # Leading and trailing blank lines: the trailing one keeps an
@@ -38,20 +67,22 @@ module Markbridge
38
67
  # from prior raw text or inline content.
39
68
  def render_block(content, language)
40
69
  fence = calculate_fence(content)
41
- "\n\n#{fence}#{language}\n#{content}\n#{fence}\n\n"
70
+ # The newline in front of the closing fence is part of the fence
71
+ # syntax, not of the code. A content that already ends with a
72
+ # newline (as <pre> content from HTML usually does) would
73
+ # otherwise cook with a blank line too many at the end.
74
+ "\n\n#{fence}#{language}\n#{content.chomp}\n#{fence}\n\n"
42
75
  end
43
76
 
77
+ # A blank line in the code would end the HTML block around it.
44
78
  def render_html_block(content, language)
45
79
  class_attr = %( class="language-#{HtmlEscaper.escape(language)}") if language
46
- "<pre><code#{class_attr}>#{content}</code></pre>"
80
+ "<pre><code#{class_attr}>#{HtmlBlock.without_blank_lines(content)}</code></pre>"
47
81
  end
48
82
 
49
83
  def calculate_fence(content)
50
- max_backticks = content.scan(/`+/).map(&:length).max || 0
51
- max_tildes = content.scan(/~+/).map(&:length).max || 0
52
-
53
- required_backticks = [3, max_backticks + 1].max
54
- required_tildes = [3, max_tildes + 1].max
84
+ required_backticks = [3, longest_run(content, BACKTICKS) + 1].max
85
+ required_tildes = [3, longest_run(content, TILDES) + 1].max
55
86
 
56
87
  if required_backticks <= required_tildes
57
88
  "`" * required_backticks
@@ -59,6 +90,13 @@ module Markbridge
59
90
  "~" * required_tildes
60
91
  end
61
92
  end
93
+
94
+ # The length of the longest run that +pattern+ finds in
95
+ # +content+, 0 when there is none. A delimiter or a fence has to
96
+ # be longer than that.
97
+ def longest_run(content, pattern)
98
+ content.scan(pattern).map(&:length).max || 0
99
+ end
62
100
  end
63
101
  end
64
102
  end
@@ -4,21 +4,81 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Tag for rendering colored text
8
- # Note: Discourse doesn't support inline color by default
9
- # Renders as plain text with HTML comment noting the color was lost
7
+ # Tag for rendering colored text.
8
+ #
9
+ # Discourse core removes the style of a span. Only the
10
+ # discourse-bbcode plugin can show colors, so the tag needs to
11
+ # know whether the site has it. Until it knows, a color raises
12
+ # {UndecidedFeatureError}.
13
+ #
14
+ # - +bbcode: nil+ (the default): a color raises.
15
+ # - +bbcode: false+: the text only, as inline tokens that the
16
+ # serializer joins with the neighbors.
17
+ # - +bbcode: true+: +[color=…]…[/color]+, with the value mapped
18
+ # through +colors:+ when there is a mapping. A value the plugin
19
+ # would not accept (+#rgb+, +#rrggbb+ or a name) raises
20
+ # ArgumentError; a mapping that returns nil drops the color on
21
+ # purpose.
22
+ #
23
+ # Inside a link the text is written without BBCode in every case,
24
+ # Discourse doesn't render BBCode in the text of a link.
10
25
  class ColorTag < Tag
26
+ # #rgb, #rrggbb or a color name. Anything else (and anything that
27
+ # could end the BBCode tag early) is refused.
28
+ VALUE = /\A(?:#\h{3}|#\h{6}|[a-z]+)\z/i
29
+ private_constant :VALUE
30
+
31
+ UNDECIDED =
32
+ "a [color] tag needs a decision: pass features: { bbcode: false } to " \
33
+ "Markbridge.discourse_renderer to write the text only, or " \
34
+ "features: { bbcode: true } for the discourse-bbcode plugin"
35
+ private_constant :UNDECIDED
36
+
37
+ # @param bbcode [Boolean, nil] whether the site has discourse-bbcode
38
+ # @param colors [Hash, #call, nil] a mapping from the source value
39
+ # to the value written. A Hash is read with +fetch+, so an
40
+ # unknown value raises KeyError.
41
+ def initialize(bbcode: nil, colors: nil)
42
+ @bbcode = bbcode
43
+ @colors = colors
44
+ end
45
+
11
46
  def render(element, interface)
12
- child_context = interface.with_parent(element)
13
- content = interface.render_children(element, context: child_context)
14
-
15
- if element.color
16
- # Render as HTML span with style - requires HTML to be enabled
17
- # Alternative: just output the text without color
18
- "<span style=\"color: #{element.color}\">#{content}</span>"
19
- else
20
- content
21
- end
47
+ context = interface.with_parent(element)
48
+ color = bbcode_color(element.color, interface)
49
+ return interface.render_tokens(element, context:) if color.nil?
50
+
51
+ content = interface.render_children(element, context:)
52
+ # The style that the discourse-bbcode plugin lets through.
53
+ return %(<span style="color:#{color}">#{content}</span>) if interface.html_mode?
54
+
55
+ Paragraphs.wrap(content, "[color=#{color}]", "[/color]")
56
+ end
57
+
58
+ private
59
+
60
+ # @return [String, nil] the color to write, nil for the text only
61
+ def bbcode_color(value, interface)
62
+ return if text_only?(value, interface)
63
+ raise UndecidedFeatureError, UNDECIDED if @bbcode.nil?
64
+
65
+ mapped = @colors ? apply(@colors, value) : value
66
+ return mapped if mapped.nil? || VALUE.match?(mapped)
67
+
68
+ raise ArgumentError,
69
+ "color #{value.inspect} maps to #{mapped.inspect}, discourse-bbcode accepts " \
70
+ "#rgb, #rrggbb or a name; pass colors: to map it, or to drop it with nil"
71
+ end
72
+
73
+ def apply(mapping, value)
74
+ mapping.respond_to?(:call) ? mapping.call(value) : mapping.fetch(value)
75
+ end
76
+
77
+ # No value, the plugin decided against, or a link around it: the
78
+ # text is all there can be, and no decision changes that.
79
+ def text_only?(value, interface)
80
+ value.to_s.match?(/\A[[:space:]]*\z/) || @bbcode == false ||
81
+ interface.has_parent?(AST::Url) || interface.has_parent?(AST::Email)
22
82
  end
23
83
  end
24
84
  end
@@ -4,37 +4,68 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Renders {AST::Details} as a Discourse +[details=…]…[/details]+
8
- # collapsible block.
7
+ # Renders {AST::Details} as the +[details="…"]…[/details]+ block of
8
+ # the discourse-details plugin.
9
9
  #
10
- # Markdown form: the BBCode is bracketed with leading and trailing
11
- # blank lines so consecutive details blocks don't merge and
12
- # adjacent inline content starts a new paragraph against the
13
- # block. Inside, the rendered children are stripped so the
14
- # +[details]+ opener and the body sit on adjacent lines without
15
- # a stray blank between them — matching Discourse's BBCode
16
- # parser expectations.
10
+ # Markdown form: the BBCode has blank lines around it, so
11
+ # consecutive details blocks don't merge and inline content next
12
+ # to the block starts a new paragraph. The content inside is
13
+ # stripped, so the opening tag and the body stand on adjacent
14
+ # lines.
17
15
  #
18
- # HTML-block form (inside a CommonMark HTML block — when
19
- # +interface.html_mode?+ is +true+): a raw
20
- # +<details><summary>…</summary>…</details>+ element. The
21
- # +title+ is HTML-escaped for the +<summary>+ text.
16
+ # html_mode form: a raw +<details><summary>…</summary>…</details>+,
17
+ # with the title HTML-escaped.
18
+ #
19
+ # With +bbcode: false+, for a site without the plugin, the title is
20
+ # a paragraph in bold in front of the content. Discourse core's
21
+ # sanitizer has no +<details>+, so there is no HTML form.
22
22
  class DetailsTag < Tag
23
23
  DEFAULT_TITLE = "Summary"
24
24
  private_constant :DEFAULT_TITLE
25
25
 
26
+ # @param bbcode [Boolean] write the BBCode of the plugin
27
+ def initialize(bbcode: true)
28
+ @bbcode = bbcode
29
+ end
30
+
26
31
  def render(element, interface)
27
32
  child_context = interface.with_parent(element)
28
33
  content = interface.render_children(element, context: child_context).strip
29
34
 
30
35
  # A details block with nothing to show renders to nothing —
31
36
  # an empty [details] shell would only add noise to the output.
32
- return "" if content.empty?
37
+ # It is still a block, so a blank line keeps the text in front
38
+ # and after it apart, except in an HTML block, which a blank
39
+ # line would end.
40
+ return interface.html_mode? ? "" : "\n\n" if content.empty?
33
41
 
42
+ return without_plugin(element, content, interface) unless @bbcode
34
43
  return render_html(element.title, content) if interface.html_mode?
35
44
 
45
+ # Blank lines keep the content apart from the two tag lines:
46
+ # a table right in front of the closing tag would take the
47
+ # tag line as a row, and a `---` right after the opening tag
48
+ # would make it a heading. Discourse finds the closing tag
49
+ # line first and reads the lines between as blocks, so the
50
+ # blank lines change nothing there.
36
51
  opener = element.title ? %([details="#{element.title}"]) : "[details]"
37
- "\n\n#{opener}\n#{content}\n[/details]\n\n"
52
+ "\n\n#{opener}\n\n#{content}\n\n[/details]\n\n"
53
+ end
54
+
55
+ # The plain form starts with the content when there is no title,
56
+ # and ends with the content in any case, so a list at that edge
57
+ # of the content is at the edge of the output (see
58
+ # Tag#leading_list). The BBCode form has its own lines there.
59
+ def leading_list(element, interface)
60
+ return nil if @bbcode || !element.title.to_s.strip.empty?
61
+
62
+ interface.leading_list(element, children: true)
63
+ end
64
+
65
+ def trailing_list(element, interface)
66
+ return nil if @bbcode
67
+
68
+ interface.trailing_list(element, children: true)
38
69
  end
39
70
 
40
71
  private
@@ -43,6 +74,22 @@ module Markbridge
43
74
  label = title ? HtmlEscaper.escape(title) : DEFAULT_TITLE
44
75
  "<details><summary>#{label}</summary>#{content}</details>"
45
76
  end
77
+
78
+ # The title as a paragraph in bold, rendered like any paragraph,
79
+ # then the content. In html_mode a div keeps the block apart from
80
+ # the text around it, as the blank lines do in Markdown.
81
+ def without_plugin(element, content, interface)
82
+ title = element.title.to_s.strip
83
+ heading = ""
84
+ unless title.empty?
85
+ paragraph = AST::Paragraph.new << (AST::Bold.new << AST::Text.new(title))
86
+ heading = interface.render_node(paragraph)
87
+ end
88
+ return "<div>#{heading}#{content}</div>" if interface.html_mode?
89
+
90
+ heading = "#{heading.strip}\n\n" unless heading.empty?
91
+ "\n\n#{heading}#{content}\n\n"
92
+ end
46
93
  end
47
94
  end
48
95
  end
@@ -4,20 +4,19 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Tag for rendering email links
7
+ # Mail links: a link token pair with a mailto: destination around
8
+ # the children. Without an address the children stand alone;
9
+ # without visible text the address is written as a bare URL.
8
10
  class EmailTag < Tag
9
11
  def render(element, interface)
10
- child_context = interface.with_parent(element)
11
- text = interface.render_children(element, context: child_context)
12
+ tokens = interface.render_tokens(element, context: interface.with_parent(element))
12
13
  address = element.address
14
+ return tokens unless address
13
15
 
14
- return text unless address
16
+ destination = "mailto:#{address}"
17
+ return [Inline::Tokens.bare_url(destination)] unless Inline::Tokens.visible?(tokens)
15
18
 
16
- if interface.html_mode?
17
- %(<a href="mailto:#{HtmlEscaper.escape(address)}">#{text}</a>)
18
- else
19
- "[#{text}](mailto:#{address})"
20
- end
19
+ tokens.unshift(Inline::Tokens.link_open(destination)) << Inline::Tokens.link_close
21
20
  end
22
21
  end
23
22
  end