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.
- checksums.yaml +4 -4
- data/lib/markbridge/ast/element.rb +28 -0
- data/lib/markbridge/ast/quote.rb +45 -15
- data/lib/markbridge/ast/text.rb +7 -4
- data/lib/markbridge/ast/url.rb +15 -0
- data/lib/markbridge/normalizer/report.rb +41 -0
- data/lib/markbridge/normalizer/rule_set.rb +89 -0
- data/lib/markbridge/normalizer/text_projection.rb +37 -0
- data/lib/markbridge/normalizer/walker.rb +250 -0
- data/lib/markbridge/normalizer.rb +180 -0
- data/lib/markbridge/parsers/bbcode/handler_registry.rb +27 -1
- data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +32 -9
- data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +8 -4
- data/lib/markbridge/parsers/bbcode/parser.rb +6 -3
- data/lib/markbridge/parsers/bbcode/scanner.rb +133 -53
- data/lib/markbridge/parsers/html/handler_registry.rb +27 -1
- data/lib/markbridge/parsers/html/parser.rb +54 -13
- data/lib/markbridge/parsers/media_wiki/inline_parser.rb +95 -57
- data/lib/markbridge/parsers/media_wiki/inline_tag_registry.rb +24 -1
- data/lib/markbridge/parsers/media_wiki/parser.rb +5 -2
- data/lib/markbridge/parsers/text_formatter/handler_registry.rb +23 -1
- data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +31 -2
- data/lib/markbridge/parsers/text_formatter/parser.rb +10 -3
- data/lib/markbridge/renderers/discourse/markdown_escaper.rb +12 -1
- data/lib/markbridge/renderers/discourse/render_context.rb +74 -11
- data/lib/markbridge/renderers/discourse/renderer.rb +63 -13
- data/lib/markbridge/renderers/discourse/rendering_interface.rb +27 -2
- data/lib/markbridge/renderers/discourse/tag_library.rb +20 -0
- data/lib/markbridge/renderers/discourse/tags/event_tag.rb +2 -4
- data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +2 -4
- data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +8 -6
- data/lib/markbridge/renderers/discourse/tags/url_tag.rb +36 -2
- data/lib/markbridge/version.rb +1 -1
- data/lib/markbridge.rb +61 -11
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e077560817f865c035c136edffd868cbabb934b6a8986614c5e1c48139b05b9b
|
|
4
|
+
data.tar.gz: 6511b321b0245010d0df81cea6e5b19950de430753a34aea54f4c99c2fa10fc9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/markbridge/ast/quote.rb
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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
|
|
27
|
+
# @return [String, nil] the author attribution (display name)
|
|
20
28
|
attr_reader :author
|
|
21
29
|
|
|
22
|
-
# @return [
|
|
23
|
-
|
|
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 [
|
|
26
|
-
attr_reader :
|
|
40
|
+
# @return [Integer, nil] the topic id the quoted post belongs to
|
|
41
|
+
attr_reader :topic_id
|
|
27
42
|
|
|
28
|
-
# @return [String, nil] the
|
|
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
|
|
35
|
-
# @param
|
|
36
|
-
# @param
|
|
37
|
-
|
|
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
|
-
@
|
|
41
|
-
@
|
|
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
|
data/lib/markbridge/ast/text.rb
CHANGED
|
@@ -19,13 +19,15 @@ module Markbridge
|
|
|
19
19
|
|
|
20
20
|
# Create a new text node with the given content.
|
|
21
21
|
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
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
|
data/lib/markbridge/ast/url.rb
CHANGED
|
@@ -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
|