markbridge 0.1.3 → 0.2.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/lib/markbridge/ast/details.rb +24 -0
  4. data/lib/markbridge/ast/element.rb +63 -0
  5. data/lib/markbridge/ast.rb +1 -0
  6. data/lib/markbridge/conversion.rb +40 -0
  7. data/lib/markbridge/parse.rb +20 -0
  8. data/lib/markbridge/parsers/bbcode/handler_registry.rb +25 -2
  9. data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +13 -2
  10. data/lib/markbridge/parsers/html/handler_registry.rb +97 -17
  11. data/lib/markbridge/parsers/html/handlers/self_closing_handler.rb +26 -0
  12. data/lib/markbridge/parsers/html/handlers/span_handler.rb +74 -0
  13. data/lib/markbridge/parsers/html/parser.rb +88 -18
  14. data/lib/markbridge/parsers/html.rb +2 -0
  15. data/lib/markbridge/parsers/media_wiki/inline_parser.rb +21 -8
  16. data/lib/markbridge/parsers/media_wiki/parser.rb +13 -5
  17. data/lib/markbridge/parsers/text_formatter/handler_registry.rb +27 -4
  18. data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +1 -1
  19. data/lib/markbridge/parsers/text_formatter/handlers/attribute_handler.rb +1 -1
  20. data/lib/markbridge/parsers/text_formatter/handlers/base_handler.rb +1 -1
  21. data/lib/markbridge/parsers/text_formatter/handlers/code_handler.rb +1 -1
  22. data/lib/markbridge/parsers/text_formatter/handlers/email_handler.rb +1 -1
  23. data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +1 -1
  24. data/lib/markbridge/parsers/text_formatter/handlers/list_handler.rb +1 -1
  25. data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +1 -1
  26. data/lib/markbridge/parsers/text_formatter/handlers/simple_handler.rb +1 -1
  27. data/lib/markbridge/parsers/text_formatter/handlers/table_cell_handler.rb +1 -1
  28. data/lib/markbridge/parsers/text_formatter/handlers/url_handler.rb +1 -1
  29. data/lib/markbridge/parsers/text_formatter/parser.rb +17 -3
  30. data/lib/markbridge/renderers/discourse/identity_escaper.rb +37 -0
  31. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +83 -8
  32. data/lib/markbridge/renderers/discourse/postprocessor.rb +53 -0
  33. data/lib/markbridge/renderers/discourse/render_context.rb +14 -40
  34. data/lib/markbridge/renderers/discourse/renderer.rb +15 -5
  35. data/lib/markbridge/renderers/discourse/rendering_interface.rb +4 -3
  36. data/lib/markbridge/renderers/discourse/tag_library.rb +42 -2
  37. data/lib/markbridge/renderers/discourse/tags/align_tag.rb +2 -2
  38. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +5 -3
  39. data/lib/markbridge/renderers/discourse/tags/details_tag.rb +46 -0
  40. data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +1 -1
  41. data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +5 -2
  42. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +4 -3
  43. data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +13 -0
  44. data/lib/markbridge/renderers/discourse.rb +3 -0
  45. data/lib/markbridge/version.rb +1 -1
  46. data/lib/markbridge.rb +274 -110
  47. metadata +9 -2
  48. data/lib/markbridge/configuration.rb +0 -11
@@ -5,13 +5,15 @@ module Markbridge
5
5
  module Discourse
6
6
  # Renders AST to Discourse-flavored Markdown in-memory.
7
7
  class Renderer
8
- def initialize(tag_library: nil, escaper: nil, html_escaper: nil)
8
+ attr_reader :postprocessor
9
+
10
+ def initialize(tag_library: nil, escaper: nil, html_escaper: nil, postprocessor: nil)
9
11
  @tag_library = tag_library || TagLibrary.default
10
12
  @escaper = escaper || MarkdownEscaper.new
11
13
  @html_escaper = html_escaper || HtmlEscaper
14
+ @postprocessor = postprocessor || Postprocessor::DEFAULT
12
15
  # @interface_cache is lazily initialized in #render's top-level
13
- # call and reset to nil after the call completes. No init
14
- # needed here — unset ivar returns nil under `.nil?` check.
16
+ # call and reset to nil after the call completes.
15
17
  end
16
18
 
17
19
  # Render a node to Markdown
@@ -20,7 +22,7 @@ module Markbridge
20
22
  # @return [String]
21
23
  def render(node, context: RenderContext.new)
22
24
  root_call = @interface_cache.nil?
23
- @interface_cache ||= {}
25
+ @interface_cache = {} if root_call
24
26
 
25
27
  tag = @tag_library[node.class]
26
28
  if tag
@@ -97,9 +99,17 @@ module Markbridge
97
99
  elsif context.html_mode?
98
100
  @html_escaper.escape(node.text)
99
101
  else
100
- @escaper.escape(node.text)
102
+ @escaper.escape(node.text, in_link_label: in_link_label?(context))
101
103
  end
102
104
  end
105
+
106
+ # `]` is structural inside a Markdown link label, so any plain text
107
+ # rendered under an Url/Email ancestor must escape it. Tags that emit
108
+ # their own bracketed markup (ImageTag, UploadTag, etc.) skip this
109
+ # path entirely, so their structural brackets are preserved.
110
+ def in_link_label?(context)
111
+ context.has_parent?(AST::Url) || context.has_parent?(AST::Email)
112
+ end
103
113
  end
104
114
  end
105
115
  end
@@ -67,7 +67,7 @@ module Markbridge
67
67
  # Handles edge cases like existing markers and whitespace
68
68
  def wrap_inline(content, open_marker, close_marker = nil)
69
69
  close_marker ||= open_marker
70
- return content unless content.match?(/\S/)
70
+ return content unless content.match?(/[^[:space:]]/)
71
71
 
72
72
  # Handle conflicts with existing markers
73
73
  if content.include?(open_marker) || content.include?(close_marker)
@@ -82,8 +82,9 @@ module Markbridge
82
82
  end
83
83
  end
84
84
 
85
- # Preserve leading/trailing whitespace
86
- content.sub(/\A(\s*)(.+?)(\s*)\z/m) do
85
+ # Preserve leading/trailing whitespace (Unicode-aware, since
86
+ # CommonMark's flanking rules treat e.g. nbsp as whitespace).
87
+ content.sub(/\A([[:space:]]*)(.+?)([[:space:]]*)\z/m) do
87
88
  match = Regexp.last_match
88
89
  "#{match[1]}#{open_marker}#{match[2]}#{close_marker}#{match[3]}"
89
90
  end
@@ -11,6 +11,15 @@ module Markbridge
11
11
  @tags = {}
12
12
  end
13
13
 
14
+ # When a TagLibrary is +dup+'d / +clone+'d, ensure the
15
+ # internal +@tags+ Hash is independent of the source. Without
16
+ # this, both copies would share the same underlying Hash and
17
+ # mutations to one would silently affect the other.
18
+ def initialize_copy(other)
19
+ super
20
+ @tags = @tags.dup
21
+ end
22
+
14
23
  # Register a tag for an element class
15
24
  # @param element_class [Class] the element class
16
25
  # @param tag [Tag] the tag instance
@@ -19,6 +28,38 @@ module Markbridge
19
28
  self
20
29
  end
21
30
 
31
+ # Remove a tag binding so the renderer falls through to
32
+ # +render_children+ for that element class. See
33
+ # +Renderer#render+ for the auto-passthrough path.
34
+ #
35
+ # @param element_class [Class]
36
+ # @return [self]
37
+ def unregister(element_class)
38
+ @tags.delete(element_class)
39
+ self
40
+ end
41
+
42
+ # Merge a Hash of class → Tag mappings on top of this library
43
+ # in-place. A +nil+ value unregisters the corresponding class
44
+ # (so the default auto-passthrough kicks in).
45
+ #
46
+ # Named with a trailing +!+ because it mutates +self+ —
47
+ # mirroring Ruby's Hash#merge / Hash#merge! convention. Use
48
+ # +dup+ first if you need a non-destructive merge.
49
+ #
50
+ # @param mapping [Hash{Class => Tag, nil}]
51
+ # @return [self]
52
+ def merge!(mapping)
53
+ mapping.each_pair do |klass, tag|
54
+ if tag.nil?
55
+ unregister(klass)
56
+ else
57
+ register(klass, tag)
58
+ end
59
+ end
60
+ self
61
+ end
62
+
22
63
  # Get tag for an element class
23
64
  # @param element_class [Class]
24
65
  # @return [Tag, nil]
@@ -59,8 +100,7 @@ module Markbridge
59
100
  # Create the default tag library for Discourse Markdown.
60
101
  #
61
102
  # Each call returns a *fresh* instance — mutations made to one will
62
- # not be visible to another. If you want a process-wide singleton,
63
- # use {Markbridge.default_tag_library} instead, which memoizes.
103
+ # not be visible to another.
64
104
  #
65
105
  # @return [TagLibrary]
66
106
  def self.default
@@ -21,9 +21,9 @@ module Markbridge
21
21
  return content unless ALLOWED_ALIGNMENTS.include?(element.alignment)
22
22
 
23
23
  wrapper = %(<div align="#{element.alignment}">#{content}</div>)
24
- # Skip the trailing blank line in html_mode: a blank line would
24
+ # Skip the blank-line bracketing in html_mode: a blank line would
25
25
  # terminate the surrounding HTML block (e.g. an enclosing <table>).
26
- interface.html_mode? ? wrapper : "#{wrapper}\n\n"
26
+ interface.html_mode? ? wrapper : "\n\n#{wrapper}\n\n"
27
27
  end
28
28
  end
29
29
  end
@@ -28,11 +28,13 @@ module Markbridge
28
28
  "`#{content}`"
29
29
  end
30
30
 
31
- # Trailing blank line keeps an adjacent fence on the next block from
32
- # being parsed as a continuation of this one.
31
+ # Leading and trailing blank lines: the trailing one keeps an
32
+ # adjacent fence on the next block from being parsed as a
33
+ # continuation of this one; the leading one separates the fence
34
+ # from prior raw text or inline content.
33
35
  def render_block(content, language)
34
36
  fence = calculate_fence(content)
35
- "#{fence}#{language}\n#{content}\n#{fence}\n\n"
37
+ "\n\n#{fence}#{language}\n#{content}\n#{fence}\n\n"
36
38
  end
37
39
 
38
40
  def render_html_block(content, language)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Renderers
5
+ module Discourse
6
+ module Tags
7
+ # Renders {AST::Details} as a Discourse +[details=…]…[/details]+
8
+ # collapsible block.
9
+ #
10
+ # Markdown form: the BBCode is bracketed with leading and trailing
11
+ # blank lines so consecutive details blocks don't merge and
12
+ # adjacent inline content starts a new paragraph against the
13
+ # block. Inside, the rendered children are stripped so the
14
+ # +[details]+ opener and the body sit on adjacent lines without
15
+ # a stray blank between them — matching Discourse's BBCode
16
+ # parser expectations.
17
+ #
18
+ # HTML-block form (inside a CommonMark HTML block — when
19
+ # +interface.html_mode?+ is +true+): a raw
20
+ # +<details><summary>…</summary>…</details>+ element. The
21
+ # +title+ is HTML-escaped for the +<summary>+ text.
22
+ class DetailsTag < Tag
23
+ DEFAULT_TITLE = "Summary"
24
+ private_constant :DEFAULT_TITLE
25
+
26
+ def render(element, interface)
27
+ child_context = interface.with_parent(element)
28
+ content = interface.render_children(element, context: child_context)
29
+
30
+ return render_html(element.title, content) if interface.html_mode?
31
+
32
+ opener = element.title ? %([details="#{element.title}"]) : "[details]"
33
+ "\n\n#{opener}\n#{content.strip}\n[/details]\n\n"
34
+ end
35
+
36
+ private
37
+
38
+ def render_html(title, content)
39
+ label = title ? HtmlEscaper.escape(title) : DEFAULT_TITLE
40
+ "<details><summary>#{label}</summary>#{content}</details>"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -16,7 +16,7 @@ module Markbridge
16
16
 
17
17
  prefix = "#" * element.level
18
18
 
19
- "#{prefix} #{content}\n\n"
19
+ "\n\n#{prefix} #{content}\n\n"
20
20
  end
21
21
  end
22
22
  end
@@ -21,8 +21,11 @@ module Markbridge
21
21
  return "<p>#{content}</p>"
22
22
  end
23
23
 
24
- # Paragraph followed by blank line (two newlines)
25
- "#{content}\n\n"
24
+ # Bracket with leading and trailing blank lines so adjacent
25
+ # non-block content (raw text, inline elements) stays separated
26
+ # from the paragraph. cleanup_markdown collapses any duplicate
27
+ # newlines that result when neighbours are themselves block tags.
28
+ "\n\n#{content}\n\n"
26
29
  end
27
30
  end
28
31
  end
@@ -27,9 +27,10 @@ module Markbridge
27
27
  content.split("\n").map { |line| "> #{line}" }.join("\n")
28
28
  end
29
29
 
30
- # Trailing blank line so consecutive quotes don't merge and
31
- # following content starts a new paragraph.
32
- "#{body}\n\n"
30
+ # Bracket with leading and trailing blank lines so consecutive
31
+ # quotes don't merge and adjacent non-block content (raw text,
32
+ # inline elements) starts a new paragraph against the quote.
33
+ "\n\n#{body}\n\n"
33
34
  end
34
35
  end
35
36
  end
@@ -8,17 +8,30 @@ module Markbridge
8
8
  # cooked by the BBCode plugin into `<span class="bbcode-u">`. The
9
9
  # BBCode plugin runs on Markdown source, not on raw HTML inside an
10
10
  # HTML block, so in html_mode we emit the cooked form directly.
11
+ #
12
+ # Inside Markdown link text (`[text](url)`) the BBCode plugin also
13
+ # does not re-cook nested BBCode, so `[[u]X[/u]](url)` would render
14
+ # literally. Drop the wrapper when rendering under a link ancestor.
11
15
  class UnderlineTag < Tag
12
16
  def render(element, interface)
13
17
  child_context = interface.with_parent(element)
14
18
  content = interface.render_children(element, context: child_context)
19
+ return content unless content.match?(/[^[:space:]]/)
15
20
 
16
21
  if interface.html_mode?
17
22
  %(<span class="bbcode-u">#{content}</span>)
23
+ elsif inside_link?(interface)
24
+ content
18
25
  else
19
26
  "[u]#{content}[/u]"
20
27
  end
21
28
  end
29
+
30
+ private
31
+
32
+ def inside_link?(interface)
33
+ interface.has_parent?(AST::Url) || interface.has_parent?(AST::Email)
34
+ end
22
35
  end
23
36
  end
24
37
  end
@@ -5,7 +5,9 @@ require_relative "discourse/tag_library"
5
5
  require_relative "discourse/render_context"
6
6
  require_relative "discourse/rendering_interface"
7
7
  require_relative "discourse/markdown_escaper"
8
+ require_relative "discourse/identity_escaper"
8
9
  require_relative "discourse/html_escaper"
10
+ require_relative "discourse/postprocessor"
9
11
 
10
12
  # Builders
11
13
  require_relative "discourse/builders/list_item_builder"
@@ -16,6 +18,7 @@ require_relative "discourse/tags/attachment_tag"
16
18
  require_relative "discourse/tags/bold_tag"
17
19
  require_relative "discourse/tags/code_tag"
18
20
  require_relative "discourse/tags/color_tag"
21
+ require_relative "discourse/tags/details_tag"
19
22
  require_relative "discourse/tags/email_tag"
20
23
  require_relative "discourse/tags/heading_tag"
21
24
  require_relative "discourse/tags/horizontal_rule_tag"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Markbridge
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end