markbridge 0.2.1 → 0.3.1

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/markbridge/ast/element.rb +28 -0
  3. data/lib/markbridge/ast/quote.rb +45 -15
  4. data/lib/markbridge/ast/text.rb +7 -4
  5. data/lib/markbridge/ast/url.rb +15 -0
  6. data/lib/markbridge/normalizer/report.rb +41 -0
  7. data/lib/markbridge/normalizer/rule_set.rb +89 -0
  8. data/lib/markbridge/normalizer/text_projection.rb +37 -0
  9. data/lib/markbridge/normalizer/walker.rb +250 -0
  10. data/lib/markbridge/normalizer.rb +180 -0
  11. data/lib/markbridge/parsers/bbcode/handler_registry.rb +27 -1
  12. data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +32 -9
  13. data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +8 -4
  14. data/lib/markbridge/parsers/bbcode/parser.rb +6 -3
  15. data/lib/markbridge/parsers/bbcode/scanner.rb +133 -53
  16. data/lib/markbridge/parsers/html/handler_registry.rb +27 -1
  17. data/lib/markbridge/parsers/html/parser.rb +54 -13
  18. data/lib/markbridge/parsers/media_wiki/inline_parser.rb +95 -57
  19. data/lib/markbridge/parsers/media_wiki/inline_tag_registry.rb +24 -1
  20. data/lib/markbridge/parsers/media_wiki/parser.rb +5 -2
  21. data/lib/markbridge/parsers/text_formatter/handler_registry.rb +23 -1
  22. data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +31 -2
  23. data/lib/markbridge/parsers/text_formatter/parser.rb +10 -3
  24. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +12 -1
  25. data/lib/markbridge/renderers/discourse/render_context.rb +74 -11
  26. data/lib/markbridge/renderers/discourse/renderer.rb +63 -13
  27. data/lib/markbridge/renderers/discourse/rendering_interface.rb +27 -2
  28. data/lib/markbridge/renderers/discourse/tag_library.rb +20 -0
  29. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +2 -4
  30. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +2 -4
  31. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +8 -6
  32. data/lib/markbridge/renderers/discourse/tags/url_tag.rb +36 -2
  33. data/lib/markbridge/version.rb +1 -1
  34. data/lib/markbridge.rb +61 -11
  35. metadata +6 -1
@@ -178,6 +178,12 @@ module Markbridge
178
178
  end
179
179
 
180
180
  def escape_text(text)
181
+ # Single-line fast path (the common case for inline text nodes):
182
+ # skip the split and its Array + line-String allocations. A lone
183
+ # `\r` without `\n` stays on the line either way — `/\r?\n/`
184
+ # needs the `\n` — so `include?("\n")` alone decides correctly.
185
+ return escape_line(text, false) unless text.include?("\n")
186
+
181
187
  # On CRLF input, consume `\r` as part of the line terminator instead
182
188
  # of leaving it on the line. A trailing `\r` breaks line-end anchored
183
189
  # regexes (e.g. SETEXT_UNDERLINE_*) and the `ws_end >= line_length`
@@ -186,7 +192,6 @@ module Markbridge
186
192
  # LF-only fast path on a string split (regex split is ~20% slower
187
193
  # on the indented-code hot path).
188
194
  lines = text.include?("\r") ? text.split(/\r?\n/, -1) : text.split("\n", -1)
189
- return escape_line(lines[0], false) if lines.size == 1
190
195
 
191
196
  # Pre-allocate result buffer
192
197
  bytesize = text.bytesize
@@ -231,6 +236,12 @@ module Markbridge
231
236
  result = String.new(encoding: line.encoding)
232
237
  result << line[0, indent_len] << escaped
233
238
  result
239
+ elsif escaped.equal?(line)
240
+ # Nothing needed escaping, so `escaped` IS the input line: its
241
+ # encoding is already right, and since escape_text's
242
+ # single-line fast path that input can be the caller's own
243
+ # (possibly frozen) string — force_encoding would raise.
244
+ line
234
245
  else
235
246
  escaped.force_encoding(line.encoding)
236
247
  end
@@ -3,30 +3,74 @@
3
3
  module Markbridge
4
4
  module Renderers
5
5
  module Discourse
6
- # Immutable context for rendering that wraps the parent chain.
6
+ # Immutable context for rendering, implemented as a linked parent
7
+ # chain: each context holds the nearest parent element plus a link
8
+ # to the enclosing context. {#with_parent} runs once per rendered
9
+ # element — the chain form makes it a single fixed-size allocation
10
+ # instead of copying (and freezing) a parents array per element.
7
11
  # Provides query methods to ask about parent elements without the
8
12
  # renderer knowing about specific element types.
9
13
  class RenderContext
10
- attr_reader :parents, :depth
14
+ # @return [Integer] number of parent elements in the chain
15
+ attr_reader :depth
11
16
 
12
- def initialize(parents = [], html_mode: false)
13
- @parents = parents.freeze
14
- @depth = parents.size
17
+ # @return [AST::Element, nil] the nearest parent element (nil at root)
18
+ attr_reader :element
19
+
20
+ # @return [RenderContext, nil] the enclosing context (nil at root)
21
+ attr_reader :parent_context
22
+
23
+ # @param parents [Array<AST::Element>] parent elements in document
24
+ # order (outermost first); convenience form for building a
25
+ # context from scratch. Ignored when +element:+ is given.
26
+ # @param html_mode [Boolean] see {#html_mode?}
27
+ # @param parent [RenderContext, nil] enclosing context (chain form)
28
+ # @param element [AST::Element, nil] nearest parent element (chain form)
29
+ def initialize(parents = [], html_mode: false, parent: nil, element: nil)
15
30
  @html_mode = html_mode
31
+ if element
32
+ @parent_context = parent
33
+ @element = element
34
+ @depth = (parent ? parent.depth : 0) + 1
35
+ elsif parents.empty?
36
+ # Root context: @element and @parent_context stay unset and
37
+ # read as nil.
38
+ @depth = 0
39
+ else
40
+ @parent_context = self.class.new(parents[0, parents.size - 1], html_mode:)
41
+ @element = parents.last
42
+ @depth = parents.size
43
+ end
44
+ end
45
+
46
+ # Parent elements in document order (outermost first), materialized
47
+ # from the chain into a frozen Array. Allocates on every call —
48
+ # prefer {#find_parent} / {#has_parent?} / {#count_parents} on hot
49
+ # paths.
50
+ # @return [Array<AST::Element>]
51
+ def parents
52
+ result = []
53
+ context = self
54
+ while context && (parent = context.element)
55
+ result << parent
56
+ context = context.parent_context
57
+ end
58
+ result.reverse!
59
+ result.freeze
16
60
  end
17
61
 
18
62
  # Create new context with element added to parent chain.
19
63
  # @param element [AST::Element]
20
64
  # @return [RenderContext]
21
65
  def with_parent(element)
22
- self.class.new(@parents + [element], html_mode: @html_mode)
66
+ self.class.new(html_mode: @html_mode, parent: self, element:)
23
67
  end
24
68
 
25
69
  # Create new context with html_mode toggled.
26
70
  # @param value [Boolean]
27
71
  # @return [RenderContext]
28
72
  def with_html_mode(value)
29
- self.class.new(@parents, html_mode: value)
73
+ self.class.new(html_mode: value, parent: @parent_context, element: @element)
30
74
  end
31
75
 
32
76
  # @return [Boolean]
@@ -35,24 +79,43 @@ module Markbridge
35
79
  end
36
80
 
37
81
  # Find closest parent that is_a? klass (handles subclasses).
82
+ # The chain walks are inlined in each query (instead of a shared
83
+ # yielding helper) — these run several times per rendered text
84
+ # node, and a plain while loop avoids the block invocation.
38
85
  # @param klass [Class]
39
- # @return [AST::Element, nil]
86
+ # @return [AST::Element, nil] nil when no parent matches (implicit
87
+ # from the exhausted while loop)
40
88
  def find_parent(klass)
41
- @parents.reverse_each.find { |parent| parent.is_a?(klass) }
89
+ context = self
90
+ while context && (parent = context.element)
91
+ return parent if parent.is_a?(klass)
92
+ context = context.parent_context
93
+ end
42
94
  end
43
95
 
44
96
  # Count parents that are is_a? klass (handles subclasses).
45
97
  # @param klass [Class]
46
98
  # @return [Integer]
47
99
  def count_parents(klass)
48
- @parents.count { |parent| parent.is_a?(klass) }
100
+ count = 0
101
+ context = self
102
+ while context && (parent = context.element)
103
+ count += 1 if parent.is_a?(klass)
104
+ context = context.parent_context
105
+ end
106
+ count
49
107
  end
50
108
 
51
109
  # Check if any parent is_a? klass (handles subclasses).
52
110
  # @param klass [Class]
53
111
  # @return [Boolean]
54
112
  def has_parent?(klass)
55
- @parents.any? { |parent| parent.is_a?(klass) }
113
+ context = self
114
+ while context && (parent = context.element)
115
+ return true if parent.is_a?(klass)
116
+ context = context.parent_context
117
+ end
118
+ false
56
119
  end
57
120
 
58
121
  # Check if we're at the root (no parents).
@@ -8,7 +8,7 @@ module Markbridge
8
8
  attr_reader :postprocessor
9
9
 
10
10
  def initialize(tag_library: nil, escaper: nil, html_escaper: nil, postprocessor: nil)
11
- @tag_library = tag_library || TagLibrary.default
11
+ @tag_library = tag_library || TagLibrary.shared_default
12
12
  @escaper = escaper || MarkdownEscaper.new
13
13
  @html_escaper = html_escaper || HtmlEscaper
14
14
  @postprocessor = postprocessor || Postprocessor::DEFAULT
@@ -20,26 +20,56 @@ module Markbridge
20
20
  # @param node [AST::Node]
21
21
  # @param context [RenderContext] rendering context with parent chain
22
22
  # @return [String]
23
+ # @raise [TypeError] when the tag bound to the node's class returns
24
+ # something other than a String (a nil from a custom tag would
25
+ # otherwise surface as an inscrutable concatenation error deep
26
+ # inside render_children)
23
27
  def render(node, context: RenderContext.new)
24
28
  root_call = @interface_cache.nil?
25
29
  @interface_cache = {} if root_call
26
30
 
27
31
  tag = @tag_library[node.class]
28
32
  if tag
29
- interface = interface_for(context)
30
- return tag.render(node, interface)
33
+ result = tag.render(node, interface_for(context))
34
+ unless result.is_a?(String)
35
+ raise TypeError,
36
+ "#{tag.class} rendered #{node.class} to " \
37
+ "#{result.inspect} — tags must return a String " \
38
+ "(use interface.render_default(node) to fall back " \
39
+ "to the stock rendering)"
40
+ end
41
+ return result
31
42
  end
32
43
 
33
- case node
34
- when AST::Element # Document is an Element subclass
35
- render_children(node, context:)
36
- when AST::MarkdownText
37
- render_markdown_text(node, context)
38
- when AST::Text
39
- render_text(node, context)
40
- else
41
- ""
42
- end
44
+ render_without_tag(node, context)
45
+ ensure
46
+ @interface_cache = nil if root_call
47
+ end
48
+
49
+ # Render a node with the stock tag for its class, ignoring any
50
+ # override registered in this renderer's tag library. Lets a
51
+ # custom tag intercept only the nodes it cares about and delegate
52
+ # the rest:
53
+ #
54
+ # library.register(AST::Quote, Tag.new do |node, interface|
55
+ # next interface.render_default(node) unless node.username&.start_with?("legacy_")
56
+ # ...custom rendering...
57
+ # end)
58
+ #
59
+ # Children still render through this renderer, so overrides for
60
+ # other node classes keep applying inside the delegated subtree.
61
+ #
62
+ # @param node [AST::Node]
63
+ # @param context [RenderContext] rendering context with parent chain
64
+ # @return [String]
65
+ def render_default(node, context: RenderContext.new)
66
+ root_call = @interface_cache.nil?
67
+ @interface_cache = {} if root_call
68
+
69
+ tag = default_tag_library[node.class]
70
+ return tag.render(node, interface_for(context)) if tag
71
+
72
+ render_without_tag(node, context)
43
73
  ensure
44
74
  @interface_cache = nil if root_call
45
75
  end
@@ -83,6 +113,26 @@ module Markbridge
83
113
  @interface_cache[context.object_id] ||= RenderingInterface.new(self, context)
84
114
  end
85
115
 
116
+ # Pristine default library backing #render_default. Built lazily —
117
+ # most renders never need it.
118
+ def default_tag_library
119
+ @default_tag_library ||= TagLibrary.default
120
+ end
121
+
122
+ # The tag-less rendering paths shared by #render and #render_default.
123
+ def render_without_tag(node, context)
124
+ case node
125
+ when AST::Element # Document is an Element subclass
126
+ render_children(node, context:)
127
+ when AST::MarkdownText
128
+ render_markdown_text(node, context)
129
+ when AST::Text
130
+ render_text(node, context)
131
+ else
132
+ ""
133
+ end
134
+ end
135
+
86
136
  # In html_mode, surround pre-formatted Markdown with blank lines so that
87
137
  # CommonMark terminates the enclosing HTML block (e.g. <table>) and
88
138
  # parses the content as Markdown before the closing tags reopen another
@@ -18,6 +18,14 @@ module Markbridge
18
18
  @renderer.render(node, context:)
19
19
  end
20
20
 
21
+ # Render a node with the stock tag for its class, bypassing any
22
+ # override in the renderer's tag library. Lets a custom tag handle
23
+ # only the nodes it cares about and delegate the rest — see
24
+ # Renderer#render_default.
25
+ def render_default(node, context: @context)
26
+ @renderer.render_default(node, context:)
27
+ end
28
+
21
29
  def render_children(element, context: @context)
22
30
  @renderer.render_children(element, context:)
23
31
  end
@@ -63,6 +71,11 @@ module Markbridge
63
71
  node.children.any? { |c| c.instance_of?(AST::Text) && c.text.include?("\n") }
64
72
  end
65
73
 
74
+ # Leading or trailing whitespace (Unicode-aware, matching the
75
+ # flanking-preservation sub in #wrap_inline).
76
+ EDGE_WHITESPACE = /\A[[:space:]]|[[:space:]]\z/m
77
+ private_constant :EDGE_WHITESPACE
78
+
66
79
  # Helper: wrap inline content with markers
67
80
  # Handles edge cases like existing markers and whitespace
68
81
  def wrap_inline(content, open_marker, close_marker = nil)
@@ -82,8 +95,20 @@ module Markbridge
82
95
  end
83
96
  end
84
97
 
85
- # Preserve leading/trailing whitespace (Unicode-aware, since
86
- # CommonMark's flanking rules treat e.g. nbsp as whitespace).
98
+ apply_markers(content, open_marker, close_marker)
99
+ end
100
+
101
+ private
102
+
103
+ # Wrap content in markers, keeping leading/trailing whitespace
104
+ # outside the markers (Unicode-aware, since CommonMark's flanking
105
+ # rules treat e.g. nbsp as whitespace).
106
+ def apply_markers(content, open_marker, close_marker)
107
+ # Fast path: no edge whitespace to preserve (the common case), so
108
+ # plain interpolation replaces the capture-group sub below and its
109
+ # MatchData + capture allocations.
110
+ return "#{open_marker}#{content}#{close_marker}" unless content.match?(EDGE_WHITESPACE)
111
+
87
112
  content.sub(/\A([[:space:]]*)(.+?)([[:space:]]*)\z/m) do
88
113
  match = Regexp.last_match
89
114
  "#{match[1]}#{open_marker}#{match[2]}#{close_marker}#{match[3]}"
@@ -106,6 +106,26 @@ module Markbridge
106
106
  def self.default
107
107
  new.auto_register!
108
108
  end
109
+
110
+ # Shared, deep-frozen default library for the no-customization
111
+ # fast path. Built once per process; {Renderer} falls back to it
112
+ # when no +tag_library:+ is given, skipping the constant-scan and
113
+ # ~30 tag instantiations of {.default} on every render. Tags are
114
+ # stateless, so sharing is safe across renderers and threads.
115
+ # +dup+ yields a mutable copy (see {#initialize_copy}).
116
+ #
117
+ # @return [TagLibrary] the same frozen instance on every call
118
+ def self.shared_default
119
+ @shared_default ||= default.freeze
120
+ end
121
+
122
+ # Freeze the library together with its internal Hash so that
123
+ # registration on a shared instance fails loudly instead of
124
+ # silently mutating state visible to every renderer.
125
+ def freeze
126
+ @tags.freeze
127
+ super
128
+ end
109
129
  end
110
130
  end
111
131
  end
@@ -18,11 +18,9 @@ module Markbridge
18
18
  # end
19
19
  # end
20
20
  class EventTag < Tag
21
- def render(element, interface)
21
+ def render(element, _interface)
22
22
  body = element.raw || build_event_bbcode(element)
23
- return "\n\n#{body}\n\n" if interface.html_mode?
24
-
25
- "#{body}\n\n"
23
+ "\n\n#{body}\n\n"
26
24
  end
27
25
 
28
26
  private
@@ -18,11 +18,9 @@ module Markbridge
18
18
  # end
19
19
  # end
20
20
  class PollTag < Tag
21
- def render(element, interface)
21
+ def render(element, _interface)
22
22
  body = element.raw || build_poll_bbcode(element)
23
- return "\n\n#{body}\n\n" if interface.html_mode?
24
-
25
- "#{body}\n\n"
23
+ "\n\n#{body}\n\n"
26
24
  end
27
25
 
28
26
  private
@@ -14,14 +14,16 @@ module Markbridge
14
14
  return "<blockquote>#{content}</blockquote>" if interface.html_mode?
15
15
 
16
16
  # Build Discourse quote BBCode
17
- # Format: [quote="username, post:123, topic:456"]content[/quote]
17
+ # Format: [quote="username, post:2, topic:456"]content[/quote]
18
+ # (post: is the post number within the topic, topic: the topic id)
18
19
  body =
19
- if element.post && element.topic && element.username
20
+ if element.post_number && element.topic_id && element.username
20
21
  # Full Discourse quote with context
21
- "[quote=\"#{element.username}, post:#{element.post}, topic:#{element.topic}\"]\n#{content}\n[/quote]"
22
- elsif element.author
23
- # Quote with author attribution only
24
- "[quote=\"#{element.author}\"]\n#{content}\n[/quote]"
22
+ "[quote=\"#{element.username}, post:#{element.post_number}, topic:#{element.topic_id}\"]\n#{content}\n[/quote]"
23
+ elsif element.author || element.username
24
+ # Name-only attribution; a bare post_id/user_id can't
25
+ # produce a valid Discourse post reference.
26
+ "[quote=\"#{element.author || element.username}\"]\n#{content}\n[/quote]"
25
27
  else
26
28
  # Plain quote rendered as Markdown blockquote
27
29
  content.split("\n").map { |line| "> #{line}" }.join("\n")
@@ -5,20 +5,54 @@ module Markbridge
5
5
  module Discourse
6
6
  module Tags
7
7
  # Tag for rendering URLs
8
+ #
9
+ # Bare URLs (a single Text child equal to the href, or no link
10
+ # text at all) render as the plain href instead of a Markdown
11
+ # link — in Discourse a bare URL on its own line oneboxes, a
12
+ # `[text](url)` link does not.
8
13
  class UrlTag < Tag
14
+ # Schemes that are safe to link. Anything else that *looks*
15
+ # like a scheme (javascript:, data:, vbscript:, …) is dropped;
16
+ # scheme-less hrefs (relative paths, anchors, protocol-relative
17
+ # URLs) pass through — common in forum exports and harmless.
18
+ ALLOWED_SCHEMES = /\A(?:https?|ftps?|mailto):/i
19
+ SCHEME_LIKE = /\A[a-z][a-z0-9+.-]*:/i
20
+ private_constant :ALLOWED_SCHEMES, :SCHEME_LIKE
21
+
9
22
  def render(element, interface)
10
23
  child_context = interface.with_parent(element)
11
24
  text = interface.render_children(element, context: child_context)
12
25
  href = element.href
13
26
 
14
- return text unless href&.match?(/\A(?:https?|ftps?|mailto):/i)
27
+ return text unless linkable?(href)
15
28
 
16
29
  if interface.html_mode?
17
30
  %(<a href="#{HtmlEscaper.escape(href)}">#{text}</a>)
31
+ elsif element.bare? || text.empty?
32
+ # Url#bare? judges the AST (so label escaping can't confuse
33
+ # it); the rendered-text check additionally catches labels
34
+ # that render to nothing (e.g. an empty formatting child).
35
+ href
18
36
  else
19
- "[#{text}](#{href})"
37
+ "[#{text}](#{markdown_destination(href)})"
20
38
  end
21
39
  end
40
+
41
+ private
42
+
43
+ # CommonMark link destinations cannot contain whitespace unless
44
+ # wrapped in <> — relevant for relative targets like MediaWiki
45
+ # page names ("Main Page").
46
+ def markdown_destination(href)
47
+ href.match?(/\s/) ? "<#{href}>" : href
48
+ end
49
+
50
+ def linkable?(href)
51
+ return false if href.nil? || href.empty?
52
+ return true if href.match?(ALLOWED_SCHEMES)
53
+
54
+ !href.match?(SCHEME_LIKE)
55
+ end
22
56
  end
23
57
  end
24
58
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Markbridge
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/markbridge.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "markbridge/parse"
5
5
  require_relative "markbridge/conversion"
6
6
 
7
7
  require_relative "markbridge/ast"
8
+ require_relative "markbridge/normalizer"
8
9
  require_relative "markbridge/renderers/discourse"
9
10
 
10
11
  module Markbridge
@@ -44,11 +45,21 @@ module Markbridge
44
45
  # {Conversion} with an empty +markdown+ string, and surface the
45
46
  # exceptions via {Conversion#errors}.
46
47
  # @yieldparam ast [AST::Document] mutate before rendering (optional)
48
+ # @param normalize [Boolean, Normalizer] apply target-format nesting
49
+ # rules between the +yield+ hook and render. +true+ (default) uses the
50
+ # shared default normalizer; a {Normalizer} is used as-is; +false+
51
+ # skips normalization. See {Normalizer}.
47
52
  # @return [Conversion]
48
- def bbcode_to_markdown(input, handlers: nil, renderer: nil, raise_on_error: true)
53
+ def bbcode_to_markdown(
54
+ input,
55
+ handlers: nil,
56
+ renderer: nil,
57
+ raise_on_error: true,
58
+ normalize: true
59
+ )
49
60
  parse = parse_bbcode(input, handlers:)
50
61
  yield(parse.ast) if block_given?
51
- build_conversion(parse, renderer:, raise_on_error:)
62
+ build_conversion(parse, renderer:, raise_on_error:, normalize:)
52
63
  end
53
64
 
54
65
  # Parse HTML to AST.
@@ -85,11 +96,12 @@ module Markbridge
85
96
  # {Conversion} with an empty +markdown+ string, and surface the
86
97
  # exceptions via {Conversion#errors}.
87
98
  # @yieldparam ast [AST::Document] mutate before rendering (optional)
99
+ # @param normalize [Boolean, Normalizer] see {.bbcode_to_markdown}
88
100
  # @return [Conversion]
89
- def html_to_markdown(input, handlers: nil, renderer: nil, raise_on_error: true)
101
+ def html_to_markdown(input, handlers: nil, renderer: nil, raise_on_error: true, normalize: true)
90
102
  parse = parse_html(input, handlers:)
91
103
  yield(parse.ast) if block_given?
92
- build_conversion(parse, renderer:, raise_on_error:)
104
+ build_conversion(parse, renderer:, raise_on_error:, normalize:)
93
105
  end
94
106
 
95
107
  # Parse s9e/TextFormatter XML to AST.
@@ -122,11 +134,18 @@ module Markbridge
122
134
  # @param renderer [Renderers::Discourse::Renderer, nil] custom renderer
123
135
  # @param raise_on_error [Boolean] see {.bbcode_to_markdown}
124
136
  # @yieldparam ast [AST::Document] mutate before rendering (optional)
137
+ # @param normalize [Boolean, Normalizer] see {.bbcode_to_markdown}
125
138
  # @return [Conversion]
126
- def text_formatter_xml_to_markdown(input, handlers: nil, renderer: nil, raise_on_error: true)
139
+ def text_formatter_xml_to_markdown(
140
+ input,
141
+ handlers: nil,
142
+ renderer: nil,
143
+ raise_on_error: true,
144
+ normalize: true
145
+ )
127
146
  parse = parse_text_formatter_xml(input, handlers:)
128
147
  yield(parse.ast) if block_given?
129
- build_conversion(parse, renderer:, raise_on_error:)
148
+ build_conversion(parse, renderer:, raise_on_error:, normalize:)
130
149
  end
131
150
 
132
151
  # Parse MediaWiki wikitext to AST.
@@ -155,11 +174,18 @@ module Markbridge
155
174
  # @param renderer [Renderers::Discourse::Renderer, nil] custom renderer
156
175
  # @param raise_on_error [Boolean] see {.bbcode_to_markdown}
157
176
  # @yieldparam ast [AST::Document] mutate before rendering (optional)
177
+ # @param normalize [Boolean, Normalizer] see {.bbcode_to_markdown}
158
178
  # @return [Conversion]
159
- def mediawiki_to_markdown(input, handlers: nil, renderer: nil, raise_on_error: true)
179
+ def mediawiki_to_markdown(
180
+ input,
181
+ handlers: nil,
182
+ renderer: nil,
183
+ raise_on_error: true,
184
+ normalize: true
185
+ )
160
186
  parse = parse_mediawiki(input, handlers:)
161
187
  yield(parse.ast) if block_given?
162
- build_conversion(parse, renderer:, raise_on_error:)
188
+ build_conversion(parse, renderer:, raise_on_error:, normalize:)
163
189
  end
164
190
 
165
191
  # Convert input in the given format. Thin dispatcher over the
@@ -211,8 +237,17 @@ module Markbridge
211
237
  # @param format [Symbol] :discourse (only renderer currently shipped)
212
238
  # @param renderer [Renderers::Discourse::Renderer, nil]
213
239
  # @param raise_on_error [Boolean]
240
+ # @param normalize [Boolean, Normalizer] see {.bbcode_to_markdown}.
241
+ # Normalization is idempotent, so re-rendering an already-normalized
242
+ # {Parse} is a no-op.
214
243
  # @return [Conversion]
215
- def render(parse_or_ast, format: :discourse, renderer: nil, raise_on_error: true)
244
+ def render(
245
+ parse_or_ast,
246
+ format: :discourse,
247
+ renderer: nil,
248
+ raise_on_error: true,
249
+ normalize: true
250
+ )
216
251
  raise ArgumentError, "unknown render format #{format.inspect}" unless format == :discourse
217
252
 
218
253
  parse =
@@ -228,7 +263,7 @@ module Markbridge
228
263
  raise ArgumentError, "expected Parse or AST::Node, got #{parse_or_ast.class}"
229
264
  end
230
265
 
231
- build_conversion(parse, renderer:, raise_on_error:)
266
+ build_conversion(parse, renderer:, raise_on_error:, normalize:)
232
267
  end
233
268
 
234
269
  # Build a configured Discourse {Renderers::Discourse::Renderer}
@@ -297,13 +332,28 @@ module Markbridge
297
332
  }
298
333
  end
299
334
 
300
- def build_conversion(parse, renderer:, raise_on_error:)
335
+ def build_conversion(parse, renderer:, raise_on_error:, normalize:)
336
+ parse = apply_normalization(parse, normalize)
301
337
  renderer ||= Renderers::Discourse::Renderer.new
302
338
  markdown, errors = render_through(renderer, parse.ast, raise_on_error:)
303
339
 
304
340
  Conversion.new(parsed: parse, markdown:, errors:)
305
341
  end
306
342
 
343
+ # Normalize +parse.ast+ in place (target-format nesting rules) and fold
344
+ # any report into +diagnostics[:normalization]+. Returns the +parse+ to
345
+ # render — a new one carrying the report when something changed, else
346
+ # the original unchanged.
347
+ def apply_normalization(parse, normalize)
348
+ return parse unless normalize
349
+
350
+ normalizer = normalize.is_a?(Normalizer) ? normalize : Normalizer.shared_default
351
+ report = normalizer.normalize(parse.ast)
352
+ return parse if report.empty?
353
+
354
+ parse.with(diagnostics: parse.diagnostics.merge(normalization: report))
355
+ end
356
+
307
357
  def build_escaper(escape:, escape_hard_line_breaks:, allow:)
308
358
  if escape == false
309
359
  if escape_hard_line_breaks || allow
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: markbridge
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Discourse Team
@@ -58,6 +58,11 @@ files:
58
58
  - lib/markbridge/gem_loader.rb
59
59
  - lib/markbridge/html.rb
60
60
  - lib/markbridge/mediawiki.rb
61
+ - lib/markbridge/normalizer.rb
62
+ - lib/markbridge/normalizer/report.rb
63
+ - lib/markbridge/normalizer/rule_set.rb
64
+ - lib/markbridge/normalizer/text_projection.rb
65
+ - lib/markbridge/normalizer/walker.rb
61
66
  - lib/markbridge/parse.rb
62
67
  - lib/markbridge/parsers/bbcode.rb
63
68
  - lib/markbridge/parsers/bbcode/closing_strategies/base.rb