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