markbridge 0.3.0 → 0.4.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/lib/markbridge/ast/code.rb +8 -1
  3. data/lib/markbridge/ast/element.rb +28 -0
  4. data/lib/markbridge/ast/node.rb +41 -0
  5. data/lib/markbridge/normalizer/report.rb +41 -0
  6. data/lib/markbridge/normalizer/rule_set.rb +163 -0
  7. data/lib/markbridge/normalizer/text_projection.rb +37 -0
  8. data/lib/markbridge/normalizer/walker.rb +255 -0
  9. data/lib/markbridge/normalizer.rb +187 -0
  10. data/lib/markbridge/parsers/bbcode/handlers/code_handler.rb +17 -0
  11. data/lib/markbridge/parsers/html/handler_registry.rb +1 -0
  12. data/lib/markbridge/parsers/html/handlers/heading_handler.rb +29 -0
  13. data/lib/markbridge/parsers/html/handlers/raw_handler.rb +55 -4
  14. data/lib/markbridge/parsers/html.rb +1 -0
  15. data/lib/markbridge/parsers/media_wiki/parser.rb +2 -2
  16. data/lib/markbridge/parsers/text_formatter/handlers/code_handler.rb +3 -1
  17. data/lib/markbridge/renderers/discourse/html_block_safety.rb +31 -0
  18. data/lib/markbridge/renderers/discourse/renderer.rb +36 -6
  19. data/lib/markbridge/renderers/discourse/rendering_interface.rb +4 -0
  20. data/lib/markbridge/renderers/discourse/tag.rb +10 -0
  21. data/lib/markbridge/renderers/discourse/tag_library.rb +59 -4
  22. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +4 -0
  23. data/lib/markbridge/renderers/discourse/tags/details_tag.rb +6 -2
  24. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +2 -4
  25. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +2 -4
  26. data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +4 -0
  27. data/lib/markbridge/renderers/discourse.rb +1 -0
  28. data/lib/markbridge/rspec.rb +46 -0
  29. data/lib/markbridge/version.rb +1 -1
  30. data/lib/markbridge.rb +65 -13
  31. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbebafc60a3665c98544f3cfd9496c3c5cdc2c0940339c998791b2df1f04aa44
4
- data.tar.gz: 2be35c042f803e37302fa149af772897326241488a9429a525ddc714af0623d1
3
+ metadata.gz: 6d958f4d4c04108de2f92388f6a62ac37ab126700f535af2c6cf38f832928e20
4
+ data.tar.gz: 86510bc530a71fefb9f30e350a7800d159dd9e5fa77231f2f6259253373c11b4
5
5
  SHA512:
6
- metadata.gz: 6b3e3dc46705d4f7cf52aabaac8b272cbc9241e57473caf11a6c53b1d1355ec2e7874ae485732ba526f67445144f12daccc521dae40a6405994e409a34e00ede
7
- data.tar.gz: 89385e8ec7942af10b79b267e6400bfb209af7871775e0891f86153e792e3ef61b15b08eb025f9a6f6d5673c3aaf9bcbb6ec26200ac3a62440b426121fcba1fa
6
+ metadata.gz: 828a0d60a75ac730dd882e7825e2ea2e0fec53ca6b2efea6329cac03030157fba4ea086118476445ea9505835ab28031221ed786be526bdfb1b2340e2c570c56
7
+ data.tar.gz: 3df501c15e4050edcbd2225d356440e4672d30a5feb8d85a3b225af0daa4cd66f09f743044ac41b7e75b01a8378951aceac7784078833facb4d42cefcfc3aa24
@@ -15,12 +15,19 @@ module Markbridge
15
15
  # @return [String, nil] the programming language for syntax highlighting
16
16
  attr_reader :language
17
17
 
18
+ # @return [Boolean, nil] +true+ forces a fenced block; anything else
19
+ # leaves the block-or-inline decision to the renderer
20
+ attr_reader :block
21
+
18
22
  # Create a new code element.
19
23
  #
20
24
  # @param language [String, nil] optional language identifier for syntax highlighting
21
- def initialize(language: nil)
25
+ # @param block [Boolean, nil] +true+ when the source construct is a code
26
+ # block by definition (e.g. +<pre>+), even with single-line content
27
+ def initialize(language: nil, block: nil)
22
28
  super()
23
29
  @language = language
30
+ @block = block
24
31
  end
25
32
  end
26
33
  end
@@ -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
@@ -14,6 +14,47 @@ module Markbridge
14
14
  #
15
15
  # @abstract Subclass and add specific behavior
16
16
  class Node
17
+ # Registry of every class that inherits from {Node}, direct or not.
18
+ # This is boot-time state: the built-in classes land here while the
19
+ # gem's requires run, and a consumer subclass is appended when its
20
+ # class body runs. Anything built from this list at freeze time
21
+ # (see +TagLibrary#freeze+ and +RuleSet#freeze+) covers only the
22
+ # classes defined up to that point; later classes go through the
23
+ # lazy ancestry lookups instead.
24
+ DESCENDANTS = []
25
+
26
+ # No +super+ on purpose: +Class#inherited+ is a no-op, so calling
27
+ # it adds nothing observable (verified by mutation testing).
28
+ # @param subclass [Class]
29
+ def self.inherited(subclass)
30
+ DESCENDANTS << subclass
31
+ end
32
+
33
+ # @return [Array<Class>] every known descendant class, in definition
34
+ # order (see {DESCENDANTS} — boot-time state)
35
+ def self.descendants
36
+ DESCENDANTS
37
+ end
38
+
39
+ # The class chain from this class up to {Node}, most specific class
40
+ # first. Cached on the class: the chain is pure class structure and
41
+ # can never change once the class is defined, so one array per class
42
+ # serves the whole process. The normalizer and the tag library use
43
+ # it for ancestry matching.
44
+ #
45
+ # @return [Array<Class>] frozen, e.g. +[Bold, Element, Node]+
46
+ def self.ast_chain
47
+ @ast_chain ||=
48
+ begin
49
+ chain = [self]
50
+ sup = superclass
51
+ while sup <= Node
52
+ chain << sup
53
+ sup = sup.superclass
54
+ end
55
+ chain.freeze
56
+ end
57
+ end
17
58
  end
18
59
  end
19
60
  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,163 @@
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
+ # Rules are keyed by +Class+ on both sides, and matching follows the
8
+ # class ancestry: a rule registered for a base class also applies to
9
+ # its subclasses, for the parent and for the child. Only classes
10
+ # inside the +AST::Node+ hierarchy take part; the walk never reaches
11
+ # +Object+, so a rule keyed on a class outside the AST never matches.
12
+ #
13
+ # Precedence, in this order:
14
+ # 1. Stack position — the outermost ancestor in the stack with any
15
+ # matching rule wins.
16
+ # 2. Child class specificity — at that ancestor, a rule keyed on the
17
+ # node's own class beats a rule keyed on a superclass.
18
+ # 3. Parent class specificity — then a rule keyed on the ancestor's
19
+ # own class beats a rule keyed on a superclass.
20
+ #
21
+ # A rule registered for the exact +(parent, child)+ pair therefore
22
+ # always overrides an inherited one. Registering a rule for a pair
23
+ # that already has one replaces it, so later layers (Discourse, a
24
+ # consumer's +#rule+) override earlier ones.
25
+ class RuleSet
26
+ NO_MATCH = [nil, nil].freeze
27
+
28
+ # Shared "no rule targets this class" marker, so the cache entry for
29
+ # a rule-less class costs no allocation.
30
+ EMPTY_CANDIDATES = [].freeze
31
+
32
+ def initialize
33
+ @by_parent = {} # parent_class => { child_class => strategy }
34
+ @child_classes = Set.new
35
+ # @candidates_cache stays unset (nil) until #freeze fills it.
36
+ end
37
+
38
+ # Register (or replace) a rule.
39
+ #
40
+ # @param parent [Class] ancestor AST class
41
+ # @param child [Class] contained AST class
42
+ # @param strategy [Symbol, #call] a strategy symbol or callable
43
+ # @return [self]
44
+ def add(parent:, child:, strategy:)
45
+ validate_strategy!(strategy)
46
+ (@by_parent[parent] ||= {})[child] = strategy
47
+ @child_classes << child
48
+ self
49
+ end
50
+
51
+ # Resolve the strategy for +child+ given its ancestor stack (root
52
+ # first). Returns +[strategy, boundary]+ where +boundary+ is the
53
+ # *outermost* ancestor with a rule that matches +child+'s class, or
54
+ # {NO_MATCH} (+[nil, nil]+) when nothing matches.
55
+ #
56
+ # +walk_cache+ caches the child-rule candidates per class. The caller owns
57
+ # it and should reuse one Hash for a whole tree walk, so each
58
+ # distinct class is analyzed at most once per walk. Keeping the
59
+ # cache outside the RuleSet leaves a frozen shared instance free of
60
+ # per-call state. The cache read is a single Hash lookup because this
61
+ # method runs for every node in the tree.
62
+ #
63
+ # @param child [AST::Node]
64
+ # @param ancestors [Array<AST::Element>] root-first ancestor stack
65
+ # @param walk_cache [Hash{Class => Array<Class>}] per-walk cache owned by
66
+ # the caller
67
+ # @return [Array(Object, AST::Element), Array(nil, nil)]
68
+ def resolve(child, ancestors, walk_cache)
69
+ klass = child.class
70
+ # A frozen rule set carries a prebuilt candidates cache (see
71
+ # #freeze); the walk cache is only needed for classes defined after the
72
+ # freeze and for mutable rule sets.
73
+ frozen_cache = @candidates_cache
74
+ candidates =
75
+ (frozen_cache && frozen_cache[klass]) || (walk_cache[klass] ||= child_candidates(klass))
76
+ # Skip the ancestor scan for a class no rule targets (most nodes, for
77
+ # example plain text). The scan below returns the same result for such
78
+ # a class, so this only saves work.
79
+ return NO_MATCH if candidates.empty?
80
+
81
+ scan_ancestors(candidates, ancestors)
82
+ end
83
+
84
+ # Freeze so a shared instance raises if something tries to change it.
85
+ # Freezing +@by_parent+ and its inner hashes is enough: {#add} writes
86
+ # there before it touches +@child_classes+, so a frozen instance raises
87
+ # on the +@by_parent+ write first. +@child_classes+ is never reached, so
88
+ # it does not need freezing.
89
+ #
90
+ # Freezing also precomputes the child-rule candidates for every AST
91
+ # class known at this point (+AST::Node.descendants+ is boot-time
92
+ # state), so {#resolve} on a frozen rule set answers with one Hash
93
+ # lookup instead of going through the per-walk cache. The rules can
94
+ # not change anymore, so the cache can never go stale; classes
95
+ # defined after the freeze fall back to the walk cache. The +||=+ keeps a
96
+ # second freeze from writing to the then-frozen instance. Like
97
+ # +@child_classes+, the cache Hash stays unfrozen — nothing writes
98
+ # to it after this point.
99
+ def freeze
100
+ @candidates_cache ||=
101
+ AST::Node.descendants.to_h { |klass| [klass, child_candidates(klass)] }
102
+ @by_parent.each_value(&:freeze)
103
+ @by_parent.freeze
104
+ super
105
+ end
106
+
107
+ private
108
+
109
+ # The ancestor scan behind {#resolve}: the outermost ancestor with any
110
+ # matching rule wins (precedence 1). {#best_rule} applies the class
111
+ # specificity order within one ancestor.
112
+ def scan_ancestors(candidates, ancestors)
113
+ ancestors.each do |ancestor|
114
+ strategy = best_rule(candidates, ancestor.class)
115
+ return strategy, ancestor if strategy
116
+ end
117
+ NO_MATCH
118
+ end
119
+
120
+ # The most specific rule at one ancestor: try each child candidate
121
+ # (the node's own class first — precedence 2), and for each walk the
122
+ # ancestor's class chain inside the AST (its own class first —
123
+ # precedence 3). An ancestor whose class is outside the AST has no
124
+ # chain and can never match. (+Module#<=+ is nil for an unrelated
125
+ # class, so the ternary — not an inverted guard — is the correct
126
+ # check here.)
127
+ def best_rule(candidates, ancestor_class)
128
+ chain = ancestor_class <= AST::Node ? ancestor_class.ast_chain : EMPTY_CANDIDATES
129
+ candidates.each do |child_class|
130
+ chain.each do |parent_class|
131
+ strategies = @by_parent[parent_class]
132
+ strategy = strategies[child_class] if strategies
133
+ return strategy if strategy
134
+ end
135
+ end
136
+ nil
137
+ end
138
+
139
+ # The classes in +klass+'s AST chain that at least one rule targets
140
+ # as a child, most specific class first (empty for most classes). A
141
+ # fresh walk cache sees every class once per document, so this allocates
142
+ # nothing unless there is a match. A class outside the AST has no
143
+ # chain and no candidates (+Module#<=+ is nil for an unrelated
144
+ # class, so the ternary — not an inverted guard — is the correct
145
+ # check here).
146
+ def child_candidates(klass)
147
+ chain = klass <= AST::Node ? klass.ast_chain : EMPTY_CANDIDATES
148
+ candidates = nil
149
+ chain.each { |current| (candidates ||= []) << current if @child_classes.include?(current) }
150
+ candidates || EMPTY_CANDIDATES
151
+ end
152
+
153
+ def validate_strategy!(strategy)
154
+ return if strategy.respond_to?(:call)
155
+ return if STRATEGIES.include?(strategy)
156
+
157
+ raise ArgumentError,
158
+ "unknown strategy #{strategy.inspect} " \
159
+ "(expected one of #{STRATEGIES.inspect} or a callable)"
160
+ end
161
+ end
162
+ end
163
+ 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,255 @@
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
+ # Per-run cache for RuleSet#resolve's ancestry analysis. A Walker
54
+ # lives for one normalize call, so each distinct node class is
55
+ # analyzed at most once per run while the RuleSet itself stays
56
+ # free of per-call state.
57
+ @walk_cache = {}
58
+ end
59
+
60
+ # Normalize +document+'s subtree in place.
61
+ # @param document [AST::Document]
62
+ def call(document)
63
+ _element, bubble = normalize_element(document, [])
64
+ # Defensive: anything that never reached its boundary becomes a
65
+ # trailing sibling rather than being lost. Well-formed rule tables
66
+ # do not get here.
67
+ bubble.each { |node, _boundary| document << node }
68
+ end
69
+
70
+ private
71
+
72
+ # Copy-on-write check: a kept child that came back as the same object
73
+ # (so normalizing it changed nothing) with no bubble, while nothing
74
+ # earlier changed, needs no rebuilt +out+. This only saves work. If it
75
+ # is wrong, the code still rebuilds the same child list, so its
76
+ # mutations do not change the output.
77
+ def unchanged?(out, normalized, child, child_bubble)
78
+ out.nil? && normalized.equal?(child) && child_bubble.empty?
79
+ end
80
+
81
+ # Normalize a node's descendants. Elements recurse; leaves are
82
+ # returned untouched. Returns +[node_or_nil, bubble]+ — +nil+ when the
83
+ # element was pruned, and +bubble+ is the list of +[node, boundary]+
84
+ # pairs to hoist above this node.
85
+ def normalize_node(node, stack)
86
+ return node, EMPTY unless node.is_a?(AST::Element)
87
+
88
+ normalize_element(node, stack)
89
+ end
90
+
91
+ # +stack+ is a shared, mutable ancestor stack (root first): +element+
92
+ # is pushed for the duration of its children's processing and popped
93
+ # after. +out+ (the rebuilt child list) and +bubble+ stay +nil+ until
94
+ # a child actually changes, so the clean path allocates nothing.
95
+ def normalize_element(element, stack)
96
+ stack.push(element)
97
+ children = element.children
98
+ out = nil
99
+ bubble = nil
100
+
101
+ children.each_with_index do |child, index|
102
+ strategy, boundary = @rules.resolve(child, stack, @walk_cache)
103
+ strategy = strategy.call(boundary, child) if strategy.respond_to?(:call)
104
+
105
+ if strategy.nil? || strategy == :keep
106
+ normalized, child_bubble = normalize_node(child, stack)
107
+ next if unchanged?(out, normalized, child, child_bubble)
108
+
109
+ out ||= children[0, index]
110
+ bubble = append_kept(normalized, child_bubble, child, out, bubble)
111
+ else
112
+ out ||= children[0, index]
113
+ bubble = emit(child, strategy, boundary, stack, out, bubble)
114
+ end
115
+ end
116
+
117
+ stack.pop
118
+ element.replace_children(coalesce(out)) if out
119
+
120
+ raised = bubble || EMPTY
121
+ return nil, raised if prune?(element, out, children)
122
+
123
+ [element, raised]
124
+ end
125
+
126
+ # An element is removed when it ends up with no children and is one of
127
+ # the wrappers in {PRUNE_WHEN_EMPTY}.
128
+ def prune?(element, out, children)
129
+ empty = out ? out.empty? : children.empty?
130
+ empty && PRUNE_WHEN_EMPTY.include?(element.class)
131
+ end
132
+
133
+ # Resolve one child (against +stack+) and place it into an existing
134
+ # +out+ — the shared entry point used by {#emit}'s +:unwrap+ recursion,
135
+ # where +out+ already exists.
136
+ def resolve_into(child, stack, out, bubble)
137
+ strategy, boundary = @rules.resolve(child, stack, @walk_cache)
138
+ strategy = strategy.call(boundary, child) if strategy.respond_to?(:call)
139
+
140
+ if strategy.nil? || strategy == :keep
141
+ child2, child_bubble = normalize_node(child, stack)
142
+ append_kept(child2, child_bubble, child, out, bubble)
143
+ else
144
+ emit(child, strategy, boundary, stack, out, bubble)
145
+ end
146
+ end
147
+
148
+ # Append a kept child that is already normalized, then place any of its
149
+ # bubbles whose boundary is this child. ({#land} does nothing when
150
+ # +child_bubble+ is empty, so there is no separate check for that.)
151
+ def append_kept(normalized, child_bubble, child, out, bubble)
152
+ out << normalized unless normalized.nil?
153
+ land(child_bubble, child, out, bubble)
154
+ end
155
+
156
+ # Apply a non-keep strategy for +child+, appending to +out+ and
157
+ # returning the (possibly newly allocated) +bubble+.
158
+ def emit(child, strategy, boundary, stack, out, bubble)
159
+ case strategy
160
+ when :hoist_after
161
+ hoist(child, boundary, stack, bubble)
162
+ when :unwrap
163
+ unwrap(child, boundary, stack, out, bubble)
164
+ when :textify
165
+ @report.record(boundary.class, child.class, :textify)
166
+ out << AST::Text.new(TextProjection.call(child))
167
+ bubble
168
+ when :drop
169
+ @report.record(boundary.class, child.class, :drop)
170
+ bubble
171
+ when Array
172
+ # A callable returned replacement nodes to splice in place.
173
+ @report.record(boundary.class, child.class, :replace)
174
+ strategy.each { |node| out << node }
175
+ bubble
176
+ else
177
+ raise ArgumentError, "strategy resolved to #{strategy.inspect}"
178
+ end
179
+ end
180
+
181
+ def hoist(child, boundary, stack, bubble)
182
+ @report.record(boundary.class, child.class, :hoist_after)
183
+ # Walk the relocated subtree against its destination stack (the
184
+ # ancestors strictly above the boundary) so its interior never sees
185
+ # the boundary it is leaving.
186
+ child2, child_bubble = normalize_node(child, ancestors_above(boundary, stack))
187
+ bubble ||= []
188
+ bubble << [child2, boundary] unless child2.nil?
189
+ # For the built-in tables a hoisted subtree yields no escaping
190
+ # bubbles; carry any (from a custom rule) up as a best effort.
191
+ child_bubble.each { |entry| bubble << entry }
192
+ bubble
193
+ end
194
+
195
+ def unwrap(child, boundary, stack, out, bubble)
196
+ # Unwrap means "promote the element's children"; a leaf has none. A
197
+ # rule that targets one is a misconfiguration, so keep the node in
198
+ # place rather than silently dropping it (and don't report a no-op).
199
+ unless child.is_a?(AST::Element)
200
+ out << child
201
+ return bubble
202
+ end
203
+
204
+ @report.record(boundary.class, child.class, :unwrap)
205
+ # Unwrap: run the child's children through the current +out+, resolved
206
+ # against the current stack. Resolving them again in the same pass is
207
+ # what fixes deeply nested links in one go.
208
+ child.children.each { |grandchild| bubble = resolve_into(grandchild, stack, out, bubble) }
209
+ bubble
210
+ end
211
+
212
+ # Land inbound bubbles whose boundary is +child+ (this level is the
213
+ # boundary's parent), each after the previous to preserve order;
214
+ # propagate the rest upward.
215
+ def land(child_bubble, child, out, bubble)
216
+ child_bubble.each do |node, boundary|
217
+ if boundary.equal?(child)
218
+ out << node
219
+ else
220
+ bubble ||= []
221
+ bubble << [node, boundary]
222
+ end
223
+ end
224
+ bubble
225
+ end
226
+
227
+ # Ancestors strictly above +boundary+ in +stack+ — the stack the
228
+ # hoisted node inherits (its parent becomes the boundary's parent).
229
+ def ancestors_above(boundary, stack)
230
+ index = stack.index { |ancestor| ancestor.equal?(boundary) }
231
+ stack.first(index)
232
+ end
233
+
234
+ # Coalesce adjacent text (textify can create neighbours that +#<<+
235
+ # would have merged) just before committing a changed child list.
236
+ def coalesce(nodes)
237
+ nodes.each_with_object([]) do |node, acc|
238
+ last = acc.last
239
+ # mergeable? is false when +last+ is nil (the first node), so no
240
+ # separate nil-guard is needed.
241
+ if mergeable?(last, node)
242
+ last.merge(node)
243
+ else
244
+ acc << node
245
+ end
246
+ end
247
+ end
248
+
249
+ def mergeable?(left, right)
250
+ (left.instance_of?(AST::Text) && right.instance_of?(AST::Text)) ||
251
+ (left.instance_of?(AST::MarkdownText) && right.instance_of?(AST::MarkdownText))
252
+ end
253
+ end
254
+ end
255
+ end