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
@@ -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
- # Note: Discourse doesn't support inline size changes by default
9
- # Renders as plain text with HTML comment noting the size was lost
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
- child_context = interface.with_parent(element)
13
- content = interface.render_children(element, context: child_context)
14
-
15
- if element.size
16
- # Render as HTML span with style - requires HTML to be enabled
17
- # Alternative: just output the text without size
18
- "<span style=\"font-size: #{element.size}px\">#{content}</span>"
19
- else
20
- content
21
- end
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
- # Tag for rendering spoilers
8
- # Renders to Discourse BBCode spoiler format
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 renders to nothing — an empty
15
- # [spoiler] shell would only add noise to the output.
16
- return "" if content.empty?
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
- return render_html(element.title, content) if interface.html_mode?
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
- if element.title
21
- "[spoiler=#{element.title}]#{content}[/spoiler]"
22
- else
23
- "[spoiler]#{content}[/spoiler]"
24
- end
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
- def render_html(title, content)
30
- summary = "<summary>#{title ? HtmlEscaper.escape(title) : "Spoiler"}</summary>"
31
- "<details>#{summary}#{content}</details>"
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
- # Tag for rendering strikethrough text
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
- child_context = interface.with_parent(element)
11
- content = interface.render_children(element, context: child_context)
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
- # Renders to HTML <sub> tag
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
- child_context = interface.with_parent(element)
12
- content = interface.render_children(element, context: child_context)
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
- # Renders to HTML <sup> tag
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
- child_context = interface.with_parent(element)
12
- content = interface.render_children(element, context: child_context)
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
- def markdown_compatible?(rows_data, interface)
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
- # Render as Markdown pipe table
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
- header_idx = rows_data.index { |r| r[:cells].all? { |c| c[:header] } }
62
- header_row = header_idx ? rows_data[header_idx] : rows_data.first
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
- has_header = rows_data.any? { |r| r[:cells].any? { |c| c[:header] } }
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 has_header
93
- header_rows, body_rows = rows_data.partition { |r| r[:cells].all? { |c| c[:header] } }
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
- unless header_rows.empty?
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
- body_rows.each { |row| lines << html_row(row) }
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
- "\n\n#{lines.join("\n")}\n\n"
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
- child_context = interface.with_parent(element)
18
- content = interface.render_children(element, context: child_context)
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
- if interface.html_mode?
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
- # "<<UPLOAD:#{element.sha1}>>"
21
+ # like = "![image](upload://#{element.sha1})"
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 build_upload_html(element) if interface.html_mode?
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
- def build_upload_markdown(element)
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
- def build_image_markdown(element)
52
- alt = build_alt(element)
53
- url = build_upload_url(element)
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
  "![#{alt}](#{url})"
56
111
  end
57
112
 
58
- def build_attachment_markdown(element)
59
- filename = element.filename || "attachment"
60
- url = build_upload_url(element)
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}|attachment](#{url})#{size_part}"
118
+ "[#{filename}#{pipe(interface)}attachment](#{url})#{size_part}"
64
119
  end
65
120
 
66
- def build_alt(element)
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
- def build_upload_url(element)
75
- filename = element.filename || element.sha1
76
- "upload://#{filename}"
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
- # html_mode reconstructs from the AST fields rather than reusing
80
- # element.raw — raw is Markdown, which CommonMark passes through
81
- # unchanged inside an HTML block (so the user would see the
82
- # literal "![…](upload://…)" string instead of an image).
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 build_attachment_html(element)
98
- href = HtmlEscaper.escape(build_upload_url(element))
99
- filename = HtmlEscaper.escape(element.filename || "attachment")
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