markbridge 0.3.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbebafc60a3665c98544f3cfd9496c3c5cdc2c0940339c998791b2df1f04aa44
4
- data.tar.gz: 2be35c042f803e37302fa149af772897326241488a9429a525ddc714af0623d1
3
+ metadata.gz: e077560817f865c035c136edffd868cbabb934b6a8986614c5e1c48139b05b9b
4
+ data.tar.gz: 6511b321b0245010d0df81cea6e5b19950de430753a34aea54f4c99c2fa10fc9
5
5
  SHA512:
6
- metadata.gz: 6b3e3dc46705d4f7cf52aabaac8b272cbc9241e57473caf11a6c53b1d1355ec2e7874ae485732ba526f67445144f12daccc521dae40a6405994e409a34e00ede
7
- data.tar.gz: 89385e8ec7942af10b79b267e6400bfb209af7871775e0891f86153e792e3ef61b15b08eb025f9a6f6d5673c3aaf9bcbb6ec26200ac3a62440b426121fcba1fa
6
+ metadata.gz: 7e807c4b7fc8eae29bbcfea9fa6c855f2b627caf2403965e5e0e4d1792a39612e74db2a37e85be72e60f2b3a9e200a70cedf695ed57256e27c03b2569bc9ba26
7
+ data.tar.gz: 77f38f9577c366005a6b06c25a6287b3631d8d2f4dbcfe54043a0c882587600f95c94f10e4110356573f72a1041e6e7ab0b05fc40bbf9d353258ee7a5dc1fc24
@@ -105,6 +105,34 @@ module Markbridge
105
105
  @children[index] = new_child
106
106
  self
107
107
  end
108
+
109
+ # Replace this element's entire child list in one shot.
110
+ #
111
+ # A plain validated setter for tree-rewriting passes (e.g. the
112
+ # {Markbridge::Normalizer}) that rebuild an element's children out
113
+ # of band and need to commit the result without re-running the
114
+ # per-append logic of {#<<}. Every entry must be a {Node}.
115
+ #
116
+ # NOTE: unlike {#<<}, this does *not* merge adjacent {Text} nodes —
117
+ # the auto-merge invariant is a property of {#<<} only. Callers that
118
+ # build the array themselves own that coalescing (the Normalizer's
119
+ # walker merges adjacent text as it assembles the list, so it never
120
+ # hands a state {#<<} would not have produced).
121
+ #
122
+ # @param new_children [Array<Node>] the replacement children
123
+ # @return [Element] +self+
124
+ # @raise [TypeError] when any entry is not a {Node}
125
+ def replace_children(new_children)
126
+ new_children.each do |child|
127
+ next if child.is_a?(Node)
128
+
129
+ actual = child.nil? ? "nil" : child.class
130
+ raise TypeError, "replace_children on #{self.class} expected #{Node}s, got #{actual}"
131
+ end
132
+
133
+ @children = new_children
134
+ self
135
+ end
108
136
  end
109
137
  end
110
138
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ class Normalizer
5
+ # Tally of the transformations a single {Normalizer#normalize} pass
6
+ # applied. Held as a local per call (never on the Normalizer), so a
7
+ # frozen shared instance stays reusable and thread-safe.
8
+ class Report
9
+ def initialize
10
+ @counts = Hash.new(0)
11
+ end
12
+
13
+ # @param parent_class [Class] the offending ancestor's class
14
+ # @param child_class [Class] the moved/removed node's class
15
+ # @param strategy [Symbol] the strategy actually applied
16
+ def record(parent_class, child_class, strategy)
17
+ @counts[[demodulize(parent_class), demodulize(child_class), strategy]] += 1
18
+ end
19
+
20
+ # @return [Boolean]
21
+ def empty?
22
+ @counts.empty?
23
+ end
24
+
25
+ # One +{parent:, child:, strategy:, count:}+ row per distinct
26
+ # transformation, e.g.
27
+ # +{parent: "Url", child: "Image", strategy: :hoist_after, count: 3}+.
28
+ #
29
+ # @return [Array<Hash>]
30
+ def to_a
31
+ @counts.map { |(parent, child, strategy), count| { parent:, child:, strategy:, count: } }
32
+ end
33
+
34
+ private
35
+
36
+ def demodulize(klass)
37
+ klass.name.split("::").last
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ class Normalizer
5
+ # Maps a node and its ancestor stack to a strategy.
6
+ #
7
+ # Matching is exact-class (equivalent to +instance_of?+): rules are
8
+ # keyed by +Class+ and looked up via +node.class+, so an anonymous
9
+ # +Class.new(AST::Element)+ or any future subclass never accidentally
10
+ # matches a rule written for the base class. Registering a rule for a
11
+ # +(parent, child)+ pair that already has one replaces it, so later
12
+ # layers (Discourse, a consumer's +#rule+) override earlier ones.
13
+ class RuleSet
14
+ NO_MATCH = [nil, nil].freeze
15
+
16
+ def initialize
17
+ @by_parent = {} # parent_class => { child_class => strategy }
18
+ @child_classes = Set.new
19
+ end
20
+
21
+ # Register (or replace) a rule.
22
+ #
23
+ # @param parent [Class] ancestor AST class
24
+ # @param child [Class] contained AST class
25
+ # @param strategy [Symbol, #call] a strategy symbol or callable
26
+ # @return [self]
27
+ def add(parent:, child:, strategy:)
28
+ validate_strategy!(strategy)
29
+ (@by_parent[parent] ||= {})[child] = strategy
30
+ @child_classes << child
31
+ self
32
+ end
33
+
34
+ # Resolve the strategy for +child+ given its ancestor stack (root
35
+ # first). Returns +[strategy, boundary]+ where +boundary+ is the
36
+ # *outermost* ancestor whose class has a rule for +child+'s class, or
37
+ # {NO_MATCH} (+[nil, nil]+) when nothing matches.
38
+ #
39
+ # @param child [AST::Node]
40
+ # @param ancestors [Array<AST::Element>] root-first ancestor stack
41
+ # @return [Array(Object, AST::Element), Array(nil, nil)]
42
+ def resolve(child, ancestors)
43
+ child_class = child.class
44
+ # Skip the ancestor scan for a class no rule targets (most nodes, for
45
+ # example plain text). The scan below returns the same result for such
46
+ # a class, so this only saves work.
47
+ return NO_MATCH unless @child_classes.include?(child_class)
48
+
49
+ scan_ancestors(child_class, ancestors)
50
+ end
51
+
52
+ # Freeze so a shared instance raises if something tries to change it.
53
+ # Freezing +@by_parent+ and its inner hashes is enough: {#add} writes
54
+ # there before it touches +@child_classes+, so a frozen instance raises
55
+ # on the +@by_parent+ write first. +@child_classes+ is never reached, so
56
+ # it does not need freezing.
57
+ def freeze
58
+ @by_parent.each_value(&:freeze)
59
+ @by_parent.freeze
60
+ super
61
+ end
62
+
63
+ private
64
+
65
+ # The ancestor scan behind {#resolve}: the outermost matching ancestor
66
+ # wins. It is split from the skip check in {#resolve} so the scan can be
67
+ # tested on its own.
68
+ def scan_ancestors(child_class, ancestors)
69
+ ancestors.each do |ancestor|
70
+ strategies = @by_parent[ancestor.class]
71
+ next unless strategies
72
+
73
+ strategy = strategies[child_class]
74
+ return strategy, ancestor if strategy
75
+ end
76
+ NO_MATCH
77
+ end
78
+
79
+ def validate_strategy!(strategy)
80
+ return if strategy.respond_to?(:call)
81
+ return if STRATEGIES.include?(strategy)
82
+
83
+ raise ArgumentError,
84
+ "unknown strategy #{strategy.inspect} " \
85
+ "(expected one of #{STRATEGIES.inspect} or a callable)"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ class Normalizer
5
+ # Best-effort plain-text projection of a subtree, used by the
6
+ # +:textify+ strategy. Concatenates text content; renders a {AST::Mention}
7
+ # as its literal +@name+, and opaque leaf nodes as their alt/raw text
8
+ # when they carry one, otherwise the empty string.
9
+ module TextProjection
10
+ class << self
11
+ # @param node [AST::Node]
12
+ # @return [String]
13
+ def call(node)
14
+ case node
15
+ when AST::Text, AST::MarkdownText
16
+ node.text
17
+ when AST::Mention
18
+ "@#{node.name}"
19
+ when AST::Element
20
+ node.children.map { |child| call(child) }.join
21
+ else
22
+ leaf_text(node)
23
+ end
24
+ end
25
+
26
+ # @param node [AST::Node] an opaque leaf (Upload, Attachment, …)
27
+ # @return [String]
28
+ def leaf_text(node)
29
+ return node.alt if node.respond_to?(:alt) && node.alt
30
+ return node.raw if node.respond_to?(:raw) && node.raw
31
+
32
+ ""
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ class Normalizer
5
+ # The parent-aware tree-rewriting engine. One {Walker} is built per
6
+ # {Normalizer#normalize} call (holding that call's {RuleSet} and
7
+ # {Report}; no state is kept on the Normalizer). It changes the tree
8
+ # in place via {AST::Element#replace_children}, touching only elements
9
+ # whose children actually changed.
10
+ #
11
+ # The main rule is resolve-before-descend: a child's strategy is resolved
12
+ # against its current ancestor stack first, and a moved subtree (hoist or
13
+ # unwrap) is then walked against the ancestor stack it will have in its
14
+ # new place. So a node that leaves a link does not see the link while its
15
+ # own inside is normalized. That keeps a legally nested quote-in-quote or
16
+ # image-in-quote intact, and it means a second normalize reports nothing.
17
+ #
18
+ # Hoisting: a node taken out of an inline container is moved up, tagged
19
+ # with its boundary (the outermost matching ancestor, compared by
20
+ # identity), and placed right after that boundary, at the boundary's
21
+ # parent. A wrapper left empty by a hoist or drop is removed (see
22
+ # {PRUNE_WHEN_EMPTY}), so no empty +**+ +**+ markers stay.
23
+ #
24
+ # For speed, the ancestor stack is one shared array that is pushed and
25
+ # popped, and an element's children list is copied only when a child
26
+ # first changes (copy-on-write). A subtree with no violations allocates
27
+ # nothing and is left as it was, so the pass can run by default.
28
+ class Walker
29
+ EMPTY = [].freeze
30
+
31
+ # Wrappers that mean nothing once they are empty, so an empty one is
32
+ # removed instead of kept. +AST::Url+ is not here on purpose: an empty
33
+ # link still renders as a bare URL, so a hoist that empties a link keeps
34
+ # the link.
35
+ PRUNE_WHEN_EMPTY = [
36
+ AST::Bold,
37
+ AST::Italic,
38
+ AST::Underline,
39
+ AST::Strikethrough,
40
+ AST::Superscript,
41
+ AST::Subscript,
42
+ AST::Color,
43
+ AST::Size,
44
+ AST::Align,
45
+ AST::Email,
46
+ ].freeze
47
+
48
+ # @param rule_set [RuleSet]
49
+ # @param report [Report]
50
+ def initialize(rule_set, report)
51
+ @rules = rule_set
52
+ @report = report
53
+ end
54
+
55
+ # Normalize +document+'s subtree in place.
56
+ # @param document [AST::Document]
57
+ def call(document)
58
+ _element, bubble = normalize_element(document, [])
59
+ # Defensive: anything that never reached its boundary becomes a
60
+ # trailing sibling rather than being lost. Well-formed rule tables
61
+ # do not get here.
62
+ bubble.each { |node, _boundary| document << node }
63
+ end
64
+
65
+ private
66
+
67
+ # Copy-on-write check: a kept child that came back as the same object
68
+ # (so normalizing it changed nothing) with no bubble, while nothing
69
+ # earlier changed, needs no rebuilt +out+. This only saves work. If it
70
+ # is wrong, the code still rebuilds the same child list, so its
71
+ # mutations do not change the output.
72
+ def unchanged?(out, normalized, child, child_bubble)
73
+ out.nil? && normalized.equal?(child) && child_bubble.empty?
74
+ end
75
+
76
+ # Normalize a node's descendants. Elements recurse; leaves are
77
+ # returned untouched. Returns +[node_or_nil, bubble]+ — +nil+ when the
78
+ # element was pruned, and +bubble+ is the list of +[node, boundary]+
79
+ # pairs to hoist above this node.
80
+ def normalize_node(node, stack)
81
+ return node, EMPTY unless node.is_a?(AST::Element)
82
+
83
+ normalize_element(node, stack)
84
+ end
85
+
86
+ # +stack+ is a shared, mutable ancestor stack (root first): +element+
87
+ # is pushed for the duration of its children's processing and popped
88
+ # after. +out+ (the rebuilt child list) and +bubble+ stay +nil+ until
89
+ # a child actually changes, so the clean path allocates nothing.
90
+ def normalize_element(element, stack)
91
+ stack.push(element)
92
+ children = element.children
93
+ out = nil
94
+ bubble = nil
95
+
96
+ children.each_with_index do |child, index|
97
+ strategy, boundary = @rules.resolve(child, stack)
98
+ strategy = strategy.call(boundary, child) if strategy.respond_to?(:call)
99
+
100
+ if strategy.nil? || strategy == :keep
101
+ normalized, child_bubble = normalize_node(child, stack)
102
+ next if unchanged?(out, normalized, child, child_bubble)
103
+
104
+ out ||= children[0, index]
105
+ bubble = append_kept(normalized, child_bubble, child, out, bubble)
106
+ else
107
+ out ||= children[0, index]
108
+ bubble = emit(child, strategy, boundary, stack, out, bubble)
109
+ end
110
+ end
111
+
112
+ stack.pop
113
+ element.replace_children(coalesce(out)) if out
114
+
115
+ raised = bubble || EMPTY
116
+ return nil, raised if prune?(element, out, children)
117
+
118
+ [element, raised]
119
+ end
120
+
121
+ # An element is removed when it ends up with no children and is one of
122
+ # the wrappers in {PRUNE_WHEN_EMPTY}.
123
+ def prune?(element, out, children)
124
+ empty = out ? out.empty? : children.empty?
125
+ empty && PRUNE_WHEN_EMPTY.include?(element.class)
126
+ end
127
+
128
+ # Resolve one child (against +stack+) and place it into an existing
129
+ # +out+ — the shared entry point used by {#emit}'s +:unwrap+ recursion,
130
+ # where +out+ already exists.
131
+ def resolve_into(child, stack, out, bubble)
132
+ strategy, boundary = @rules.resolve(child, stack)
133
+ strategy = strategy.call(boundary, child) if strategy.respond_to?(:call)
134
+
135
+ if strategy.nil? || strategy == :keep
136
+ child2, child_bubble = normalize_node(child, stack)
137
+ append_kept(child2, child_bubble, child, out, bubble)
138
+ else
139
+ emit(child, strategy, boundary, stack, out, bubble)
140
+ end
141
+ end
142
+
143
+ # Append a kept child that is already normalized, then place any of its
144
+ # bubbles whose boundary is this child. ({#land} does nothing when
145
+ # +child_bubble+ is empty, so there is no separate check for that.)
146
+ def append_kept(normalized, child_bubble, child, out, bubble)
147
+ out << normalized unless normalized.nil?
148
+ land(child_bubble, child, out, bubble)
149
+ end
150
+
151
+ # Apply a non-keep strategy for +child+, appending to +out+ and
152
+ # returning the (possibly newly allocated) +bubble+.
153
+ def emit(child, strategy, boundary, stack, out, bubble)
154
+ case strategy
155
+ when :hoist_after
156
+ hoist(child, boundary, stack, bubble)
157
+ when :unwrap
158
+ unwrap(child, boundary, stack, out, bubble)
159
+ when :textify
160
+ @report.record(boundary.class, child.class, :textify)
161
+ out << AST::Text.new(TextProjection.call(child))
162
+ bubble
163
+ when :drop
164
+ @report.record(boundary.class, child.class, :drop)
165
+ bubble
166
+ when Array
167
+ # A callable returned replacement nodes to splice in place.
168
+ @report.record(boundary.class, child.class, :replace)
169
+ strategy.each { |node| out << node }
170
+ bubble
171
+ else
172
+ raise ArgumentError, "strategy resolved to #{strategy.inspect}"
173
+ end
174
+ end
175
+
176
+ def hoist(child, boundary, stack, bubble)
177
+ @report.record(boundary.class, child.class, :hoist_after)
178
+ # Walk the relocated subtree against its destination stack (the
179
+ # ancestors strictly above the boundary) so its interior never sees
180
+ # the boundary it is leaving.
181
+ child2, child_bubble = normalize_node(child, ancestors_above(boundary, stack))
182
+ bubble ||= []
183
+ bubble << [child2, boundary] unless child2.nil?
184
+ # For the built-in tables a hoisted subtree yields no escaping
185
+ # bubbles; carry any (from a custom rule) up as a best effort.
186
+ child_bubble.each { |entry| bubble << entry }
187
+ bubble
188
+ end
189
+
190
+ def unwrap(child, boundary, stack, out, bubble)
191
+ # Unwrap means "promote the element's children"; a leaf has none. A
192
+ # rule that targets one is a misconfiguration, so keep the node in
193
+ # place rather than silently dropping it (and don't report a no-op).
194
+ unless child.is_a?(AST::Element)
195
+ out << child
196
+ return bubble
197
+ end
198
+
199
+ @report.record(boundary.class, child.class, :unwrap)
200
+ # Unwrap: run the child's children through the current +out+, resolved
201
+ # against the current stack. Resolving them again in the same pass is
202
+ # what fixes deeply nested links in one go.
203
+ child.children.each { |grandchild| bubble = resolve_into(grandchild, stack, out, bubble) }
204
+ bubble
205
+ end
206
+
207
+ # Land inbound bubbles whose boundary is +child+ (this level is the
208
+ # boundary's parent), each after the previous to preserve order;
209
+ # propagate the rest upward.
210
+ def land(child_bubble, child, out, bubble)
211
+ child_bubble.each do |node, boundary|
212
+ if boundary.equal?(child)
213
+ out << node
214
+ else
215
+ bubble ||= []
216
+ bubble << [node, boundary]
217
+ end
218
+ end
219
+ bubble
220
+ end
221
+
222
+ # Ancestors strictly above +boundary+ in +stack+ — the stack the
223
+ # hoisted node inherits (its parent becomes the boundary's parent).
224
+ def ancestors_above(boundary, stack)
225
+ index = stack.index { |ancestor| ancestor.equal?(boundary) }
226
+ stack.first(index)
227
+ end
228
+
229
+ # Coalesce adjacent text (textify can create neighbours that +#<<+
230
+ # would have merged) just before committing a changed child list.
231
+ def coalesce(nodes)
232
+ nodes.each_with_object([]) do |node, acc|
233
+ last = acc.last
234
+ # mergeable? is false when +last+ is nil (the first node), so no
235
+ # separate nil-guard is needed.
236
+ if mergeable?(last, node)
237
+ last.merge(node)
238
+ else
239
+ acc << node
240
+ end
241
+ end
242
+ end
243
+
244
+ def mergeable?(left, right)
245
+ (left.instance_of?(AST::Text) && right.instance_of?(AST::Text)) ||
246
+ (left.instance_of?(AST::MarkdownText) && right.instance_of?(AST::MarkdownText))
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "normalizer/rule_set"
4
+ require_relative "normalizer/report"
5
+ require_relative "normalizer/text_projection"
6
+ require_relative "normalizer/walker"
7
+
8
+ module Markbridge
9
+ # Rewrites an AST so the renderer only gets markup the target format can
10
+ # express. It runs once, between parse and render. The default rules are
11
+ # CommonMark legality: no link inside a link, no block element inside an
12
+ # inline container, and an inline-only code span. Each match resolves to a
13
+ # strategy (+:keep+, +:hoist_after+, +:unwrap+, +:textify+, +:drop+, or a
14
+ # callable) that the {Walker} applies.
15
+ #
16
+ # Discourse-specific policy (for example, moving an image out of a link) is
17
+ # not built in. A consumer adds those with {#rule}.
18
+ #
19
+ # @example The default, reused across conversions
20
+ # Markbridge::Normalizer.shared_default
21
+ #
22
+ # @example A customized normalizer
23
+ # n = Markbridge::Normalizer.default
24
+ # n.rule(parent: AST::Url, child: AST::Image, strategy: :hoist_after)
25
+ # Markbridge.convert(input, format: :bbcode, normalize: n)
26
+ #
27
+ # @example List what would change, without changing it
28
+ # Markbridge::Normalizer.default.violations(ast) # => [...]
29
+ class Normalizer
30
+ # The strategy symbols a rule may resolve to.
31
+ STRATEGIES = %i[keep hoist_after unwrap textify drop].freeze
32
+
33
+ EMPTY_STACK = [].freeze
34
+ private_constant :EMPTY_STACK
35
+
36
+ # Containers that hold inline content only: a link's text (CommonMark
37
+ # §6.3), and emphasis and heading content.
38
+ INLINE_CONTAINERS = [
39
+ AST::Url,
40
+ AST::Bold,
41
+ AST::Italic,
42
+ AST::Strikethrough,
43
+ AST::Underline,
44
+ AST::Superscript,
45
+ AST::Subscript,
46
+ AST::Heading,
47
+ ].freeze
48
+
49
+ # AST nodes the Discourse renderer prints as block-level Markdown (their
50
+ # output has blank lines around it). One inside an inline container breaks
51
+ # that container, so it is moved out. Spoiler and single-line Code stay
52
+ # inline and are not listed (Code is handled by {KEEP_INLINE_CODE}).
53
+ BLOCK_NODES = [
54
+ AST::Quote,
55
+ AST::Heading,
56
+ AST::List,
57
+ AST::ListItem,
58
+ AST::Table,
59
+ AST::TableRow,
60
+ AST::TableCell,
61
+ AST::Details,
62
+ AST::Paragraph,
63
+ AST::HorizontalRule,
64
+ AST::Align,
65
+ AST::Poll,
66
+ AST::Event,
67
+ ].freeze
68
+
69
+ # A code span may stay inside an inline container while it is on one line.
70
+ # A fenced or multi-line block is moved out. This matches
71
+ # +RenderingInterface#block_context?+: Code prints as a fenced block when a
72
+ # Text child has a newline (the language alone does not make it a block).
73
+ KEEP_INLINE_CODE =
74
+ lambda do |_boundary, node|
75
+ block = node.children.any? { |c| c.instance_of?(AST::Text) && c.text.include?("\n") }
76
+ block ? :hoist_after : :keep
77
+ end
78
+
79
+ class << self
80
+ # A fresh, customizable normalizer with the default rules. Add more with
81
+ # {#rule}.
82
+ # @return [Normalizer]
83
+ def default
84
+ new(build_rules)
85
+ end
86
+
87
+ # The default normalizer, built once and frozen, reused across
88
+ # conversions. +#normalize+ and +#violations+ keep no state on the
89
+ # instance, so one frozen instance is safe to reuse, also across threads.
90
+ # @return [Normalizer] the same frozen instance on every call
91
+ def shared_default
92
+ @shared_default ||= default.freeze
93
+ end
94
+
95
+ private
96
+
97
+ def build_rules
98
+ rules = RuleSet.new
99
+
100
+ # §6.3 A link may not contain another link, at any depth. Unwrap the
101
+ # inner link and keep its text.
102
+ rules.add(parent: AST::Url, child: AST::Url, strategy: :unwrap)
103
+
104
+ INLINE_CONTAINERS.each do |container|
105
+ rules.add(parent: container, child: AST::Code, strategy: KEEP_INLINE_CODE)
106
+
107
+ BLOCK_NODES.each do |block|
108
+ next if container == block
109
+
110
+ rules.add(parent: container, child: block, strategy: :hoist_after)
111
+ end
112
+ end
113
+
114
+ rules
115
+ end
116
+ end
117
+
118
+ # @param rule_set [RuleSet]
119
+ def initialize(rule_set)
120
+ @rules = rule_set
121
+ end
122
+
123
+ # Add or override a rule. Chainable. A rule for a +(parent, child)+ pair
124
+ # that already exists is replaced. Raises on a frozen ({shared_default})
125
+ # instance; build a fresh one with {.default}.
126
+ #
127
+ # @param parent [Class] ancestor AST class
128
+ # @param child [Class] contained AST class
129
+ # @param strategy [Symbol, #call] one of {STRATEGIES} or a callable
130
+ # @return [self]
131
+ def rule(parent:, child:, strategy:)
132
+ @rules.add(parent:, child:, strategy:)
133
+ self
134
+ end
135
+
136
+ # Rewrite +ast+ in place so it satisfies the rules.
137
+ #
138
+ # @param ast [AST::Document, AST::Element]
139
+ # @return [Array<Hash>] a report of what changed (empty when nothing did),
140
+ # one +{parent:, child:, strategy:, count:}+ row per distinct change.
141
+ def normalize(ast)
142
+ report = Report.new
143
+ Walker.new(@rules, report).call(ast)
144
+ report.to_a
145
+ end
146
+
147
+ # List the violations in +ast+ without changing it.
148
+ #
149
+ # @param ast [AST::Document, AST::Element]
150
+ # @return [Array<Hash>] +{parent:, child:, strategy:}+ per occurrence
151
+ def violations(ast)
152
+ found = []
153
+ collect_violations(ast, EMPTY_STACK, found)
154
+ found
155
+ end
156
+
157
+ def freeze
158
+ @rules.freeze
159
+ super
160
+ end
161
+
162
+ private
163
+
164
+ def collect_violations(element, ancestors, found)
165
+ stack = ancestors + [element]
166
+ element.children.each do |child|
167
+ strategy, boundary = @rules.resolve(child, stack)
168
+ strategy = strategy.call(boundary, child) if strategy.respond_to?(:call)
169
+ unless strategy.nil? || strategy == :keep
170
+ found << { parent: demodulize(boundary.class), child: demodulize(child.class), strategy: }
171
+ end
172
+ collect_violations(child, stack, found) if child.is_a?(AST::Element)
173
+ end
174
+ end
175
+
176
+ def demodulize(klass)
177
+ klass.name.split("::").last
178
+ end
179
+ end
180
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Markbridge
4
- VERSION = "0.3.0"
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.3.0
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