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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0cee12c41cea526db53cf389e2cc2bc49310568c0ebe9979d03daa7a4c2d23ac
4
- data.tar.gz: a6d541954eea2357b6bb31bcfb685a634383de405dd1f505b257c78007e087d4
3
+ metadata.gz: e077560817f865c035c136edffd868cbabb934b6a8986614c5e1c48139b05b9b
4
+ data.tar.gz: 6511b321b0245010d0df81cea6e5b19950de430753a34aea54f4c99c2fa10fc9
5
5
  SHA512:
6
- metadata.gz: b7ddd72dd5d0aa197a7b2477f1bfddd72e9cf7a01f26b4def8da341c8edd739503c04a23a501175adbcc23e23064578a2a4bcb17f3518b543f2716059636e3e7
7
- data.tar.gz: 409dcbed4de6b6551c420ca51a00ed998abf11aeac8bd3bce9522b49a5ca07d3e524d68737b9461ddb6e3949485c1c84123c30c8e6e44cb57d7a99d873c7c3af
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
@@ -4,42 +4,72 @@ module Markbridge
4
4
  module AST
5
5
  # Represents a quote/blockquote element.
6
6
  #
7
+ # The attribution fields follow Discourse's quote format
8
+ # (`[quote="username, post:2, topic:456"]`): +post_number+ is the
9
+ # position of the quoted post *within its topic* (not a database
10
+ # id), +topic_id+ is the topic's id. Sources that attribute quotes
11
+ # by database id instead (phpBB, XenForo) use +post_id+ and
12
+ # +user_id+ — those never feed a Discourse +post:+ reference and
13
+ # are carried for consumers to remap.
14
+ #
7
15
  # @example Basic quote
8
16
  # quote = AST::Quote.new
9
17
  # quote << AST::Text.new("quoted text")
10
18
  #
11
19
  # @example Quote with author attribution
12
- # quote = AST::Quote.new(author: "John")
20
+ # quote = AST::Quote.new(author: "alice")
13
21
  # quote << AST::Text.new("quoted text")
14
22
  #
15
23
  # @example Quote with full Discourse context
16
- # quote = AST::Quote.new(author: "John", post: "123", topic: "456", username: "john123")
24
+ # quote = AST::Quote.new(author: "alice", post_number: 123, topic_id: 456, username: "alice_b")
17
25
  # quote << AST::Text.new("quoted text")
18
26
  class Quote < Element
19
- # @return [String, nil] the author/username of the quote
27
+ # @return [String, nil] the author attribution (display name)
20
28
  attr_reader :author
21
29
 
22
- # @return [String, nil] the post ID for Discourse quotes
23
- attr_reader :post
30
+ # @return [Integer, nil] the quoted post's number within its topic
31
+ # (Discourse semantics — not a post id)
32
+ attr_reader :post_number
33
+
34
+ # @return [Integer, nil] the quoted post's database id, for sources
35
+ # that attribute quotes by id (phpBB's +post_id+, XenForo's
36
+ # +post:+). Distinct from {#post_number}; parsers map whichever
37
+ # their dialect provides.
38
+ attr_reader :post_id
24
39
 
25
- # @return [String, nil] the topic ID for Discourse quotes
26
- attr_reader :topic
40
+ # @return [Integer, nil] the topic id the quoted post belongs to
41
+ attr_reader :topic_id
27
42
 
28
- # @return [String, nil] the username for Discourse quotes
43
+ # @return [String, nil] the quoted user's username
29
44
  attr_reader :username
30
45
 
46
+ # @return [Integer, nil] the quoted user's id, for sources that
47
+ # attribute quotes by id (usernames may be unknown or stale)
48
+ attr_reader :user_id
49
+
31
50
  # Create a new Quote element.
32
51
  #
33
- # @param author [String, nil] the author attribution
34
- # @param post [String, nil] the post ID (Discourse-specific)
35
- # @param topic [String, nil] the topic ID (Discourse-specific)
36
- # @param username [String, nil] the username (Discourse-specific)
37
- def initialize(author: nil, post: nil, topic: nil, username: nil)
52
+ # @param author [String, nil] the author attribution (display name)
53
+ # @param post_number [Integer, nil] the quoted post's number within its topic
54
+ # @param post_id [Integer, nil] the quoted post's database id
55
+ # @param topic_id [Integer, nil] the topic id
56
+ # @param username [String, nil] the quoted user's username
57
+ # @param user_id [Integer, nil] the quoted user's id
58
+ def initialize(
59
+ author: nil,
60
+ post_number: nil,
61
+ post_id: nil,
62
+ topic_id: nil,
63
+ username: nil,
64
+ user_id: nil
65
+ )
38
66
  super()
39
67
  @author = author
40
- @post = post
41
- @topic = topic
68
+ @post_number = post_number
69
+ @post_id = post_id
70
+ @topic_id = topic_id
42
71
  @username = username
72
+ @user_id = user_id
43
73
  end
44
74
  end
45
75
  end
@@ -19,13 +19,15 @@ module Markbridge
19
19
 
20
20
  # Create a new text node with the given content.
21
21
  #
22
- # The text is copied into a fresh mutable buffer so that subsequent
23
- # in-place mutations (e.g. via {#merge}) do not leak back to the caller's
24
- # original string.
22
+ # Frozen input is shared as-is (copy-on-write: {#merge} dups before
23
+ # its first append) the parser hot path always passes frozen token
24
+ # text, so no copy is made per text node. Mutable input is copied so
25
+ # that in-place mutations cannot leak in either direction between
26
+ # the caller's string and this node.
25
27
  #
26
28
  # @param text [String] the text content
27
29
  def initialize(text)
28
- @text = text.dup
30
+ @text = text.frozen? ? text : text.dup
29
31
  end
30
32
 
31
33
  # Merge another text node's content into this one.
@@ -34,6 +36,7 @@ module Markbridge
34
36
  # @param other [Text] the text node to merge from
35
37
  # @return [Text] self for method chaining
36
38
  def merge(other)
39
+ @text = @text.dup if @text.frozen?
37
40
  @text << other.text
38
41
  self
39
42
  end
@@ -22,6 +22,21 @@ module Markbridge
22
22
  super()
23
23
  @href = href
24
24
  end
25
+
26
+ # Whether this link is "bare" — it has no link text of its own:
27
+ # either no children, or a single Text child whose content is
28
+ # exactly the href (how parsers model a URL that stands for
29
+ # itself). Consumers that record or rewrite links need this
30
+ # judgment too (a bare URL must stay bare to keep autolinking
31
+ # and oneboxing working), so it lives on the node rather than
32
+ # in the renderer.
33
+ #
34
+ # @return [Boolean]
35
+ def bare?
36
+ return true if children.empty?
37
+
38
+ children.size == 1 && children.first.instance_of?(Text) && children.first.text == href
39
+ end
25
40
  end
26
41
  end
27
42
  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