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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa422d5e0a05eee7f32524e42823d02a552111132025fd9ede1fc5045a79412e
4
- data.tar.gz: cc7af6319a7c9dde74a764dc9edaab186ef2cca92bc6fabd842a147216adaec0
3
+ metadata.gz: e9a481ff76e961a6f9c93e9cea998a6cdbe02ee46000c3acfba4e00023c12fd4
4
+ data.tar.gz: ffcc31afdae7383a83a2a51a26bfacfcd1c482dd5e779144cb6263b8547ed63c
5
5
  SHA512:
6
- metadata.gz: 2eb57814326cf0fbaf2e356290d962a0a9e8bf2b9c38aa4e41ad7526d9494574162fc5384473b15c833b4e34bd7f322bf464098337bf9d535662f615590c19e4
7
- data.tar.gz: d4fc22c47cc54ab91a0d29b140bf69317c5c10aae3358758b0036b213f96ddca9318d723016854819a8a80dcf03ea5f66180eaccc8df2d550d06ab69b8f25a87
6
+ metadata.gz: 46ba479a92c780d448385002567fea76f51478d18af74b6e7675bf916d01e5bbf61d56fd4f6b9f8ce1fd702662a9d41f15f1d43d86ec605e62604f2171b17795
7
+ data.tar.gz: 1eb764723017c5ca0afefb538945b37343dddf9aefb891947a7c3357451078bea5b045588c66275e517a95343ebef0a9bb9bfaef17639ded233333a819af0c9f
@@ -9,6 +9,9 @@ module Markbridge
9
9
  #
10
10
  # @example Image with dimensions
11
11
  # image = AST::Image.new(src: "https://example.com/img.png", width: 100, height: 100)
12
+ #
13
+ # @example Image with alternative text
14
+ # image = AST::Image.new(src: "https://example.com/img.png", alt: "a cat")
12
15
  class Image < Element
13
16
  # @return [String, nil] the image source URL
14
17
  attr_reader :src
@@ -19,16 +22,21 @@ module Markbridge
19
22
  # @return [Integer, nil] the image height
20
23
  attr_reader :height
21
24
 
25
+ # @return [String, nil] the alternative text
26
+ attr_reader :alt
27
+
22
28
  # Create a new Image element.
23
29
  #
24
30
  # @param src [String, nil] the image source URL
25
31
  # @param width [Integer, nil] the image width
26
32
  # @param height [Integer, nil] the image height
27
- def initialize(src: nil, width: nil, height: nil)
33
+ # @param alt [String, nil] the alternative text
34
+ def initialize(src: nil, width: nil, height: nil, alt: nil)
28
35
  super()
29
36
  @src = src
30
37
  @width = width
31
38
  @height = height
39
+ @alt = alt
32
40
  end
33
41
  end
34
42
  end
@@ -12,6 +12,10 @@ module Markbridge
12
12
  # url = AST::Url.new(href: "https://example.com")
13
13
  # url << AST::Text.new("https://example.com")
14
14
  class Url < Element
15
+ # Whitespace at the start or at the end of the text.
16
+ EDGE_SPACE = /\A[[:space:]]+|[[:space:]]+\z/
17
+ private_constant :EDGE_SPACE
18
+
15
19
  # @return [String, nil] the URL/href for this link
16
20
  attr_reader :href
17
21
 
@@ -24,18 +28,39 @@ module Markbridge
24
28
  end
25
29
 
26
30
  # Whether this link is "bare" — it has no link text of its own:
27
- # either no children, or a single Text child whose content is
28
- # exactly the href (how parsers model a URL that stands for
29
- # itself). Consumers that record or rewrite links need this
31
+ # either no children, or a single Text child whose content is the
32
+ # href (how parsers model a URL that stands for itself).
33
+ # Whitespace at the edges of the text and line breaks in front of
34
+ # it or after it don't count: forum posts often have
35
+ # `[url] https://example.com [/url]` or a line break right after
36
+ # `[url]`. Consumers that record or rewrite links need this
30
37
  # judgment too (a bare URL must stay bare to keep autolinking
31
38
  # and oneboxing working), so it lives on the node rather than
32
39
  # in the renderer.
33
40
  #
34
41
  # @return [Boolean]
42
+ # @example
43
+ # url = AST::Url.new(href: "https://example.com")
44
+ # url << AST::LineBreak.new << AST::Text.new(" https://example.com ")
45
+ # url.bare? # => true
35
46
  def bare?
36
47
  return true if children.empty?
37
48
 
38
- children.size == 1 && children.first.instance_of?(Text) && children.first.text == href
49
+ bare_text = nil
50
+ children.each do |child|
51
+ if child.instance_of?(Text)
52
+ return false if bare_text
53
+
54
+ bare_text = child.text
55
+ elsif !child.instance_of?(LineBreak)
56
+ return false
57
+ end
58
+ end
59
+ return false if bare_text.nil?
60
+ # Most bare links have no whitespace to take off.
61
+ return true if bare_text == href
62
+
63
+ bare_text.gsub(EDGE_SPACE, "") == href
39
64
  end
40
65
  end
41
66
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ # Raised when a post holds something whose output depends on a plugin
5
+ # of the target site and the renderer was not told whether the site
6
+ # has it. See the +features:+ option of {Markbridge.discourse_renderer}.
7
+ class UndecidedFeatureError < StandardError
8
+ end
9
+ end
@@ -32,8 +32,13 @@ module Markbridge
32
32
  def register(tag_names, handler)
33
33
  element_class = handler.element_class
34
34
  Array(tag_names).each { |tag_name| @handlers[tag_name.to_s.downcase] = handler }
35
- @element_handlers[element_class] = handler
35
+ # The conditional write goes first. Both writes are independent and
36
+ # keyed by element_class, so the order makes no difference to a
37
+ # successful register. On a frozen registry it decides which
38
+ # collection raises, and only a write that can be skipped lets a
39
+ # spec reach the one behind it.
36
40
  @auto_closeable_elements << element_class if handler.auto_closeable?
41
+ @element_handlers[element_class] = handler
37
42
  self
38
43
  end
39
44
 
@@ -10,6 +10,7 @@ module Markbridge
10
10
  # - [img=100x100]url[/img]
11
11
  # - [img width=100]url[/img]
12
12
  # - [img width=100 height=50]url[/img]
13
+ # - [img alt="a cat"]url[/img]
13
14
  class ImageHandler < RawHandler
14
15
  def initialize
15
16
  super(AST::Image)
@@ -19,7 +20,7 @@ module Markbridge
19
20
 
20
21
  def create_element(token:, content:)
21
22
  width, height = extract_dimensions(token)
22
- AST::Image.new(src: content, width:, height:)
23
+ AST::Image.new(src: content, width:, height:, alt: token.attrs[:alt])
23
24
  end
24
25
 
25
26
  OPTION_DIMENSIONS_PATTERN = /\A(?<width>\d+)(?:x(?<height>\d+))?\z/i
@@ -5,6 +5,9 @@ module Markbridge
5
5
  module BBCode
6
6
  module Handlers
7
7
  # Handler for URL tags
8
+ # Supports:
9
+ # - [url=https://example.com]text[/url]
10
+ # - [url]https://example.com[/url], where the text is the href
8
11
  class UrlHandler < BaseHandler
9
12
  def initialize
10
13
  @element_class = AST::Url
@@ -12,11 +15,32 @@ module Markbridge
12
15
 
13
16
  def on_open(token:, context:, registry:, tokens: nil)
14
17
  href = token.attrs[:href] || token.attrs[:url] || token.attrs[:option]
15
- element = AST::Url.new(href:)
16
- context.push(element, token:)
18
+ content = href ? nil : content_href(token:, tokens:)
19
+ return context.push(AST::Url.new(href:), token:) unless content
20
+
21
+ # The text and the closing tag that content_href looked at.
22
+ tokens.next
23
+ tokens.next
24
+ context.add_child(AST::Url.new(href: content) << AST::Text.new(content))
17
25
  end
18
26
 
19
27
  attr_reader :element_class
28
+
29
+ private
30
+
31
+ # [url]https://example.com[/url] has its href in the text. Only
32
+ # plain text followed by the closing tag counts: with tags inside
33
+ # the text is no URL, and the element keeps a nil href.
34
+ #
35
+ # @return [String, nil]
36
+ def content_href(token:, tokens:)
37
+ text, close = tokens&.peek_ahead(2)
38
+ return unless text.instance_of?(TextToken) && close.instance_of?(TagEndToken)
39
+ return unless close.tag == token.tag
40
+
41
+ href = text.text.strip
42
+ href unless href.empty?
43
+ end
20
44
  end
21
45
  end
22
46
  end
@@ -14,8 +14,11 @@ module Markbridge
14
14
  src = element["src"]
15
15
  width = sanitize_dimension(element["width"])
16
16
  height = sanitize_dimension(element["height"])
17
+ # An empty alt attribute means "no text", the same as a missing one.
18
+ alt = element["alt"]
19
+ alt = nil if alt&.empty?
17
20
 
18
- ast_element = AST::Image.new(src:, width:, height:)
21
+ ast_element = AST::Image.new(src:, width:, height:, alt:)
19
22
  parent << ast_element
20
23
 
21
24
  # Return nil to signal: don't process children (void element)
@@ -70,9 +70,21 @@ module Markbridge
70
70
  # a <pre> keep their semantics.
71
71
  @preserve_depth = initial_preserve_depth(doc)
72
72
 
73
+ # Whitespace collapsing follows the browser rule (CSS Text
74
+ # §4.1.1): a collapsible space is dropped at the start of a
75
+ # line, at the end of a line, and right after another
76
+ # collapsible space — wherever the inline boundaries fall, so
77
+ # `<b>a </b> b` keeps one space and `<p><b>x </b></p>` none.
78
+ # A line starts at the parse root and on every block-level tag
79
+ # boundary. @space_tail is the element whose last child is a
80
+ # collapsible Text ending in a space (the candidate for the
81
+ # end-of-line trim), or nil.
82
+ @line_start = true
83
+ @space_tail = nil
84
+
73
85
  # Process all nodes
74
86
  children.each { |node| process_node(node, document) }
75
- trim_trailing_whitespace(document)
87
+ end_line
76
88
 
77
89
  document
78
90
  end
@@ -117,17 +129,27 @@ module Markbridge
117
129
 
118
130
  if @preserve_depth.positive?
119
131
  parent << AST::Text.new(text)
132
+ # Preserved whitespace is content: it neither starts a line
133
+ # nor collapses against a space that follows it.
134
+ @line_start = false
135
+ @space_tail = nil
120
136
  return
121
137
  end
122
138
 
123
- text = text.gsub(WHITESPACE_RUN, " ") if text.match?(COLLAPSIBLE_WHITESPACE)
124
- # Drop leading whitespace at the start of an element's content,
125
- # matching the browser rule that whitespace at the beginning of a
126
- # block (or before any inline content) is collapsed away.
127
- text = text.lstrip if parent.children.empty?
139
+ text = collapse_whitespace(text)
140
+ text = text.lstrip if @line_start || @space_tail
128
141
  return if text.empty?
129
142
 
130
143
  parent << AST::Text.new(text)
144
+ @line_start = false
145
+ @space_tail = (parent if text.end_with?(" "))
146
+ end
147
+
148
+ # Collapse runs of whitespace to a single space.
149
+ # @param text [String]
150
+ # @return [String]
151
+ def collapse_whitespace(text)
152
+ text.match?(COLLAPSIBLE_WHITESPACE) ? text.gsub(WHITESPACE_RUN, " ") : text
131
153
  end
132
154
 
133
155
  # Process an element node
@@ -137,19 +159,21 @@ module Markbridge
137
159
  tag_name = node.name
138
160
  return if IGNORED_TAGS.include?(tag_name)
139
161
 
140
- # Drop whitespace that sits between content and the start of a
141
- # block-level tag, matching browser behavior where such whitespace
142
- # collapses against the block boundary. Applies whether or not a
143
- # handler is registered, so unknown tags like <div> or <section>
144
- # still collapse the whitespace before them.
145
- trim_trailing_whitespace(parent) if @handlers.block_level_tags.include?(tag_name)
162
+ # A block-level tag ends the line before it and the line inside
163
+ # it, matching browser behavior where whitespace collapses
164
+ # against the block boundary. Applies whether or not a handler
165
+ # is registered, so unknown tags like <div> or <section> still
166
+ # end lines.
167
+ block = @handlers.block_level_tags.include?(tag_name)
168
+ end_line if block
146
169
 
147
170
  preserving = @handlers.whitespace_preserving_tags.include?(tag_name)
148
171
  @preserve_depth += 1 if preserving
149
172
 
150
- dispatch_element(node, tag_name, parent, preserving)
173
+ dispatch_element(node, tag_name, parent)
151
174
 
152
175
  @preserve_depth -= 1 if preserving
176
+ end_line if block
153
177
  end
154
178
 
155
179
  # Dispatch an element to its handler (or the unknown-tag path) and
@@ -157,18 +181,22 @@ module Markbridge
157
181
  # @param node [Nokogiri::XML::Element]
158
182
  # @param tag_name [String]
159
183
  # @param parent [AST::Element]
160
- # @param preserving [Boolean] whether this tag preserves whitespace
161
- def dispatch_element(node, tag_name, parent, preserving)
184
+ def dispatch_element(node, tag_name, parent)
162
185
  handler = @handlers[tag_name]
163
186
  return handle_unknown_tag(node, parent) unless handler
164
187
 
165
188
  # Handler returns element if children should be processed, nil otherwise
189
+ size = parent.children.size
166
190
  ast_element = handler.process(element: node, parent:)
167
191
 
168
- return unless ast_element
169
-
170
- process_children(node, ast_element)
171
- trim_trailing_whitespace(ast_element) unless preserving
192
+ if ast_element
193
+ process_children(node, ast_element)
194
+ elsif parent.children.size > size
195
+ # The handler appended a leaf (image, line break, ...), which
196
+ # is content on the current line like a word.
197
+ @line_start = false
198
+ @space_tail = nil
199
+ end
172
200
  end
173
201
 
174
202
  # Handle unknown tag by tracking it and ignoring the wrapper
@@ -180,6 +208,16 @@ module Markbridge
180
208
  process_children(node, parent)
181
209
  end
182
210
 
211
+ # End the current line: the collapsible space that closed it goes,
212
+ # and the next collapsible space starts a line. @space_tail is
213
+ # not cleared here: the trim is idempotent, so a stale tail is
214
+ # harmless until the next text or leaf on the new line resets it,
215
+ # and @line_start already covers the lstrip.
216
+ def end_line
217
+ trim_trailing_whitespace(@space_tail) if @space_tail
218
+ @line_start = true
219
+ end
220
+
183
221
  # Number of whitespace-preserving elements enclosing the parse
184
222
  # root, the root itself included. Computed once per parse; from
185
223
  # there the counter is maintained during descent. Documents and
@@ -21,8 +21,9 @@ module Markbridge
21
21
  )
22
22
  parent << node
23
23
 
24
- # Return node to signal: process children into this node
25
- node
24
+ # The content is only fallback text (the filename), so there are
25
+ # no children to process.
26
+ nil
26
27
  end
27
28
 
28
29
  attr_reader :element_class
@@ -17,11 +17,13 @@ module Markbridge
17
17
  src: attrs[:src],
18
18
  width: attrs[:width]&.to_i,
19
19
  height: attrs[:height]&.to_i,
20
+ alt: attrs[:alt],
20
21
  )
21
22
  parent << node
22
23
 
23
- # Return node to signal: process children into this node
24
- node
24
+ # The content is only fallback text (the URL), so there are
25
+ # no children to process.
26
+ nil
25
27
  end
26
28
 
27
29
  attr_reader :element_class
@@ -9,6 +9,12 @@ module Markbridge
9
9
  # - Plain text: <t>text content</t>
10
10
  # - Rich text: <r><B>bold</B> <URL url="...">link</URL></r>
11
11
  # - Markup preservation: <s> and <e> elements (ignored during parsing)
12
+ # - Ignored text: <i> (lowercase) holds text s9e did not use, like the
13
+ # newline after [list] or a stray [/b]. It is skipped too; <I> is italic.
14
+ # - Line breaks: <br/> followed by a newline in the next text node. The
15
+ # newline is only source formatting, like any whitespace at the start
16
+ # of a line, because phpBB renders the XML as HTML. Inside <CODE> the
17
+ # text is kept as it is.
12
18
  #
13
19
  # This format is used by phpBB 3.2+ and other forum software.
14
20
  #
@@ -36,6 +42,12 @@ module Markbridge
36
42
  handlers || HandlerRegistry.shared_default
37
43
  end
38
44
  @unknown_tags = Hash.new(0)
45
+ # Whether an AST class is one of the BLOCK_NODES, computed once
46
+ # per class, so an inline element doesn't check all of them.
47
+ @block_classes =
48
+ Hash.new do |block_class_cache, klass|
49
+ block_class_cache[klass] = BLOCK_NODES.any? { klass <= _1 }
50
+ end
39
51
  end
40
52
 
41
53
  # Parse s9e/TextFormatter XML into an AST.
@@ -49,11 +61,13 @@ module Markbridge
49
61
  # @return [AST::Document]
50
62
  def parse(input)
51
63
  @unknown_tags.clear
64
+ @line_start = true
52
65
 
53
66
  if input.is_a?(Nokogiri::XML::Node)
54
67
  root = input.is_a?(Nokogiri::XML::Document) ? input.root : input
55
68
  document = AST::Document.new
56
69
  process_node(root, document) if root
70
+ flush_line_break
57
71
  return document
58
72
  end
59
73
 
@@ -70,17 +84,38 @@ module Markbridge
70
84
 
71
85
  document = AST::Document.new
72
86
  process_node(root, document)
87
+ flush_line_break
73
88
  document
74
89
  rescue Nokogiri::XML::SyntaxError
75
90
  AST::Document.new << AST::Text.new(input)
76
91
  end
77
92
 
78
- # <s>/<e> hold the original BBCode markup for unparsing; <t>/<r>
79
- # are the plain-text/rich-text roots. Frozen constants — the
80
- # membership checks run once per element node.
81
- MARKUP_PRESERVATION_TAGS = %w[s e].freeze
93
+ # <s>/<e> hold the original BBCode markup for unparsing, <i> holds
94
+ # text that s9e ignored; <t>/<r> are the plain-text/rich-text roots.
95
+ # Frozen constants — the membership checks run once per element
96
+ # node. The check is case-sensitive: <I> is italic.
97
+ MARKUP_PRESERVATION_TAGS = %w[s e i].freeze
82
98
  ROOT_TAGS = %w[t r].freeze
83
- private_constant :MARKUP_PRESERVATION_TAGS, :ROOT_TAGS
99
+
100
+ # AST nodes that phpBB shows as blocks. A new line starts before and
101
+ # after them, so whitespace at the start of their content and of the
102
+ # text after them is not visible.
103
+ BLOCK_NODES = [
104
+ AST::Align,
105
+ AST::Code,
106
+ AST::Details,
107
+ AST::Heading,
108
+ AST::HorizontalRule,
109
+ AST::List,
110
+ AST::ListItem,
111
+ AST::Paragraph,
112
+ AST::Quote,
113
+ AST::Spoiler,
114
+ AST::Table,
115
+ AST::TableCell,
116
+ AST::TableRow,
117
+ ].freeze
118
+ private_constant :MARKUP_PRESERVATION_TAGS, :ROOT_TAGS, :BLOCK_NODES
84
119
 
85
120
  # Process children of an XML element (public for handler access)
86
121
  # @param element [Nokogiri::XML::Element]
@@ -114,33 +149,91 @@ module Markbridge
114
149
  # Handle root nodes
115
150
  return process_children(element, ast_parent) if ROOT_TAGS.include?(tag_name)
116
151
 
117
- # Handle line breaks
118
- if tag_name == "br"
119
- ast_parent << AST::LineBreak.new
120
- return
121
- end
152
+ return process_line_break(ast_parent) if tag_name == "br"
122
153
 
123
154
  # Process element with registered handler
124
155
  # Handler returns element if children should be processed, nil otherwise
156
+ flush_line_break
125
157
  result_element = @handlers.process_element(element, ast_parent, self)
126
158
 
127
159
  if result_element
128
160
  # Handler succeeded and returned element - process children into it
129
- process_children(element, result_element)
130
- elsif !@handlers.has_handler?(tag_name)
161
+ process_handled_children(element, result_element)
162
+ elsif @handlers.has_handler?(tag_name)
163
+ # The handler returned nil intentionally: a leaf like an image
164
+ # or an attachment, whose content is only fallback text. It is
165
+ # visible, so the text after it is no longer at the line start.
166
+ @line_start = false
167
+ else
131
168
  # No handler found - track as unknown and process children directly
132
169
  @unknown_tags[tag_name] += 1
133
170
  process_children(element, ast_parent)
134
171
  end
135
- # else: handler returned nil intentionally (no children to process)
172
+ end
173
+
174
+ # Two line breaks in a row are an empty line in phpBB. In Markdown
175
+ # that is a paragraph break, so a line break waits in
176
+ # +@pending_break+ until something visible follows it. When the
177
+ # next line break comes first, the two become a blank line. A
178
+ # third one comes after the blank line and is not visible.
179
+ #
180
+ # @param ast_parent [AST::Element]
181
+ def process_line_break(ast_parent)
182
+ if @pending_break.equal?(ast_parent)
183
+ @pending_break = nil
184
+ ast_parent << AST::Text.new("\n\n")
185
+ else
186
+ flush_line_break
187
+ @pending_break = ast_parent
188
+ end
189
+ @line_start = true
190
+ end
191
+
192
+ # Adds the line break that waits, if there is one, before anything
193
+ # visible is added after it.
194
+ def flush_line_break
195
+ @pending_break&.<<(AST::LineBreak.new)
196
+ @pending_break = nil
197
+ end
198
+
199
+ # @param element [Nokogiri::XML::Element]
200
+ # @param ast_element [AST::Element]
201
+ def process_handled_children(element, ast_element)
202
+ if @block_classes[ast_element.class]
203
+ @line_start = true
204
+ process_children(element, ast_element)
205
+ @line_start = true
206
+ else
207
+ process_children(element, ast_element)
208
+ end
136
209
  end
137
210
 
138
211
  # Process text node
212
+ #
213
+ # Outside of code, whitespace at the start of a line is not visible
214
+ # in phpBB, but in Markdown a newline after a line break would end
215
+ # the paragraph and four spaces would start a code block, so it is
216
+ # removed. Whitespace-only text between two inline elements (the
217
+ # space in "<B>a</B> <I>b</I>") is kept; with a newline in it, it
218
+ # is only formatting between blocks.
219
+ #
139
220
  # @param text_node [Nokogiri::XML::Text]
140
221
  # @param ast_parent [AST::Element]
141
222
  def process_text(text_node, ast_parent)
142
223
  text = text_node.content
143
- ast_parent << AST::Text.new(text) if text.match?(/\S/)
224
+
225
+ if ast_parent.is_a?(AST::Code)
226
+ ast_parent << AST::Text.new(text) if text.match?(/\S/)
227
+ elsif @line_start
228
+ text = text.lstrip
229
+ return if text.empty?
230
+
231
+ @line_start = false
232
+ flush_line_break
233
+ ast_parent << AST::Text.new(text)
234
+ elsif text.match?(/\S/) || !text.include?("\n")
235
+ ast_parent << AST::Text.new(text)
236
+ end
144
237
  end
145
238
  end
146
239
  end
@@ -11,15 +11,21 @@ module Markbridge
11
11
  # renders into such a block therefore picks one of two forms:
12
12
  #
13
13
  # 1. raw HTML — an HTML equivalent of the tag's Markdown, spliced
14
- # into the block as it is;
14
+ # into the block as it is, with no blank line in it;
15
15
  # 2. a Markdown island — the tag's normal Markdown surrounded by
16
- # blank lines, which end the block and start it again:
16
+ # blank lines, which end the block, and a line that opens it
17
+ # again after them:
17
18
  #
18
19
  # <div align="center">
19
20
  #
20
21
  # [a link](https://example.com)
21
22
  #
22
23
  # </div>
24
+ #
25
+ # Inside an HTML block the island is always in a `<div>`
26
+ # (.enclosed_island, which a tag reaches through
27
+ # RenderingInterface#markdown_island). After a bare island the rest
28
+ # of the cell would be Markdown too.
23
29
  module HtmlBlock
24
30
  # Tag names that start a type 6 HTML block when a line begins
25
31
  # with `<name` or `</name` followed by a space, a tab, `>`, `/>`
@@ -94,6 +100,10 @@ module Markbridge
94
100
  # are item content that the list builder has not indented yet.
95
101
  OPENER = %r{\A[ \t]*</?(?:#{Regexp.union(BLOCK_TAGS).source})(?:[ \t>]|/>|\z)}i
96
102
 
103
+ # The newline in front of a line that holds nothing but spaces and
104
+ # tabs: with the line it makes a blank line.
105
+ BLANK_LINE_NEWLINE = /\n(?=[ \t]*\n)/
106
+
97
107
  # Markdown sigils that would surface as literal text inside an
98
108
  # HTML block: emphasis (`*`, `_`, `~`) and link middles (`](`).
99
109
  MARKDOWN_SIGILS = /[*_~]|\]\(/
@@ -102,7 +112,7 @@ module Markbridge
102
112
  # content line keeps its own indentation, e.g. a nested ` - a`.
103
113
  BLANK_EDGES = /\A(?:[ \t]*\n)+|(?:\n[ \t]*)+\z/
104
114
 
105
- private_constant :BLOCK_TAGS, :OPENER, :MARKDOWN_SIGILS, :BLANK_EDGES
115
+ private_constant :BLOCK_TAGS, :OPENER, :BLANK_LINE_NEWLINE, :MARKDOWN_SIGILS, :BLANK_EDGES
106
116
 
107
117
  # Whether +line+ starts an HTML block. A closing tag such as
108
118
  # `</div>` starts one too. The block runs until the next blank
@@ -124,17 +134,50 @@ module Markbridge
124
134
  "\n\n#{markdown.gsub(BLANK_EDGES, "")}\n\n"
125
135
  end
126
136
 
127
- # Whether a fragment can be spliced into an HTML block: raw HTML
128
- # or plain text without Markdown sigils, or an island.
137
+ # An island for the inside of an HTML block, in a `<div>`. After
138
+ # a bare island what follows on the same lines is Markdown until
139
+ # a line opens an HTML block again, so text after it in a table
140
+ # cell would get Markdown meaning; the `</div>` line opens the
141
+ # block again. A `div` is a block in the HTML as well, so the
142
+ # island shows as a paragraph of its own, like a bare island
143
+ # does.
144
+ # @param markdown [String]
145
+ # @return [String]
146
+ # @example
147
+ # HtmlBlock.enclosed_island("![a](b)") # => "<div>\n\n![a](b)\n\n</div>"
148
+ def self.enclosed_island(markdown)
149
+ "<div>#{island(markdown)}</div>"
150
+ end
151
+
152
+ # Text for the inside of an HTML block, with no blank line in it:
153
+ # a blank line ends the block, and what follows is Markdown
154
+ # again. The newline that would start a blank line is written as
155
+ # the entity, which the HTML parser reads back as a newline.
156
+ # @param text [String] text or HTML, already escaped
157
+ # @return [String]
158
+ # @example
159
+ # HtmlBlock.without_blank_lines("a\n\nb") # => "a&#10;\nb"
160
+ def self.without_blank_lines(text)
161
+ return text unless text.include?("\n")
162
+
163
+ text.gsub(BLANK_LINE_NEWLINE, "&#10;")
164
+ end
165
+
166
+ # Whether a fragment can be spliced into an HTML block: an island
167
+ # in a `<div>`, or raw HTML or plain text without Markdown sigils
168
+ # and without a blank line. A blank line anywhere else ends the
169
+ # block, and what follows it is Markdown until a line opens a
170
+ # block again; a bare island, `\n\n…\n\n`, is not accepted for
171
+ # that reason.
129
172
  #
130
173
  # Used by the html_mode contract check that ships in
131
174
  # +markbridge/rspec+ and by this repo's own contract spec.
132
175
  # @param output [String] a tag's html_mode render result
133
176
  # @return [Boolean]
134
177
  def self.safe?(output)
135
- return true if output.start_with?("\n\n") && output.end_with?("\n\n")
178
+ return true if output.start_with?("<div>\n\n") && output.end_with?("\n\n</div>")
136
179
 
137
- !output.match?(MARKDOWN_SIGILS)
180
+ !output.match?(MARKDOWN_SIGILS) && !output.match?(BLANK_LINE_NEWLINE)
138
181
  end
139
182
  end
140
183
  end