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.
- checksums.yaml +4 -4
- data/lib/markbridge/ast/code.rb +8 -1
- data/lib/markbridge/ast/element.rb +28 -0
- data/lib/markbridge/ast/node.rb +41 -0
- data/lib/markbridge/normalizer/report.rb +41 -0
- data/lib/markbridge/normalizer/rule_set.rb +163 -0
- data/lib/markbridge/normalizer/text_projection.rb +37 -0
- data/lib/markbridge/normalizer/walker.rb +255 -0
- data/lib/markbridge/normalizer.rb +187 -0
- data/lib/markbridge/parsers/bbcode/handlers/code_handler.rb +17 -0
- data/lib/markbridge/parsers/html/handler_registry.rb +1 -0
- data/lib/markbridge/parsers/html/handlers/heading_handler.rb +29 -0
- data/lib/markbridge/parsers/html/handlers/raw_handler.rb +55 -4
- data/lib/markbridge/parsers/html.rb +1 -0
- data/lib/markbridge/parsers/media_wiki/parser.rb +2 -2
- data/lib/markbridge/parsers/text_formatter/handlers/code_handler.rb +3 -1
- data/lib/markbridge/renderers/discourse/html_block_safety.rb +31 -0
- data/lib/markbridge/renderers/discourse/renderer.rb +36 -6
- data/lib/markbridge/renderers/discourse/rendering_interface.rb +4 -0
- data/lib/markbridge/renderers/discourse/tag.rb +10 -0
- data/lib/markbridge/renderers/discourse/tag_library.rb +59 -4
- data/lib/markbridge/renderers/discourse/tags/code_tag.rb +4 -0
- data/lib/markbridge/renderers/discourse/tags/details_tag.rb +6 -2
- 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/spoiler_tag.rb +4 -0
- data/lib/markbridge/renderers/discourse.rb +1 -0
- data/lib/markbridge/rspec.rb +46 -0
- data/lib/markbridge/version.rb +1 -1
- data/lib/markbridge.rb +65 -13
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d958f4d4c04108de2f92388f6a62ac37ab126700f535af2c6cf38f832928e20
|
|
4
|
+
data.tar.gz: 86510bc530a71fefb9f30e350a7800d159dd9e5fa77231f2f6259253373c11b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 828a0d60a75ac730dd882e7825e2ea2e0fec53ca6b2efea6329cac03030157fba4ea086118476445ea9505835ab28031221ed786be526bdfb1b2340e2c570c56
|
|
7
|
+
data.tar.gz: 3df501c15e4050edcbd2225d356440e4672d30a5feb8d85a3b225af0daa4cd66f09f743044ac41b7e75b01a8378951aceac7784078833facb4d42cefcfc3aa24
|
data/lib/markbridge/ast/code.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/markbridge/ast/node.rb
CHANGED
|
@@ -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
|