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.
Files changed (53) 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/handlers/image_handler.rb +2 -1
  6. data/lib/markbridge/parsers/bbcode/handlers/url_handler.rb +26 -2
  7. data/lib/markbridge/parsers/html/handlers/image_handler.rb +4 -1
  8. data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +3 -2
  9. data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +4 -2
  10. data/lib/markbridge/parsers/text_formatter/parser.rb +107 -14
  11. data/lib/markbridge/renderers/discourse/html_block.rb +50 -7
  12. data/lib/markbridge/renderers/discourse/inline/joins.rb +382 -0
  13. data/lib/markbridge/renderers/discourse/inline/serializer.rb +738 -0
  14. data/lib/markbridge/renderers/discourse/inline/tokens.rb +182 -0
  15. data/lib/markbridge/renderers/discourse/link_destination.rb +43 -0
  16. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +105 -45
  17. data/lib/markbridge/renderers/discourse/paragraphs.rb +88 -0
  18. data/lib/markbridge/renderers/discourse/postprocessor.rb +183 -7
  19. data/lib/markbridge/renderers/discourse/render_context.rb +38 -12
  20. data/lib/markbridge/renderers/discourse/renderer.rb +336 -92
  21. data/lib/markbridge/renderers/discourse/rendering_interface.rb +123 -44
  22. data/lib/markbridge/renderers/discourse/tag.rb +53 -9
  23. data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +4 -6
  24. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +48 -10
  25. data/lib/markbridge/renderers/discourse/tags/color_tag.rb +73 -13
  26. data/lib/markbridge/renderers/discourse/tags/details_tag.rb +62 -15
  27. data/lib/markbridge/renderers/discourse/tags/email_tag.rb +8 -9
  28. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +31 -9
  29. data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +20 -1
  30. data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +12 -1
  31. data/lib/markbridge/renderers/discourse/tags/image_tag.rb +13 -27
  32. data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +4 -6
  33. data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +9 -2
  34. data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +75 -9
  35. data/lib/markbridge/renderers/discourse/tags/list_tag.rb +29 -7
  36. data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +12 -0
  37. data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +13 -6
  38. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +52 -9
  39. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +26 -1
  40. data/lib/markbridge/renderers/discourse/tags/size_tag.rb +78 -13
  41. data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +72 -14
  42. data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +5 -6
  43. data/lib/markbridge/renderers/discourse/tags/subscript_tag.rb +11 -5
  44. data/lib/markbridge/renderers/discourse/tags/superscript_tag.rb +11 -5
  45. data/lib/markbridge/renderers/discourse/tags/table_tag.rb +48 -28
  46. data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +3 -10
  47. data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +90 -43
  48. data/lib/markbridge/renderers/discourse/tags/url_tag.rb +41 -31
  49. data/lib/markbridge/renderers/discourse.rb +6 -0
  50. data/lib/markbridge/rspec.rb +4 -2
  51. data/lib/markbridge/version.rb +1 -1
  52. data/lib/markbridge.rb +90 -1
  53. metadata +7 -1
@@ -4,27 +4,49 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Placeholder tag for rendering events.
7
+ # Renders {AST::Event} as the +[event]+ BBCode of the discourse-events
8
+ # plugin: the raw BBCode of the source when the parser kept it,
9
+ # otherwise the BBCode built from the attributes.
8
10
  #
9
- # This is a STUB implementation that outputs the original raw BBCode.
10
- # Applications using Markbridge should provide their own custom tag
11
- # to render events as placeholders or convert them to another format.
11
+ # With +bbcode: false+, for a site without the plugin, the event is
12
+ # a paragraph with the name and the dates, so the reader still sees
13
+ # it.
12
14
  #
13
- # @example Custom renderer with placeholder
15
+ # The BBCode has no HTML form, so it goes through
16
+ # RenderingInterface#markdown_island: a block with blank lines
17
+ # around it, and a Markdown island in a `<div>` inside an HTML
18
+ # table. A placeholder for an event is written the same way (see
19
+ # "Placeholders" in the migration guide).
20
+ #
21
+ # @example A placeholder for the importer
14
22
  # class MyEventTag < Markbridge::Renderers::Discourse::Tags::EventTag
15
23
  # def render(element, interface)
16
24
  # id = register_event(element)
17
- # "<<EVENT:#{id}>>"
25
+ # interface.markdown_island("<<EVENT:#{id}>>", block: true)
18
26
  # end
19
27
  # end
20
28
  class EventTag < Tag
21
- def render(element, _interface)
22
- body = element.raw || build_event_bbcode(element)
23
- "\n\n#{body}\n\n"
29
+ # @param bbcode [Boolean] write the BBCode of the plugin
30
+ def initialize(bbcode: true)
31
+ @bbcode = bbcode
32
+ end
33
+
34
+ def render(element, interface)
35
+ return without_plugin(element, interface) unless @bbcode
36
+
37
+ interface.markdown_island(element.raw || build_event_bbcode(element), block: true)
24
38
  end
25
39
 
26
40
  private
27
41
 
42
+ def without_plugin(element, interface)
43
+ dates = [element.starts_at, element.ends_at].map(&:to_s).reject(&:empty?).join(" to ")
44
+ text = [element.name.to_s, dates].reject(&:empty?).join(", ")
45
+ return "" if text.empty?
46
+
47
+ interface.render_node(AST::Paragraph.new << AST::Text.new(text))
48
+ end
49
+
28
50
  def build_event_bbcode(element)
29
51
  attrs = build_attributes(element)
30
52
  "[event#{attrs}]\n[/event]"
@@ -5,6 +5,17 @@ module Markbridge
5
5
  module Discourse
6
6
  module Tags
7
7
  class HeadingTag < Tag
8
+ # A run of # at the end of an ATX heading, after whitespace, is
9
+ # the optional closing sequence and disappears when cooked. The
10
+ # space after the prefix counts, so a run that is the whole
11
+ # content is one too.
12
+ CLOSING_SEQUENCE = /(?:\A|(?<=[ \t]))#+[ \t]*\z/
13
+ # A heading is one line. A newline in its content, from a text
14
+ # node, is a soft break and becomes a space; LineBreakTag writes
15
+ # a hard break in a heading as <br>.
16
+ LINE_BREAK = /[ \t]*\n[ \t]*/
17
+ private_constant :CLOSING_SEQUENCE, :LINE_BREAK
18
+
8
19
  def render(element, interface)
9
20
  child_context = interface.with_parent(element)
10
21
  content = interface.render_children(element, context: child_context)
@@ -16,7 +27,15 @@ module Markbridge
16
27
 
17
28
  prefix = "#" * element.level
18
29
 
19
- "\n\n#{prefix} #{content}\n\n"
30
+ "\n\n#{prefix} #{keep_trailing_hashes(content.gsub(LINE_BREAK, " "))}\n\n"
31
+ end
32
+
33
+ private
34
+
35
+ # A backslash in front of the run keeps it as text. The escaper
36
+ # does not do this, it only knows about # at the start of a line.
37
+ def keep_trailing_hashes(content)
38
+ content.sub(CLOSING_SEQUENCE) { |run| "\\#{run}" }
20
39
  end
21
40
  end
22
41
  end
@@ -7,7 +7,18 @@ module Markbridge
7
7
  # Tag for rendering horizontal rules
8
8
  class HorizontalRuleTag < Tag
9
9
  def render(_element, interface)
10
- interface.html_mode? ? "<hr>" : "\n\n---\n\n"
10
+ return "<hr>" if interface.html_mode?
11
+
12
+ list = interface.find_parent(AST::List)
13
+ return "\n\n---\n\n" unless list
14
+
15
+ # `- ---` is a thematic break that ends the list, because the
16
+ # marker and the dashes together are one run of dashes and
17
+ # spaces. `- * * *` is a list item that holds a thematic break
18
+ # (CommonMark example 61), so inside a list item the rule is
19
+ # written with the character the list's marker does not use;
20
+ # `* * * *` would be the rule alone again.
21
+ interface.list_marker(list)&.start_with?("*") ? "\n\n- - -\n\n" : "\n\n* * *\n\n"
11
22
  end
12
23
  end
13
24
  end
@@ -4,34 +4,20 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Tag for rendering images
8
- # Renders to Markdown image syntax with optional Discourse sizing
7
+ # Images: an image token. The serializer writes
8
+ # `![alt|WIDTHxHEIGHT](url)`, `![alt|WIDTH](url)` or `![alt](url)`
9
+ # with the alt text escaped like a link label, and <img> in an
10
+ # HTML block.
9
11
  class ImageTag < Tag
10
- def render(element, interface)
11
- src = element.src
12
- width = element.width
13
- height = element.height
14
-
15
- return render_html(src, width, height) if interface.html_mode?
16
-
17
- # Build Discourse image syntax with dimensions
18
- # Format: ![alt|WIDTHxHEIGHT](url) or ![alt|WIDTH](url)
19
- if width && height
20
- "![|#{width}x#{height}](#{src})"
21
- elsif width
22
- "![|#{width}](#{src})"
23
- else
24
- "![](#{src})"
25
- end
26
- end
27
-
28
- private
29
-
30
- def render_html(src, width, height)
31
- attrs = %(src="#{HtmlEscaper.escape(src)}" alt="")
32
- attrs << %( width="#{width}") if width
33
- attrs << %( height="#{height}") if height
34
- "<img #{attrs}>"
12
+ def render(element, _interface)
13
+ [
14
+ Inline::Tokens.image(
15
+ src: element.src,
16
+ alt: element.alt,
17
+ width: element.width,
18
+ height: element.height,
19
+ ),
20
+ ]
35
21
  end
36
22
  end
37
23
  end
@@ -4,14 +4,12 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Tag for rendering italic text
7
+ # Emphasis. The serializer writes the markers, or the HTML tags
8
+ # where the markers would not work.
8
9
  class ItalicTag < 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 "<em>#{content}</em>" 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(:em)) << Inline::Tokens.close(:em)
15
13
  end
16
14
  end
17
15
  end
@@ -4,10 +4,17 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Tag for rendering line breaks
7
+ # Line breaks: a hard break token, which the serializer writes as a
8
+ # newline, as <br> on a line that holds nothing else (a newline
9
+ # there would be a blank line, which ends the paragraph and makes
10
+ # a list loose), and as <br> in an HTML block. A heading is one
11
+ # line, so a break in a heading is <br> as well.
8
12
  class LineBreakTag < Tag
13
+ HARD_BREAK = [Inline::Tokens.hard_break].freeze
14
+ private_constant :HARD_BREAK
15
+
9
16
  def render(_element, interface)
10
- interface.html_mode? ? "<br>" : "\n"
17
+ interface.has_parent?(AST::Heading) ? "<br>" : HARD_BREAK
11
18
  end
12
19
  end
13
20
  end
@@ -12,6 +12,16 @@ module Markbridge
12
12
  # lands at the content column of the item that holds it, whatever
13
13
  # the nesting depth is.
14
14
  class ListItemTag < Tag
15
+ # Ends a nested list in front of more content of the item.
16
+ COMMENT = "<!---->"
17
+ LIST_END = "\n<!---->"
18
+ NEWLINE = 10
19
+ SPACE = 32
20
+ # Whitespace and the <br> the serializer writes for a line break
21
+ # on a line that holds nothing else.
22
+ TRAILING_BREAKS = /(?:[[:space:]]|<br>)+\z/
23
+ private_constant :COMMENT, :LIST_END, :NEWLINE, :SPACE, :TRAILING_BREAKS
24
+
15
25
  def initialize
16
26
  @builder = Builders::ListItemBuilder.new
17
27
  end
@@ -24,7 +34,7 @@ module Markbridge
24
34
  return "" if content.empty?
25
35
 
26
36
  parent_list = interface.find_parent(AST::List)
27
- @builder.build(content, marker: determine_marker(parent_list))
37
+ @builder.build(content, marker: determine_marker(parent_list, interface))
28
38
  end
29
39
 
30
40
  private
@@ -39,28 +49,84 @@ module Markbridge
39
49
  # it. A blank line there would make the whole list loose, and
40
50
  # CommonMark then wraps every item in a <p>. Blank lines inside
41
51
  # the output of a child are never touched.
52
+ #
53
+ # Text after a nested list would become a continuation line of
54
+ # its last item, and a blank line in between would make the
55
+ # list loose again. An empty HTML comment on a line of its own
56
+ # ends the nested list without one; the whitespace between the
57
+ # list and the text goes.
42
58
  # @return [String]
43
59
  def render_content(element, interface, child_context)
60
+ after_list = false
44
61
  content =
45
62
  interface.render_children(element, context: child_context) do |buffer, child|
46
- tighten(buffer) if child.is_a?(AST::List)
63
+ if child.is_a?(AST::List)
64
+ tighten(buffer)
65
+ after_list = true
66
+ elsif after_list && visible?(child)
67
+ end_list(buffer, before: child)
68
+ after_list = false
69
+ end
47
70
  end
48
71
  content.strip
49
72
  end
50
73
 
51
- # Drops the blank lines in front of a nested list, but keeps one
52
- # when the line before it opens an HTML block (for example a
53
- # `</table>`), because only a blank line ends such a block.
74
+ # The comment needs a line of its own: after it on the same line,
75
+ # the text would be part of the HTML block. Text that starts with
76
+ # a newline brings the line break itself. +before+ is the child
77
+ # that comes next.
78
+ def end_list(buffer, before:)
79
+ buffer.sub!(TRAILING_BREAKS, "")
80
+ buffer << LIST_END
81
+ buffer << "\n" unless text_of(before)&.start_with?("\n")
82
+ end
83
+
84
+ # Whitespace and line breaks between a nested list and the text
85
+ # after it do not count, the text does.
86
+ def visible?(child)
87
+ text = text_of(child)
88
+ return text.match?(/[^[:space:]]/) if text
89
+
90
+ !child.is_a?(AST::LineBreak)
91
+ end
92
+
93
+ # The text of a text child, as it is written to the output.
94
+ def text_of(child)
95
+ child.text if child.is_a?(AST::Text) || child.is_a?(AST::MarkdownText)
96
+ end
97
+
98
+ # Drops the blank lines in front of a nested list. A blank line
99
+ # is what ends an HTML block, so a line in front that opens one
100
+ # gets a different treatment: a line that starts a block of its
101
+ # own (the line in front of it is blank, as with the `</div>` of
102
+ # an aligned block) gets an empty comment in front, which makes
103
+ # the line a comment block that ends on the same line; a line
104
+ # inside a block an earlier line opened (the `</table>` of a
105
+ # table) keeps the blank line, and the outer list is loose.
54
106
  def tighten(buffer)
55
107
  buffer.rstrip!
56
- last_line = buffer[(buffer.rindex("\n") || -1) + 1..]
57
- buffer << "\n" if HtmlBlock.opens?(last_line)
108
+ # Byte offsets throughout: the content in front may hold
109
+ # multi-byte characters.
110
+ start = (buffer.byterindex("\n") || -1) + 1
111
+ return unless HtmlBlock.opens?(buffer.byteslice(start..))
112
+
113
+ if start.zero? || buffer.getbyte(start - 2) == NEWLINE
114
+ # After the indentation of the line: a nested list has
115
+ # indented its lines already.
116
+ start += 1 while buffer.getbyte(start) == SPACE
117
+ buffer.bytesplice(start, 0, COMMENT)
118
+ else
119
+ buffer << "\n"
120
+ end
58
121
  end
59
122
 
60
123
  # @param parent_list [AST::List, nil]
61
124
  # @return [String]
62
- def determine_marker(parent_list)
63
- parent_list&.ordered? ? "1. " : "- "
125
+ # The marker the renderer chose for the list (see
126
+ # RenderingInterface#choose_list_marker), or the first one of
127
+ # its kind for an item rendered without its list.
128
+ def determine_marker(parent_list, interface)
129
+ interface.list_marker(parent_list) || (parent_list&.ordered? ? "1. " : "- ")
64
130
  end
65
131
  end
66
132
  end
@@ -5,27 +5,49 @@ module Markbridge
5
5
  module Discourse
6
6
  module Tags
7
7
  class ListTag < Tag
8
+ # The output is the list, see Tag#leading_list.
9
+ def leading_list(element, _interface)
10
+ element
11
+ end
12
+
13
+ def trailing_list(element, _interface)
14
+ element
15
+ end
16
+
8
17
  def render(element, interface)
9
18
  child_context = interface.with_parent(element)
10
19
 
20
+ # Two lists of the same kind in a row would cook as one list,
21
+ # and the blank line between them would make it loose. A
22
+ # different marker starts a new list, so the renderer picks
23
+ # the one the list in front did not use, and the items write
24
+ # it (ListItemTag). In html_mode nothing reads it.
25
+ interface.choose_list_marker(element, ordered: element.ordered?)
26
+
11
27
  content =
12
28
  element
13
29
  .children
14
30
  .map { |child| interface.render_node(child, context: child_context) }
15
31
  .join
16
32
 
33
+ # A list without items renders to nothing, like the other
34
+ # containers. Blank lines alone would still count as a list
35
+ # for the separator between two lists of the same kind.
36
+ return "" if content.empty?
37
+
17
38
  if interface.html_mode?
18
39
  tag_name = element.ordered? ? "ol" : "ul"
19
40
  return "<#{tag_name}>#{content}</#{tag_name}>"
20
41
  end
21
42
 
22
- nested = interface.has_parent?(AST::List) || interface.has_parent?(AST::ListItem)
23
-
24
- if nested
25
- "\n#{content}"
26
- else
27
- "\n\n#{content}\n\n"
28
- end
43
+ # A list right inside a list item starts on the line after the
44
+ # item text, so the list stays tight. ListItemTag takes care of
45
+ # what follows it in the item. Everywhere else (also inside a
46
+ # quote or an aligned block that sits in an item) the list is a
47
+ # block with blank lines on both sides.
48
+ parent = interface.context.element
49
+ nested = parent.is_a?(AST::ListItem) || parent.is_a?(AST::List)
50
+ nested ? "\n#{content}" : "\n\n#{content}\n\n"
29
51
  end
30
52
  end
31
53
  end
@@ -17,6 +17,18 @@ module Markbridge
17
17
  # end
18
18
  # end
19
19
  #
20
+ # @example Custom renderer with placeholder
21
+ # # The importer replaces the placeholder with the mention, so the
22
+ # # renderer joins it with its neighbors like a mention (see
23
+ # # "Placeholders" in the migration guide).
24
+ # Tokens = Markbridge::Renderers::Discourse::Inline::Tokens
25
+ #
26
+ # class MyMentionTag < Markbridge::Renderers::Discourse::Tags::MentionTag
27
+ # def render(element, interface)
28
+ # [Tokens.placeholder("[mention|#{element.name}]", like: "@#{element.name}")]
29
+ # end
30
+ # end
31
+ #
20
32
  # @example Custom renderer that links to user
21
33
  # class MyMentionTag < Markbridge::Renderers::Discourse::Tags::MentionTag
22
34
  # def render(element, interface)
@@ -7,17 +7,20 @@ module Markbridge
7
7
  # Tag for rendering paragraphs
8
8
  # Paragraphs are separated by blank lines in Markdown
9
9
  class ParagraphTag < Tag
10
+ # Spaces and tabs at the start of the paragraph. Not a no-break
11
+ # space: the escaper writes one where it wants indentation kept.
12
+ LEADING_BLANKS = /\A[ \t]+/
13
+ private_constant :LEADING_BLANKS
14
+
10
15
  def render(element, interface)
11
16
  child_context = interface.with_parent(element)
12
17
  content = interface.render_children(element, context: child_context)
13
18
 
14
19
  if interface.html_mode?
15
- # Inside a table cell the surrounding <td> already provides the
16
- # block context, so a <p> wrapper just adds vertical margin —
17
- # and if the paragraph contains a block element (e.g. a list),
18
- # `<p><ul>…</ul></p>` is invalid per HTML5. Drop the wrapper.
19
- return content if interface.has_parent?(AST::TableCell)
20
-
20
+ # Also inside a table cell: without the <p> the paragraph
21
+ # runs into the text next to it. A block inside the
22
+ # paragraph closes the <p> early in the HTML parser, which
23
+ # is what the Markdown form does as well.
21
24
  return "<p>#{content}</p>"
22
25
  end
23
26
 
@@ -25,6 +28,10 @@ module Markbridge
25
28
  # non-block content (raw text, inline elements) stays separated
26
29
  # from the paragraph. cleanup_markdown collapses any duplicate
27
30
  # newlines that result when neighbours are themselves block tags.
31
+ # Spaces at the start go: CommonMark drops up to three of them
32
+ # anyway, and after a list they would make the paragraph part
33
+ # of the last item.
34
+ content = content.sub(LEADING_BLANKS, "") if content.match?(LEADING_BLANKS)
28
35
  "\n\n#{content}\n\n"
29
36
  end
30
37
  end
@@ -4,27 +4,70 @@ module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
6
  module Tags
7
- # Placeholder tag for rendering polls.
7
+ # Renders {AST::Poll} as the +[poll]+ BBCode of the poll plugin: the
8
+ # raw BBCode of the source when the parser kept it, otherwise the
9
+ # BBCode built from the attributes and the options.
8
10
  #
9
- # This is a STUB implementation that outputs the original raw BBCode.
10
- # Applications using Markbridge should provide their own custom tag
11
- # to render polls as placeholders or convert them to another format.
11
+ # With +bbcode: false+, for a site without the plugin, the options
12
+ # are written as a bullet list. The raw BBCode is of no use there.
12
13
  #
13
- # @example Custom renderer with placeholder
14
+ # The BBCode has no HTML form, so it goes through
15
+ # RenderingInterface#markdown_island: a block with blank lines
16
+ # around it, and a Markdown island in a `<div>` inside an HTML
17
+ # table. A placeholder for a poll is written the same way (see
18
+ # "Placeholders" in the migration guide).
19
+ #
20
+ # @example A placeholder for the importer
14
21
  # class MyPollTag < Markbridge::Renderers::Discourse::Tags::PollTag
15
22
  # def render(element, interface)
16
23
  # id = register_poll(element)
17
- # "<<POLL:#{id}>>"
24
+ # interface.markdown_island("<<POLL:#{id}>>", block: true)
18
25
  # end
19
26
  # end
20
27
  class PollTag < Tag
21
- def render(element, _interface)
22
- body = element.raw || build_poll_bbcode(element)
23
- "\n\n#{body}\n\n"
28
+ # @param bbcode [Boolean] write the BBCode of the plugin
29
+ def initialize(bbcode: true)
30
+ @bbcode = bbcode
31
+ end
32
+
33
+ def render(element, interface)
34
+ return without_plugin(element, interface) unless @bbcode
35
+
36
+ interface.markdown_island(element.raw || build_poll_bbcode(element), block: true)
37
+ end
38
+
39
+ # The plain form is a bullet list, so a bullet list next to the
40
+ # poll would continue it (see Tag#leading_list). The poll stands
41
+ # for the list, the marker is chosen for it.
42
+ def leading_list(element, _interface)
43
+ element unless @bbcode
44
+ end
45
+
46
+ def trailing_list(element, _interface)
47
+ element unless @bbcode
24
48
  end
25
49
 
26
50
  private
27
51
 
52
+ # The options as the items of a bullet list, one line each; a
53
+ # poll without options renders to nothing, like an empty list.
54
+ # The poll writes the lines itself, with the marker the renderer
55
+ # picks for it against the list in front.
56
+ def without_plugin(element, interface)
57
+ return "" if element.options.empty?
58
+ if interface.html_mode?
59
+ items = element.options.map { |option| "<li>#{HtmlEscaper.escape(option)}</li>" }
60
+ return "<ul>#{items.join}</ul>"
61
+ end
62
+
63
+ marker = interface.choose_list_marker(element, ordered: false)
64
+ lines =
65
+ element.options.map do |option|
66
+ "#{marker}#{interface.render_node(AST::Text.new(option))}"
67
+ end
68
+ "\n\n#{lines.join("\n")}\n\n"
69
+ end
70
+
28
71
  def build_poll_bbcode(element)
29
72
  attrs = build_attributes(element)
30
73
  options = element.options.map { |opt| "* #{opt}" }.join("\n")
@@ -10,9 +10,15 @@ module Markbridge
10
10
  def render(element, interface)
11
11
  child_context = interface.with_parent(element)
12
12
  content = interface.render_children(element, context: child_context)
13
-
13
+ # An empty quote shows nothing, but it is still a block: the
14
+ # blank line keeps the text in front and after it apart. In an
15
+ # HTML block a blank line would end the block.
16
+ return interface.html_mode? ? "" : "\n\n" if content.empty?
14
17
  return "<blockquote>#{content}</blockquote>" if interface.html_mode?
15
18
 
19
+ # A block at the start or the end brings its own blank lines.
20
+ content = content.gsub(/\A\n+|\n+\z/, "")
21
+
16
22
  # Build Discourse quote BBCode
17
23
  # Format: [quote="username, post:2, topic:456"]content[/quote]
18
24
  # (post: is the post number within the topic, topic: the topic id)
@@ -24,6 +30,10 @@ module Markbridge
24
30
  # Name-only attribution; a bare post_id/user_id can't
25
31
  # produce a valid Discourse post reference.
26
32
  "[quote=\"#{element.author || element.username}\"]\n#{content}\n[/quote]"
33
+ elsif nests_attributed_quote?(element)
34
+ # Discourse doesn't render a [quote="…"] inside a Markdown
35
+ # blockquote, so the outer quote is BBCode too.
36
+ "[quote]\n#{content}\n[/quote]"
27
37
  else
28
38
  # Plain quote rendered as Markdown blockquote
29
39
  content.split("\n").map { |line| "> #{line}" }.join("\n")
@@ -34,6 +44,21 @@ module Markbridge
34
44
  # inline elements) starts a new paragraph against the quote.
35
45
  "\n\n#{body}\n\n"
36
46
  end
47
+
48
+ private
49
+
50
+ # @param element [AST::Element]
51
+ # @return [Boolean] whether a quote that renders as [quote="…"]
52
+ # is somewhere inside +element+
53
+ def nests_attributed_quote?(element)
54
+ element.children.any? do |child|
55
+ child.is_a?(AST::Element) &&
56
+ (
57
+ (child.is_a?(AST::Quote) && (child.author || child.username)) ||
58
+ nests_attributed_quote?(child)
59
+ )
60
+ end
61
+ end
37
62
  end
38
63
  end
39
64
  end