markbridge 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/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 +41 -31
- 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
|
@@ -4,21 +4,86 @@ module Markbridge
|
|
|
4
4
|
module Renderers
|
|
5
5
|
module Discourse
|
|
6
6
|
module Tags
|
|
7
|
-
# Tag for rendering sized text
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# Tag for rendering sized text.
|
|
8
|
+
#
|
|
9
|
+
# Discourse core removes the style of a span. Only the
|
|
10
|
+
# discourse-bbcode plugin can show sizes, and it reads
|
|
11
|
+
# +[size=…]+ as a percentage, as phpBB 3.x writes it. So the tag
|
|
12
|
+
# needs to know two things: whether the site has the plugin, and
|
|
13
|
+
# how the values of the source forum map to percent. Until it
|
|
14
|
+
# knows the first, a size raises {UndecidedFeatureError}.
|
|
15
|
+
#
|
|
16
|
+
# - +bbcode: nil+ (the default): a size raises.
|
|
17
|
+
# - +bbcode: false+: the text only, as inline tokens that the
|
|
18
|
+
# serializer joins with the neighbors.
|
|
19
|
+
# - +bbcode: true+: +[size=…]…[/size]+, with the value mapped
|
|
20
|
+
# through +sizes:+. Without a mapping the value is taken as it
|
|
21
|
+
# is. A value that is not a whole number from 1 to 999 after the
|
|
22
|
+
# mapping raises ArgumentError; a mapping that returns nil drops
|
|
23
|
+
# the size on purpose.
|
|
24
|
+
#
|
|
25
|
+
# Inside a link the text is written without BBCode in every case,
|
|
26
|
+
# Discourse doesn't render BBCode in the text of a link.
|
|
10
27
|
class SizeTag < Tag
|
|
28
|
+
# A whole number from 1 to 999, the range the plugin lets through.
|
|
29
|
+
VALUE = /\A[1-9]\d{0,2}\z/
|
|
30
|
+
private_constant :VALUE
|
|
31
|
+
|
|
32
|
+
UNDECIDED =
|
|
33
|
+
"a [size] tag needs a decision: pass features: { bbcode: false } to " \
|
|
34
|
+
"Markbridge.discourse_renderer to write the text only, or " \
|
|
35
|
+
"features: { bbcode: true } with sizes: for the discourse-bbcode plugin"
|
|
36
|
+
private_constant :UNDECIDED
|
|
37
|
+
|
|
38
|
+
# @param bbcode [Boolean, nil] whether the site has discourse-bbcode
|
|
39
|
+
# @param sizes [Hash, #call, nil] the mapping from the source value
|
|
40
|
+
# to percent. A Hash is read with +fetch+, so an unknown value
|
|
41
|
+
# raises KeyError.
|
|
42
|
+
def initialize(bbcode: nil, sizes: nil)
|
|
43
|
+
@bbcode = bbcode
|
|
44
|
+
@sizes = sizes
|
|
45
|
+
end
|
|
46
|
+
|
|
11
47
|
def render(element, interface)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
48
|
+
context = interface.with_parent(element)
|
|
49
|
+
percent = bbcode_percent(element.size, interface)
|
|
50
|
+
return interface.render_tokens(element, context:) if percent.nil?
|
|
51
|
+
|
|
52
|
+
content = interface.render_children(element, context:)
|
|
53
|
+
# The style that the discourse-bbcode plugin lets through.
|
|
54
|
+
return %(<span style="font-size:#{percent}%">#{content}</span>) if interface.html_mode?
|
|
55
|
+
|
|
56
|
+
Paragraphs.wrap(content, "[size=#{percent}]", "[/size]")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# @return [String, nil] the percent to write, nil for the text only
|
|
62
|
+
def bbcode_percent(value, interface)
|
|
63
|
+
return if text_only?(value, interface)
|
|
64
|
+
raise UndecidedFeatureError, UNDECIDED if @bbcode.nil?
|
|
65
|
+
|
|
66
|
+
mapped = @sizes ? apply(@sizes, value) : value
|
|
67
|
+
return if mapped.nil?
|
|
68
|
+
|
|
69
|
+
text = mapped.to_s
|
|
70
|
+
return text if VALUE.match?(text)
|
|
71
|
+
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"size #{value.inspect} maps to #{mapped.inspect}, discourse-bbcode accepts a " \
|
|
74
|
+
"whole number from 1 to 999 (percent); pass sizes: to map it, or to drop " \
|
|
75
|
+
"it with nil"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def apply(mapping, value)
|
|
79
|
+
mapping.respond_to?(:call) ? mapping.call(value) : mapping.fetch(value)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# No value, the plugin decided against, or a link around it: the
|
|
83
|
+
# text is all there can be, and no decision changes that.
|
|
84
|
+
def text_only?(value, interface)
|
|
85
|
+
value.to_s.match?(/\A[[:space:]]*\z/) || @bbcode == false ||
|
|
86
|
+
interface.has_parent?(AST::Url) || interface.has_parent?(AST::Email)
|
|
22
87
|
end
|
|
23
88
|
end
|
|
24
89
|
end
|
|
@@ -4,31 +4,89 @@ module Markbridge
|
|
|
4
4
|
module Renderers
|
|
5
5
|
module Discourse
|
|
6
6
|
module Tags
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# Renders a spoiler as the `[spoiler]…[/spoiler]` BBCode of the
|
|
8
|
+
# spoiler-alert plugin. Discourse never reads an attribute of the
|
|
9
|
+
# tag, so a title is written as text in front of the spoiler:
|
|
10
|
+
# `title: [spoiler]…[/spoiler]`.
|
|
11
|
+
#
|
|
12
|
+
# With +bbcode: false+, for a site without the plugin, the content
|
|
13
|
+
# is written as it is, so the hidden text shows.
|
|
9
14
|
class SpoilerTag < Tag
|
|
15
|
+
# @param bbcode [Boolean] write the BBCode of the plugin
|
|
16
|
+
def initialize(bbcode: true)
|
|
17
|
+
@bbcode = bbcode
|
|
18
|
+
end
|
|
19
|
+
|
|
10
20
|
def render(element, interface)
|
|
11
21
|
child_context = interface.with_parent(element)
|
|
12
22
|
content = interface.render_children(element, context: child_context)
|
|
13
23
|
|
|
14
|
-
# A spoiler with nothing to hide
|
|
15
|
-
# [spoiler] shell would only
|
|
16
|
-
|
|
24
|
+
# A spoiler with nothing to hide gets no tags, like the
|
|
25
|
+
# serializer's markers: an empty [spoiler] shell would only
|
|
26
|
+
# add noise to the output.
|
|
27
|
+
return content unless content.match?(/[^[:space:]]/)
|
|
17
28
|
|
|
18
|
-
|
|
29
|
+
title = title_text(element, interface)
|
|
30
|
+
# Discourse doesn't cook BBCode in the text of a link.
|
|
31
|
+
return "#{title}#{content}" if !@bbcode || inside_link?(interface)
|
|
32
|
+
return "#{title}#{render_html(content)}" if interface.html_mode?
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
# `[spoiler]:x` at the start of a line reads as a link
|
|
35
|
+
# reference definition, the backslash keeps the colon text.
|
|
36
|
+
content = "\\#{content}" if content.start_with?(":")
|
|
37
|
+
output = "#{title}[spoiler]#{content}[/spoiler]"
|
|
38
|
+
# A line that starts with `[spoiler]` and ends with `[/spoiler]`
|
|
39
|
+
# is one block spoiler to Discourse, whatever stands between, so
|
|
40
|
+
# two spoilers on it show their inner tags as text. A comment
|
|
41
|
+
# after the second one ends the line with something else.
|
|
42
|
+
output << Inline::Joins::BOUNDARY_COMMENT if after_spoiler?(element, interface)
|
|
43
|
+
output
|
|
25
44
|
end
|
|
26
45
|
|
|
27
46
|
private
|
|
28
47
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
48
|
+
# Whether a spoiler stands in front of +element+ on the same line,
|
|
49
|
+
# with anything inline between. A text that has something visible
|
|
50
|
+
# after its last newline starts the line, so the search ends
|
|
51
|
+
# there. A spoiler with a title starts its line with the title,
|
|
52
|
+
# not with `[spoiler]`, and is looked over like text.
|
|
53
|
+
def after_spoiler?(element, interface)
|
|
54
|
+
offset = -1
|
|
55
|
+
loop do
|
|
56
|
+
node = interface.sibling(element, offset:)
|
|
57
|
+
return false if node.nil?
|
|
58
|
+
return true if node.is_a?(AST::Spoiler) && !titled?(node)
|
|
59
|
+
return false if node.is_a?(AST::Text) && starts_line?(node.text)
|
|
60
|
+
|
|
61
|
+
offset -= 1
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def starts_line?(text)
|
|
66
|
+
text.include?("\n") && text.rpartition("\n").last.match?(/[^[:space:]]/)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def titled?(element)
|
|
70
|
+
element.title.to_s.match?(/[^[:space:]]/)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The title with a colon and a space after it, escaped like any
|
|
74
|
+
# text, or nil without a title.
|
|
75
|
+
def title_text(element, interface)
|
|
76
|
+
return unless titled?(element)
|
|
77
|
+
|
|
78
|
+
"#{interface.render_node(AST::Text.new(element.title.strip))}: "
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The inline form Discourse cooks the BBCode to. A <details>
|
|
82
|
+
# would be a block, which an HTML parser moves out of the
|
|
83
|
+
# emphasis around it.
|
|
84
|
+
def render_html(content)
|
|
85
|
+
%(<span class="spoiler">#{content}</span>)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def inside_link?(interface)
|
|
89
|
+
interface.has_parent?(AST::Url) || interface.has_parent?(AST::Email)
|
|
32
90
|
end
|
|
33
91
|
end
|
|
34
92
|
end
|
|
@@ -4,14 +4,13 @@ module Markbridge
|
|
|
4
4
|
module Renderers
|
|
5
5
|
module Discourse
|
|
6
6
|
module Tags
|
|
7
|
-
#
|
|
7
|
+
# Strikethrough. The serializer writes the tildes, or an s tag
|
|
8
|
+
# where a tilde at the edge of the content would make the marker
|
|
9
|
+
# three tildes long.
|
|
8
10
|
class StrikethroughTag < Tag
|
|
9
11
|
def render(element, interface)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return "<s>#{content}</s>" if interface.html_mode?
|
|
13
|
-
|
|
14
|
-
interface.wrap_inline(content, "~~")
|
|
12
|
+
tokens = interface.render_tokens(element, context: interface.with_parent(element))
|
|
13
|
+
tokens.unshift(Inline::Tokens.open(:s)) << Inline::Tokens.close(:s)
|
|
15
14
|
end
|
|
16
15
|
end
|
|
17
16
|
end
|
|
@@ -4,13 +4,19 @@ module Markbridge
|
|
|
4
4
|
module Renderers
|
|
5
5
|
module Discourse
|
|
6
6
|
module Tags
|
|
7
|
-
# Tag for rendering subscript text
|
|
8
|
-
#
|
|
7
|
+
# Tag for rendering subscript text as the HTML tag, which Markdown
|
|
8
|
+
# has no form for. The tags are inline tokens so that the
|
|
9
|
+
# serializer sees them as neighbors: a newline right after the
|
|
10
|
+
# opening tag would leave it alone on its line, where it opens an
|
|
11
|
+
# HTML block.
|
|
9
12
|
class SubscriptTag < Tag
|
|
13
|
+
OPEN = Inline::Tokens.html("<sub>")
|
|
14
|
+
CLOSE = Inline::Tokens.html("</sub>")
|
|
15
|
+
private_constant :OPEN, :CLOSE
|
|
16
|
+
|
|
10
17
|
def render(element, interface)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"<sub>#{content}</sub>"
|
|
18
|
+
tokens = interface.render_tokens(element, context: interface.with_parent(element))
|
|
19
|
+
tokens.unshift(OPEN) << CLOSE
|
|
14
20
|
end
|
|
15
21
|
end
|
|
16
22
|
end
|
|
@@ -4,13 +4,19 @@ module Markbridge
|
|
|
4
4
|
module Renderers
|
|
5
5
|
module Discourse
|
|
6
6
|
module Tags
|
|
7
|
-
# Tag for rendering superscript text
|
|
8
|
-
#
|
|
7
|
+
# Tag for rendering superscript text as the HTML tag, which Markdown
|
|
8
|
+
# has no form for. The tags are inline tokens so that the
|
|
9
|
+
# serializer sees them as neighbors: a newline right after the
|
|
10
|
+
# opening tag would leave it alone on its line, where it opens an
|
|
11
|
+
# HTML block.
|
|
9
12
|
class SuperscriptTag < Tag
|
|
13
|
+
OPEN = Inline::Tokens.html("<sup>")
|
|
14
|
+
CLOSE = Inline::Tokens.html("</sup>")
|
|
15
|
+
private_constant :OPEN, :CLOSE
|
|
16
|
+
|
|
10
17
|
def render(element, interface)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"<sup>#{content}</sup>"
|
|
18
|
+
tokens = interface.render_tokens(element, context: interface.with_parent(element))
|
|
19
|
+
tokens.unshift(OPEN) << CLOSE
|
|
14
20
|
end
|
|
15
21
|
end
|
|
16
22
|
end
|
|
@@ -6,19 +6,37 @@ module Markbridge
|
|
|
6
6
|
module Tags
|
|
7
7
|
# Tag for rendering tables as Markdown pipe tables with HTML fallback
|
|
8
8
|
class TableTag < Tag
|
|
9
|
+
# Nodes a Markdown table cell cannot hold: they render with a
|
|
10
|
+
# marker that a cell reads as text. A block of code is not here,
|
|
11
|
+
# its fence lines make the cell content more than one line.
|
|
12
|
+
BLOCK_NODES = [
|
|
13
|
+
AST::List,
|
|
14
|
+
AST::Quote,
|
|
15
|
+
AST::Heading,
|
|
16
|
+
AST::HorizontalRule,
|
|
17
|
+
AST::Table,
|
|
18
|
+
AST::Details,
|
|
19
|
+
AST::Align,
|
|
20
|
+
AST::Poll,
|
|
21
|
+
AST::Event,
|
|
22
|
+
].freeze
|
|
23
|
+
private_constant :BLOCK_NODES
|
|
24
|
+
|
|
9
25
|
def render(element, interface)
|
|
10
26
|
child_context = interface.with_parent(element)
|
|
11
27
|
rows_data = extract_rows(element, interface, child_context)
|
|
12
28
|
|
|
13
29
|
return "" if rows_data.empty?
|
|
14
30
|
|
|
15
|
-
if markdown_compatible?(rows_data, interface)
|
|
31
|
+
if markdown_compatible?(element, rows_data, interface)
|
|
16
32
|
render_markdown(rows_data)
|
|
17
33
|
else
|
|
18
34
|
# Re-render cells in html_mode so inline Markdown like **bold** becomes
|
|
19
35
|
# <strong>bold</strong>; CommonMark would not parse Markdown inside an HTML block.
|
|
20
36
|
html_rows = extract_rows(element, interface, child_context.with_html_mode(true))
|
|
21
|
-
render_html(html_rows)
|
|
37
|
+
html = render_html(html_rows)
|
|
38
|
+
# Inside an HTML block a blank line would end the block.
|
|
39
|
+
interface.html_mode? ? html : "\n\n#{html}\n\n"
|
|
22
40
|
end
|
|
23
41
|
end
|
|
24
42
|
|
|
@@ -45,9 +63,15 @@ module Markbridge
|
|
|
45
63
|
end
|
|
46
64
|
end
|
|
47
65
|
|
|
48
|
-
# Check if the table can be rendered as Markdown
|
|
49
|
-
|
|
66
|
+
# Check if the table can be rendered as Markdown: not nested in a
|
|
67
|
+
# table, no block in a cell, the header row (when there is one)
|
|
68
|
+
# first, every row as wide as the first, no cell on two lines.
|
|
69
|
+
def markdown_compatible?(element, rows_data, interface)
|
|
50
70
|
return false if interface.has_parent?(AST::Table)
|
|
71
|
+
return false if block_in_cell?(element)
|
|
72
|
+
|
|
73
|
+
header_idx = rows_data.index { |r| r[:cells].all? { |c| c[:header] } }
|
|
74
|
+
return false if header_idx&.positive?
|
|
51
75
|
|
|
52
76
|
cell_count = rows_data.first[:cells].length
|
|
53
77
|
rows_data.all? do |row|
|
|
@@ -56,18 +80,15 @@ module Markbridge
|
|
|
56
80
|
end
|
|
57
81
|
end
|
|
58
82
|
|
|
59
|
-
|
|
83
|
+
def block_in_cell?(element)
|
|
84
|
+
element.descendants.any? { |node| BLOCK_NODES.include?(node.class) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Render as Markdown pipe table. The first row is the header
|
|
88
|
+
# row: markdown_compatible? lets no other header row through.
|
|
60
89
|
def render_markdown(rows_data)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
data_rows =
|
|
64
|
-
(
|
|
65
|
-
if header_idx
|
|
66
|
-
rows_data[0...header_idx] + rows_data[(header_idx + 1)..]
|
|
67
|
-
else
|
|
68
|
-
rows_data[1..]
|
|
69
|
-
end
|
|
70
|
-
)
|
|
90
|
+
header_row = rows_data.first
|
|
91
|
+
data_rows = rows_data[1..]
|
|
71
92
|
|
|
72
93
|
col_count = header_row[:cells].length
|
|
73
94
|
lines = []
|
|
@@ -84,23 +105,22 @@ module Markbridge
|
|
|
84
105
|
"| #{cells.map { |c| c[:content] }.join(" | ")} |"
|
|
85
106
|
end
|
|
86
107
|
|
|
87
|
-
# Render as HTML table
|
|
108
|
+
# Render as HTML table. The header rows at the top go into a
|
|
109
|
+
# <thead>, the rest into a <tbody>; the rows keep their order, a
|
|
110
|
+
# header row further down stays where it is.
|
|
88
111
|
def render_html(rows_data)
|
|
89
|
-
|
|
112
|
+
header_count =
|
|
113
|
+
rows_data.index { |r| !r[:cells].all? { |c| c[:header] } } || rows_data.length
|
|
90
114
|
lines = ["<table>"]
|
|
91
115
|
|
|
92
|
-
if
|
|
93
|
-
|
|
116
|
+
if header_count.positive?
|
|
117
|
+
lines << "<thead>"
|
|
118
|
+
rows_data[0...header_count].each { |row| lines << html_row(row) }
|
|
119
|
+
lines << "</thead>"
|
|
94
120
|
|
|
95
|
-
|
|
96
|
-
lines << "<thead>"
|
|
97
|
-
header_rows.each { |row| lines << html_row(row) }
|
|
98
|
-
lines << "</thead>"
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
unless body_rows.empty?
|
|
121
|
+
if header_count < rows_data.length
|
|
102
122
|
lines << "<tbody>"
|
|
103
|
-
|
|
123
|
+
rows_data[header_count..].each { |row| lines << html_row(row) }
|
|
104
124
|
lines << "</tbody>"
|
|
105
125
|
end
|
|
106
126
|
else
|
|
@@ -108,7 +128,7 @@ module Markbridge
|
|
|
108
128
|
end
|
|
109
129
|
|
|
110
130
|
lines << "</table>"
|
|
111
|
-
|
|
131
|
+
lines.join("\n")
|
|
112
132
|
end
|
|
113
133
|
|
|
114
134
|
# Render a single HTML table row
|
|
@@ -14,17 +14,10 @@ module Markbridge
|
|
|
14
14
|
# literally. Drop the wrapper when rendering under a link ancestor.
|
|
15
15
|
class UnderlineTag < Tag
|
|
16
16
|
def render(element, interface)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return content unless content.match?(/[^[:space:]]/)
|
|
17
|
+
tokens = interface.render_tokens(element, context: interface.with_parent(element))
|
|
18
|
+
return tokens if inside_link?(interface)
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
%(<span class="bbcode-u">#{content}</span>)
|
|
23
|
-
elsif inside_link?(interface)
|
|
24
|
-
content
|
|
25
|
-
else
|
|
26
|
-
"[u]#{content}[/u]"
|
|
27
|
-
end
|
|
20
|
+
tokens.unshift(Inline::Tokens.open(:u)) << Inline::Tokens.close(:u)
|
|
28
21
|
end
|
|
29
22
|
|
|
30
23
|
private
|
|
@@ -11,9 +11,15 @@ module Markbridge
|
|
|
11
11
|
# to render uploads as placeholders or resolve upload:// URLs.
|
|
12
12
|
#
|
|
13
13
|
# @example Custom renderer with placeholder
|
|
14
|
+
# # An upload is inline, so the placeholder token says what the
|
|
15
|
+
# # importer will put there (see "Placeholders" in the migration
|
|
16
|
+
# # guide): the renderer joins it with its neighbors like that.
|
|
17
|
+
# Tokens = Markbridge::Renderers::Discourse::Inline::Tokens
|
|
18
|
+
#
|
|
14
19
|
# class MyUploadTag < Markbridge::Renderers::Discourse::Tags::UploadTag
|
|
15
20
|
# def render(element, interface)
|
|
16
|
-
# "
|
|
21
|
+
# like = ""
|
|
22
|
+
# [Tokens.placeholder("<<UPLOAD:#{element.sha1}>>", like:)]
|
|
17
23
|
# end
|
|
18
24
|
# end
|
|
19
25
|
#
|
|
@@ -29,76 +35,117 @@ module Markbridge
|
|
|
29
35
|
# end
|
|
30
36
|
# end
|
|
31
37
|
class UploadTag < Tag
|
|
38
|
+
# An upload is always written in one of Discourse's Markdown
|
|
39
|
+
# forms, through RenderingInterface#markdown_island: Discourse
|
|
40
|
+
# reads the dimensions and the `attachment` marker only from the
|
|
41
|
+
# Markdown, and its sanitizer drops the `upload://` href of a
|
|
42
|
+
# raw HTML link.
|
|
32
43
|
def render(element, interface)
|
|
33
|
-
return
|
|
34
|
-
|
|
35
|
-
# Return raw Markdown if available, otherwise reconstruct
|
|
36
|
-
return element.raw if element.raw
|
|
44
|
+
return name_in_link(element, interface) if attachment_in_link?(element, interface)
|
|
37
45
|
|
|
38
|
-
build_upload_markdown(element)
|
|
46
|
+
interface.markdown_island(build_upload_markdown(element, interface), block: false)
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
private
|
|
42
50
|
|
|
43
|
-
|
|
51
|
+
# A file attachment is a link, and a link cannot hold a link:
|
|
52
|
+
# Markdown reads the inner one and leaves the brackets of the
|
|
53
|
+
# outer one as text. Like the normalizer unwraps a link inside a
|
|
54
|
+
# link, the attachment inside a link label is its file name. An
|
|
55
|
+
# image inside a link label is fine.
|
|
56
|
+
def attachment_in_link?(element, interface)
|
|
57
|
+
element.type == :attachment && element.raw.nil? &&
|
|
58
|
+
(interface.has_parent?(AST::Url) || interface.has_parent?(AST::Email))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The file name and the size as text, in the mode of the link
|
|
62
|
+
# around it: Markdown in a label, HTML inside an HTML block. The
|
|
63
|
+
# link is on the parent chain already, which is all the text
|
|
64
|
+
# needs for the escaping of a label.
|
|
65
|
+
def name_in_link(element, interface)
|
|
66
|
+
name = interface.render_node(AST::Text.new(element.filename || "attachment"))
|
|
67
|
+
size_part = " (#{element.size})" if element.size
|
|
68
|
+
|
|
69
|
+
"#{name}#{size_part}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_upload_markdown(element, interface)
|
|
73
|
+
return raw(element, interface) if element.raw
|
|
74
|
+
|
|
44
75
|
if element.type == :image
|
|
45
|
-
build_image_markdown(element)
|
|
76
|
+
build_image_markdown(element, interface)
|
|
46
77
|
else
|
|
47
|
-
build_attachment_markdown(element)
|
|
78
|
+
build_attachment_markdown(element, interface)
|
|
48
79
|
end
|
|
49
80
|
end
|
|
50
81
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
82
|
+
# The raw Markdown of the source, with the pipes escaped in a
|
|
83
|
+
# table cell. One that is escaped already stays as it is.
|
|
84
|
+
def raw(element, interface)
|
|
85
|
+
return element.raw unless markdown_cell?(interface)
|
|
86
|
+
|
|
87
|
+
element.raw.gsub(/(?<!\\)\|/, "\\\\|")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A `|` ends the cell of a Markdown table. Discourse keeps the
|
|
91
|
+
# pipes inside a link or an image since August 2026, plain
|
|
92
|
+
# markdown-it and older Discourse versions do not, and `\|` works
|
|
93
|
+
# in all of them: Discourse reads the dimensions and the attachment
|
|
94
|
+
# marker all the same.
|
|
95
|
+
def pipe(interface)
|
|
96
|
+
markdown_cell?(interface) ? "\\|" : "|"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The cell of a table written in Markdown. Inside a table written
|
|
100
|
+
# as HTML the island is a paragraph of its own, where a pipe ends
|
|
101
|
+
# nothing.
|
|
102
|
+
def markdown_cell?(interface)
|
|
103
|
+
interface.has_parent?(AST::TableCell) && !interface.html_mode?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_image_markdown(element, interface)
|
|
107
|
+
alt = build_alt(element, interface)
|
|
108
|
+
url = destination(element)
|
|
54
109
|
|
|
55
110
|
""
|
|
56
111
|
end
|
|
57
112
|
|
|
58
|
-
def build_attachment_markdown(element)
|
|
59
|
-
filename = element.filename || "attachment"
|
|
60
|
-
url =
|
|
113
|
+
def build_attachment_markdown(element, interface)
|
|
114
|
+
filename = label(element.filename || "attachment", element, interface)
|
|
115
|
+
url = destination(element)
|
|
61
116
|
size_part = " (#{element.size})" if element.size
|
|
62
117
|
|
|
63
|
-
"[#{filename}
|
|
118
|
+
"[#{filename}#{pipe(interface)}attachment](#{url})#{size_part}"
|
|
64
119
|
end
|
|
65
120
|
|
|
66
|
-
|
|
121
|
+
# The alt text is escaped for a link label, the dimensions are
|
|
122
|
+
# ours and stay as they are.
|
|
123
|
+
def build_alt(element, interface)
|
|
67
124
|
parts = []
|
|
68
|
-
parts << element.alt if element.alt
|
|
125
|
+
parts << label(element.alt, element, interface) if element.alt
|
|
69
126
|
parts << element.dimensions if element.dimensions
|
|
70
127
|
|
|
71
|
-
parts.join(
|
|
128
|
+
parts.join(pipe(interface))
|
|
72
129
|
end
|
|
73
130
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
131
|
+
# Text inside the brackets goes through the renderer like any
|
|
132
|
+
# text, so a `]` or Markdown in it is escaped. The label is
|
|
133
|
+
# Markdown inside an HTML table as well, so html_mode is off for
|
|
134
|
+
# it there.
|
|
135
|
+
def label(text, element, interface)
|
|
136
|
+
context = interface.with_html_mode(false).with_parent(element)
|
|
137
|
+
interface.render_node(AST::Text.new(text), context:)
|
|
77
138
|
end
|
|
78
139
|
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def build_upload_html(element)
|
|
84
|
-
if element.type == :image
|
|
85
|
-
build_image_html(element)
|
|
86
|
-
else
|
|
87
|
-
build_attachment_html(element)
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def build_image_html(element)
|
|
92
|
-
src = HtmlEscaper.escape(build_upload_url(element))
|
|
93
|
-
alt = HtmlEscaper.escape(element.alt)
|
|
94
|
-
%(<img src="#{src}" alt="#{alt}">)
|
|
140
|
+
# The destination gets the same treatment as the one of a link:
|
|
141
|
+
# a file name with a space or a parenthesis would end it early.
|
|
142
|
+
def destination(element)
|
|
143
|
+
LinkDestination.escape(build_upload_url(element))
|
|
95
144
|
end
|
|
96
145
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
filename
|
|
100
|
-
size_part = " (#{HtmlEscaper.escape(element.size)})" if element.size
|
|
101
|
-
%(<a href="#{href}">#{filename}</a>#{size_part})
|
|
146
|
+
def build_upload_url(element)
|
|
147
|
+
filename = element.filename || element.sha1
|
|
148
|
+
"upload://#{filename}"
|
|
102
149
|
end
|
|
103
150
|
end
|
|
104
151
|
end
|