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.
- checksums.yaml +4 -4
- data/lib/markbridge/ast/image.rb +9 -1
- data/lib/markbridge/ast/url.rb +29 -4
- data/lib/markbridge/errors.rb +9 -0
- data/lib/markbridge/parsers/bbcode/handler_registry.rb +6 -1
- data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +2 -1
- data/lib/markbridge/parsers/bbcode/handlers/url_handler.rb +26 -2
- data/lib/markbridge/parsers/html/handlers/image_handler.rb +4 -1
- data/lib/markbridge/parsers/html/parser.rb +57 -19
- data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +3 -2
- data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +4 -2
- data/lib/markbridge/parsers/text_formatter/parser.rb +107 -14
- data/lib/markbridge/renderers/discourse/html_block.rb +50 -7
- data/lib/markbridge/renderers/discourse/inline/joins.rb +382 -0
- data/lib/markbridge/renderers/discourse/inline/serializer.rb +738 -0
- data/lib/markbridge/renderers/discourse/inline/tokens.rb +182 -0
- data/lib/markbridge/renderers/discourse/link_destination.rb +43 -0
- data/lib/markbridge/renderers/discourse/markdown_escaper.rb +105 -45
- data/lib/markbridge/renderers/discourse/paragraphs.rb +88 -0
- data/lib/markbridge/renderers/discourse/postprocessor.rb +183 -7
- data/lib/markbridge/renderers/discourse/render_context.rb +38 -12
- data/lib/markbridge/renderers/discourse/renderer.rb +336 -92
- data/lib/markbridge/renderers/discourse/rendering_interface.rb +123 -44
- data/lib/markbridge/renderers/discourse/tag.rb +53 -9
- data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +4 -6
- data/lib/markbridge/renderers/discourse/tags/code_tag.rb +48 -10
- data/lib/markbridge/renderers/discourse/tags/color_tag.rb +73 -13
- data/lib/markbridge/renderers/discourse/tags/details_tag.rb +62 -15
- data/lib/markbridge/renderers/discourse/tags/email_tag.rb +8 -9
- data/lib/markbridge/renderers/discourse/tags/event_tag.rb +31 -9
- data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +20 -1
- data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +12 -1
- data/lib/markbridge/renderers/discourse/tags/image_tag.rb +13 -27
- data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +4 -6
- data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +9 -2
- data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +75 -9
- data/lib/markbridge/renderers/discourse/tags/list_tag.rb +29 -7
- data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +12 -0
- data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +13 -6
- data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +52 -9
- data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +26 -1
- data/lib/markbridge/renderers/discourse/tags/size_tag.rb +78 -13
- data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +72 -14
- data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +5 -6
- data/lib/markbridge/renderers/discourse/tags/subscript_tag.rb +11 -5
- data/lib/markbridge/renderers/discourse/tags/superscript_tag.rb +11 -5
- data/lib/markbridge/renderers/discourse/tags/table_tag.rb +48 -28
- data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +3 -10
- data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +90 -43
- data/lib/markbridge/renderers/discourse/tags/url_tag.rb +43 -26
- data/lib/markbridge/renderers/discourse.rb +6 -0
- data/lib/markbridge/rspec.rb +4 -2
- data/lib/markbridge/version.rb +1 -1
- data/lib/markbridge.rb +90 -1
- 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 (``), 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
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
#
|
|
9
|
-
#
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
8
|
-
#
|
|
7
|
+
# Renders {AST::Details} as the +[details="…"]…[/details]+ block of
|
|
8
|
+
# the discourse-details plugin.
|
|
9
9
|
#
|
|
10
|
-
# Markdown form: the BBCode
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# +
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
16
|
+
destination = "mailto:#{address}"
|
|
17
|
+
return [Inline::Tokens.bare_url(destination)] unless Inline::Tokens.visible?(tokens)
|
|
15
18
|
|
|
16
|
-
|
|
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
|