markbridge 0.3.1 → 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/node.rb +41 -0
- data/lib/markbridge/normalizer/rule_set.rb +93 -19
- data/lib/markbridge/normalizer/walker.rb +7 -2
- data/lib/markbridge/normalizer.rb +16 -9
- 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/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 +4 -2
- metadata +4 -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
|
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
|
|
@@ -4,18 +4,35 @@ module Markbridge
|
|
|
4
4
|
class Normalizer
|
|
5
5
|
# Maps a node and its ancestor stack to a strategy.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# +
|
|
12
|
-
#
|
|
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.
|
|
13
25
|
class RuleSet
|
|
14
26
|
NO_MATCH = [nil, nil].freeze
|
|
15
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
|
+
|
|
16
32
|
def initialize
|
|
17
33
|
@by_parent = {} # parent_class => { child_class => strategy }
|
|
18
34
|
@child_classes = Set.new
|
|
35
|
+
# @candidates_cache stays unset (nil) until #freeze fills it.
|
|
19
36
|
end
|
|
20
37
|
|
|
21
38
|
# Register (or replace) a rule.
|
|
@@ -33,20 +50,35 @@ module Markbridge
|
|
|
33
50
|
|
|
34
51
|
# Resolve the strategy for +child+ given its ancestor stack (root
|
|
35
52
|
# first). Returns +[strategy, boundary]+ where +boundary+ is the
|
|
36
|
-
# *outermost* ancestor
|
|
53
|
+
# *outermost* ancestor with a rule that matches +child+'s class, or
|
|
37
54
|
# {NO_MATCH} (+[nil, nil]+) when nothing matches.
|
|
38
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
|
+
#
|
|
39
63
|
# @param child [AST::Node]
|
|
40
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
|
|
41
67
|
# @return [Array(Object, AST::Element), Array(nil, nil)]
|
|
42
|
-
def resolve(child, ancestors)
|
|
43
|
-
|
|
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))
|
|
44
76
|
# Skip the ancestor scan for a class no rule targets (most nodes, for
|
|
45
77
|
# example plain text). The scan below returns the same result for such
|
|
46
78
|
# a class, so this only saves work.
|
|
47
|
-
return NO_MATCH
|
|
79
|
+
return NO_MATCH if candidates.empty?
|
|
48
80
|
|
|
49
|
-
scan_ancestors(
|
|
81
|
+
scan_ancestors(candidates, ancestors)
|
|
50
82
|
end
|
|
51
83
|
|
|
52
84
|
# Freeze so a shared instance raises if something tries to change it.
|
|
@@ -54,7 +86,19 @@ module Markbridge
|
|
|
54
86
|
# there before it touches +@child_classes+, so a frozen instance raises
|
|
55
87
|
# on the +@by_parent+ write first. +@child_classes+ is never reached, so
|
|
56
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.
|
|
57
99
|
def freeze
|
|
100
|
+
@candidates_cache ||=
|
|
101
|
+
AST::Node.descendants.to_h { |klass| [klass, child_candidates(klass)] }
|
|
58
102
|
@by_parent.each_value(&:freeze)
|
|
59
103
|
@by_parent.freeze
|
|
60
104
|
super
|
|
@@ -62,20 +106,50 @@ module Markbridge
|
|
|
62
106
|
|
|
63
107
|
private
|
|
64
108
|
|
|
65
|
-
# The ancestor scan behind {#resolve}: the outermost
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
def scan_ancestors(
|
|
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)
|
|
69
113
|
ancestors.each do |ancestor|
|
|
70
|
-
|
|
71
|
-
next unless strategies
|
|
72
|
-
|
|
73
|
-
strategy = strategies[child_class]
|
|
114
|
+
strategy = best_rule(candidates, ancestor.class)
|
|
74
115
|
return strategy, ancestor if strategy
|
|
75
116
|
end
|
|
76
117
|
NO_MATCH
|
|
77
118
|
end
|
|
78
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
|
+
|
|
79
153
|
def validate_strategy!(strategy)
|
|
80
154
|
return if strategy.respond_to?(:call)
|
|
81
155
|
return if STRATEGIES.include?(strategy)
|
|
@@ -50,6 +50,11 @@ module Markbridge
|
|
|
50
50
|
def initialize(rule_set, report)
|
|
51
51
|
@rules = rule_set
|
|
52
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 = {}
|
|
53
58
|
end
|
|
54
59
|
|
|
55
60
|
# Normalize +document+'s subtree in place.
|
|
@@ -94,7 +99,7 @@ module Markbridge
|
|
|
94
99
|
bubble = nil
|
|
95
100
|
|
|
96
101
|
children.each_with_index do |child, index|
|
|
97
|
-
strategy, boundary = @rules.resolve(child, stack)
|
|
102
|
+
strategy, boundary = @rules.resolve(child, stack, @walk_cache)
|
|
98
103
|
strategy = strategy.call(boundary, child) if strategy.respond_to?(:call)
|
|
99
104
|
|
|
100
105
|
if strategy.nil? || strategy == :keep
|
|
@@ -129,7 +134,7 @@ module Markbridge
|
|
|
129
134
|
# +out+ — the shared entry point used by {#emit}'s +:unwrap+ recursion,
|
|
130
135
|
# where +out+ already exists.
|
|
131
136
|
def resolve_into(child, stack, out, bubble)
|
|
132
|
-
strategy, boundary = @rules.resolve(child, stack)
|
|
137
|
+
strategy, boundary = @rules.resolve(child, stack, @walk_cache)
|
|
133
138
|
strategy = strategy.call(boundary, child) if strategy.respond_to?(:call)
|
|
134
139
|
|
|
135
140
|
if strategy.nil? || strategy == :keep
|
|
@@ -48,8 +48,9 @@ module Markbridge
|
|
|
48
48
|
|
|
49
49
|
# AST nodes the Discourse renderer prints as block-level Markdown (their
|
|
50
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
|
|
52
|
-
# inline and are not listed (Code is handled by
|
|
51
|
+
# that container, so it is moved out. Spoiler and single-line Code without
|
|
52
|
+
# a forced +block+ flag stay inline and are not listed (Code is handled by
|
|
53
|
+
# {KEEP_INLINE_CODE}).
|
|
53
54
|
BLOCK_NODES = [
|
|
54
55
|
AST::Quote,
|
|
55
56
|
AST::Heading,
|
|
@@ -68,11 +69,14 @@ module Markbridge
|
|
|
68
69
|
|
|
69
70
|
# A code span may stay inside an inline container while it is on one line.
|
|
70
71
|
# A fenced or multi-line block is moved out. This matches
|
|
71
|
-
# +RenderingInterface#block_context?+: Code prints as a fenced block when
|
|
72
|
-
# Text child has a newline (the language alone
|
|
72
|
+
# +RenderingInterface#block_context?+: Code prints as a fenced block when
|
|
73
|
+
# its +block+ flag is set or a Text child has a newline (the language alone
|
|
74
|
+
# does not make it a block).
|
|
73
75
|
KEEP_INLINE_CODE =
|
|
74
76
|
lambda do |_boundary, node|
|
|
75
|
-
block =
|
|
77
|
+
block =
|
|
78
|
+
node.block ||
|
|
79
|
+
node.children.any? { |c| c.instance_of?(AST::Text) && c.text.include?("\n") }
|
|
76
80
|
block ? :hoist_after : :keep
|
|
77
81
|
end
|
|
78
82
|
|
|
@@ -150,7 +154,10 @@ module Markbridge
|
|
|
150
154
|
# @return [Array<Hash>] +{parent:, child:, strategy:}+ per occurrence
|
|
151
155
|
def violations(ast)
|
|
152
156
|
found = []
|
|
153
|
-
|
|
157
|
+
# Per-call cache for RuleSet#resolve's ancestry analysis; keeps the
|
|
158
|
+
# Normalizer instance itself free of state, so a frozen shared
|
|
159
|
+
# instance stays safe across threads.
|
|
160
|
+
collect_violations(ast, EMPTY_STACK, found, {})
|
|
154
161
|
found
|
|
155
162
|
end
|
|
156
163
|
|
|
@@ -161,15 +168,15 @@ module Markbridge
|
|
|
161
168
|
|
|
162
169
|
private
|
|
163
170
|
|
|
164
|
-
def collect_violations(element, ancestors, found)
|
|
171
|
+
def collect_violations(element, ancestors, found, walk_cache)
|
|
165
172
|
stack = ancestors + [element]
|
|
166
173
|
element.children.each do |child|
|
|
167
|
-
strategy, boundary = @rules.resolve(child, stack)
|
|
174
|
+
strategy, boundary = @rules.resolve(child, stack, walk_cache)
|
|
168
175
|
strategy = strategy.call(boundary, child) if strategy.respond_to?(:call)
|
|
169
176
|
unless strategy.nil? || strategy == :keep
|
|
170
177
|
found << { parent: demodulize(boundary.class), child: demodulize(child.class), strategy: }
|
|
171
178
|
end
|
|
172
|
-
collect_violations(child, stack, found) if child.is_a?(AST::Element)
|
|
179
|
+
collect_violations(child, stack, found, walk_cache) if child.is_a?(AST::Element)
|
|
173
180
|
end
|
|
174
181
|
end
|
|
175
182
|
|
|
@@ -11,9 +11,26 @@ module Markbridge
|
|
|
11
11
|
# # end
|
|
12
12
|
# # [/code]
|
|
13
13
|
class CodeHandler < RawHandler
|
|
14
|
+
# Tags that mean a code block by definition; [tt] is inline
|
|
15
|
+
# teletype and leaves the decision to the renderer.
|
|
16
|
+
BLOCK_TAGS = %w[code pre].freeze
|
|
17
|
+
private_constant :BLOCK_TAGS
|
|
18
|
+
|
|
14
19
|
def initialize
|
|
15
20
|
super(AST::Code)
|
|
16
21
|
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def create_element(token:, content:)
|
|
26
|
+
element =
|
|
27
|
+
AST::Code.new(
|
|
28
|
+
language: token.attrs[:lang] || token.attrs[:option],
|
|
29
|
+
block: (true if BLOCK_TAGS.include?(token.tag)),
|
|
30
|
+
)
|
|
31
|
+
element << AST::Text.new(content) unless content.empty?
|
|
32
|
+
element
|
|
33
|
+
end
|
|
17
34
|
end
|
|
18
35
|
end
|
|
19
36
|
end
|
|
@@ -132,6 +132,7 @@ module Markbridge
|
|
|
132
132
|
registry.register("a", Handlers::UrlHandler.new)
|
|
133
133
|
registry.register("img", Handlers::ImageHandler.new)
|
|
134
134
|
registry.register("blockquote", Handlers::QuoteHandler.new)
|
|
135
|
+
registry.register(%w[h1 h2 h3 h4 h5 h6], Handlers::HeadingHandler.new)
|
|
135
136
|
registry.register("br", Handlers::SelfClosingHandler.new(AST::LineBreak))
|
|
136
137
|
registry.register("hr", Handlers::SelfClosingHandler.new(AST::HorizontalRule))
|
|
137
138
|
registry.register(%w[ul ol], Handlers::ListHandler.new)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markbridge
|
|
4
|
+
module Parsers
|
|
5
|
+
module HTML
|
|
6
|
+
module Handlers
|
|
7
|
+
# Handles <h1> through <h6> by creating a Heading element with the
|
|
8
|
+
# matching level and processing children into it.
|
|
9
|
+
class HeadingHandler < BaseHandler
|
|
10
|
+
# @param element [Nokogiri::XML::Element] the heading element
|
|
11
|
+
# @param parent [AST::Element] the parent AST node
|
|
12
|
+
# @return [AST::Heading] the created heading, so children get processed into it
|
|
13
|
+
def process(element:, parent:)
|
|
14
|
+
level = element.name.delete_prefix("h").to_i.clamp(1, 6)
|
|
15
|
+
heading = AST::Heading.new(level:)
|
|
16
|
+
parent << heading
|
|
17
|
+
|
|
18
|
+
heading
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Class] AST::Heading
|
|
22
|
+
def element_class
|
|
23
|
+
AST::Heading
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -6,6 +6,12 @@ module Markbridge
|
|
|
6
6
|
module Handlers
|
|
7
7
|
# Handler for raw/preformatted tags that preserve content as-is
|
|
8
8
|
class RawHandler < BaseHandler
|
|
9
|
+
# A language must be one clean token — the renderer splices it
|
|
10
|
+
# into the code fence line, so a class attribute with spaces or
|
|
11
|
+
# other markup characters must not end up there.
|
|
12
|
+
LANGUAGE_PATTERN = /\A[a-z0-9][a-z0-9_+-]*\z/
|
|
13
|
+
private_constant :LANGUAGE_PATTERN
|
|
14
|
+
|
|
9
15
|
def initialize(element_class)
|
|
10
16
|
@element_class = element_class
|
|
11
17
|
end
|
|
@@ -14,10 +20,8 @@ module Markbridge
|
|
|
14
20
|
# Get the inner text content
|
|
15
21
|
content = element.inner_text
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
ast_element = @element_class.new(language:)
|
|
23
|
+
ast_element =
|
|
24
|
+
@element_class.new(language: language_for(element), block: block_for(element))
|
|
21
25
|
ast_element << AST::Text.new(content) unless content.empty?
|
|
22
26
|
parent << ast_element
|
|
23
27
|
|
|
@@ -26,6 +30,53 @@ module Markbridge
|
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
attr_reader :element_class
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# <pre> is a block by definition, so its content keeps the fenced
|
|
37
|
+
# form even on one line; <code> and <tt> leave the decision to the
|
|
38
|
+
# renderer's newline check.
|
|
39
|
+
#
|
|
40
|
+
# @param element [Nokogiri::XML::Element]
|
|
41
|
+
# @return [Boolean, nil]
|
|
42
|
+
def block_for(element)
|
|
43
|
+
true if element.name == "pre"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The language of a code block, from the strongest signal to the
|
|
47
|
+
# weakest: a `language-*` class on the element itself or on its
|
|
48
|
+
# direct <code> child (the CommonMark convention for fenced code,
|
|
49
|
+
# `<pre><code class="language-ruby">`), then the `lang` attribute,
|
|
50
|
+
# then a lone class used as-is. A lone class ranks below `lang`
|
|
51
|
+
# because a class can be pure styling (`hljs`, `prettyprint`).
|
|
52
|
+
def language_for(element)
|
|
53
|
+
code_child_classes = element.at_xpath("./code")&.[]("class")
|
|
54
|
+
|
|
55
|
+
prefixed_language(element["class"]) || prefixed_language(code_child_classes) ||
|
|
56
|
+
attribute_language(element["lang"]) || single_class_language(element["class"]) ||
|
|
57
|
+
single_class_language(code_child_classes)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def prefixed_language(classes)
|
|
61
|
+
classes
|
|
62
|
+
&.split
|
|
63
|
+
&.filter_map do |name|
|
|
64
|
+
name.delete_prefix("language-").downcase if name.start_with?("language-")
|
|
65
|
+
end
|
|
66
|
+
&.find { |language| LANGUAGE_PATTERN.match?(language) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def attribute_language(value)
|
|
70
|
+
language = value&.strip&.downcase
|
|
71
|
+
language if language&.match?(LANGUAGE_PATTERN)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def single_class_language(classes)
|
|
75
|
+
names = classes&.split
|
|
76
|
+
return unless names&.length == 1
|
|
77
|
+
|
|
78
|
+
attribute_language(names.first)
|
|
79
|
+
end
|
|
29
80
|
end
|
|
30
81
|
end
|
|
31
82
|
end
|
|
@@ -17,6 +17,7 @@ require_relative "html/handlers/image_handler"
|
|
|
17
17
|
require_relative "html/handlers/list_handler"
|
|
18
18
|
require_relative "html/handlers/list_item_handler"
|
|
19
19
|
require_relative "html/handlers/quote_handler"
|
|
20
|
+
require_relative "html/handlers/heading_handler"
|
|
20
21
|
require_relative "html/handlers/paragraph_handler"
|
|
21
22
|
require_relative "html/handlers/table_handler"
|
|
22
23
|
require_relative "html/handlers/table_row_handler"
|
|
@@ -347,7 +347,7 @@ module Markbridge
|
|
|
347
347
|
consumed = lines[start_index..].take_while { |line| line.start_with?(" ") }
|
|
348
348
|
content = consumed.map { |line| line[1..] }.join("\n")
|
|
349
349
|
|
|
350
|
-
code = AST::Code.new
|
|
350
|
+
code = AST::Code.new(block: true)
|
|
351
351
|
code << AST::Text.new(content)
|
|
352
352
|
@document << code
|
|
353
353
|
|
|
@@ -372,7 +372,7 @@ module Markbridge
|
|
|
372
372
|
combined = consumed.join("\n")
|
|
373
373
|
content = combined.sub(PRE_TAG_OPEN, "").sub(PRE_TAG_CLOSE_TRAILING, "")
|
|
374
374
|
|
|
375
|
-
code = AST::Code.new
|
|
375
|
+
code = AST::Code.new(block: true)
|
|
376
376
|
code << AST::Text.new(content)
|
|
377
377
|
@document << code
|
|
378
378
|
|
|
@@ -13,7 +13,9 @@ module Markbridge
|
|
|
13
13
|
def process(element:, parent:, processor: nil)
|
|
14
14
|
attrs = extract_attributes(element)
|
|
15
15
|
lang = attrs[:lang] || attrs[:language]
|
|
16
|
-
|
|
16
|
+
# s9e CODE is always a block, so the flag keeps the fenced form
|
|
17
|
+
# even for single-line content.
|
|
18
|
+
node = AST::Code.new(language: lang, block: true)
|
|
17
19
|
parent << node
|
|
18
20
|
|
|
19
21
|
# Return node to signal: process children into this node
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markbridge
|
|
4
|
+
module Renderers
|
|
5
|
+
module Discourse
|
|
6
|
+
# Decides whether a rendered fragment is safe to splice into a
|
|
7
|
+
# CommonMark HTML block (spec §4.6). Inside such a block the
|
|
8
|
+
# content passes through as raw HTML; Markdown is only parsed
|
|
9
|
+
# again across blank lines. Safe output is therefore: a raw HTML
|
|
10
|
+
# or plain-text fragment without Markdown sigils, or a
|
|
11
|
+
# +\n\n…\n\n+ wrap — a deliberate Markdown island.
|
|
12
|
+
#
|
|
13
|
+
# Used by the html_mode contract check that ships in
|
|
14
|
+
# +markbridge/rspec+ and by this repo's own contract spec.
|
|
15
|
+
module HtmlBlockSafety
|
|
16
|
+
# Markdown sigils that would surface as literal text inside an
|
|
17
|
+
# HTML block: emphasis (`*`, `_`, `~`) and link middles (`](`).
|
|
18
|
+
MARKDOWN_SIGILS = /[*_~]|\]\(/
|
|
19
|
+
private_constant :MARKDOWN_SIGILS
|
|
20
|
+
|
|
21
|
+
# @param output [String] a tag's html_mode render result
|
|
22
|
+
# @return [Boolean]
|
|
23
|
+
def self.safe?(output)
|
|
24
|
+
return true if output.start_with?("\n\n") && output.end_with?("\n\n")
|
|
25
|
+
|
|
26
|
+
!output.match?(MARKDOWN_SIGILS)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -12,8 +12,9 @@ module Markbridge
|
|
|
12
12
|
@escaper = escaper || MarkdownEscaper.new
|
|
13
13
|
@html_escaper = html_escaper || HtmlEscaper
|
|
14
14
|
@postprocessor = postprocessor || Postprocessor::DEFAULT
|
|
15
|
-
# @interface_cache
|
|
16
|
-
#
|
|
15
|
+
# @interface_cache, @resolved_tags, and @resolved_default_tags
|
|
16
|
+
# are lazily initialized during a top-level #render /
|
|
17
|
+
# #render_default call and reset to nil after the call completes.
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
# Render a node to Markdown
|
|
@@ -28,7 +29,10 @@ module Markbridge
|
|
|
28
29
|
root_call = @interface_cache.nil?
|
|
29
30
|
@interface_cache = {} if root_call
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
# Exact-class hit first (a single Hash lookup, the common case),
|
|
33
|
+
# then the memoized ancestry fallback so a subclass without its
|
|
34
|
+
# own tag renders through its nearest ancestor's tag.
|
|
35
|
+
tag = @tag_library[node.class] || resolved_tag(node.class)
|
|
32
36
|
if tag
|
|
33
37
|
result = tag.render(node, interface_for(context))
|
|
34
38
|
unless result.is_a?(String)
|
|
@@ -43,7 +47,11 @@ module Markbridge
|
|
|
43
47
|
|
|
44
48
|
render_without_tag(node, context)
|
|
45
49
|
ensure
|
|
46
|
-
|
|
50
|
+
if root_call
|
|
51
|
+
@interface_cache = nil
|
|
52
|
+
@resolved_tags = nil
|
|
53
|
+
@resolved_default_tags = nil
|
|
54
|
+
end
|
|
47
55
|
end
|
|
48
56
|
|
|
49
57
|
# Render a node with the stock tag for its class, ignoring any
|
|
@@ -66,12 +74,16 @@ module Markbridge
|
|
|
66
74
|
root_call = @interface_cache.nil?
|
|
67
75
|
@interface_cache = {} if root_call
|
|
68
76
|
|
|
69
|
-
tag = default_tag_library[node.class]
|
|
77
|
+
tag = default_tag_library[node.class] || resolved_default_tag(node.class)
|
|
70
78
|
return tag.render(node, interface_for(context)) if tag
|
|
71
79
|
|
|
72
80
|
render_without_tag(node, context)
|
|
73
81
|
ensure
|
|
74
|
-
|
|
82
|
+
if root_call
|
|
83
|
+
@interface_cache = nil
|
|
84
|
+
@resolved_tags = nil
|
|
85
|
+
@resolved_default_tags = nil
|
|
86
|
+
end
|
|
75
87
|
end
|
|
76
88
|
|
|
77
89
|
# Render all children of a node
|
|
@@ -113,6 +125,24 @@ module Markbridge
|
|
|
113
125
|
@interface_cache[context.object_id] ||= RenderingInterface.new(self, context)
|
|
114
126
|
end
|
|
115
127
|
|
|
128
|
+
# Ancestry fallback for tag dispatch (see TagLibrary#resolve),
|
|
129
|
+
# memoized per top-level render call — the tag library can change
|
|
130
|
+
# between calls, so the cache must not outlive one call (it is
|
|
131
|
+
# reset in #render's ensure). +fetch+ stores nil results too, so a
|
|
132
|
+
# class that resolves to no tag is walked once per call, not once
|
|
133
|
+
# per node.
|
|
134
|
+
def resolved_tag(node_class)
|
|
135
|
+
cache = @resolved_tags ||= {}
|
|
136
|
+
cache.fetch(node_class) { cache[node_class] = @tag_library.resolve(node_class) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Same as {#resolved_tag}, against the default library backing
|
|
140
|
+
# #render_default.
|
|
141
|
+
def resolved_default_tag(node_class)
|
|
142
|
+
cache = @resolved_default_tags ||= {}
|
|
143
|
+
cache.fetch(node_class) { cache[node_class] = default_tag_library.resolve(node_class) }
|
|
144
|
+
end
|
|
145
|
+
|
|
116
146
|
# Pristine default library backing #render_default. Built lazily —
|
|
117
147
|
# most renders never need it.
|
|
118
148
|
def default_tag_library
|
|
@@ -65,6 +65,10 @@ module Markbridge
|
|
|
65
65
|
def block_context?(node)
|
|
66
66
|
# Check if it's a block-level element type (but not code, which can be inline)
|
|
67
67
|
return true if node.instance_of?(AST::List) || node.instance_of?(AST::HorizontalRule)
|
|
68
|
+
# A Code node whose source construct is a block by definition keeps
|
|
69
|
+
# its block form even with single-line content (is_a?, so Code
|
|
70
|
+
# subclasses inherit the behavior).
|
|
71
|
+
return true if node.is_a?(AST::Code) && node.block
|
|
68
72
|
return false unless node.is_a?(AST::Element)
|
|
69
73
|
|
|
70
74
|
# Check if content has newlines
|
|
@@ -36,6 +36,16 @@ module Markbridge
|
|
|
36
36
|
raise NotImplementedError, "#{self.class} must implement #render or provide a block"
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
# A tag that renders only the element's children, with the element
|
|
41
|
+
# pushed on the parent chain. Tag dispatch matches by ancestry, so
|
|
42
|
+
# a subclass normally inherits its base class tag; register
|
|
43
|
+
# PASSTHROUGH for the subclass to opt out — the exact-class hit
|
|
44
|
+
# wins and the base tag is not used.
|
|
45
|
+
PASSTHROUGH =
|
|
46
|
+
new do |element, interface|
|
|
47
|
+
interface.render_children(element, context: interface.with_parent(element))
|
|
48
|
+
end.freeze
|
|
39
49
|
end
|
|
40
50
|
end
|
|
41
51
|
end
|
|
@@ -15,9 +15,17 @@ module Markbridge
|
|
|
15
15
|
# internal +@tags+ Hash is independent of the source. Without
|
|
16
16
|
# this, both copies would share the same underlying Hash and
|
|
17
17
|
# mutations to one would silently affect the other.
|
|
18
|
+
#
|
|
19
|
+
# A frozen source also carries flattened ancestry entries (see
|
|
20
|
+
# {#freeze}); the copy is mutable again, so those entries are
|
|
21
|
+
# dropped and the copy goes back to the lazy {#resolve} lookup.
|
|
22
|
+
# Keeping them would bake in inheritance decisions from before
|
|
23
|
+
# any changes made to the copy.
|
|
18
24
|
def initialize_copy(other)
|
|
19
25
|
super
|
|
20
26
|
@tags = @tags.dup
|
|
27
|
+
@flattened_classes&.each { |klass| @tags.delete(klass) }
|
|
28
|
+
@flattened_classes = nil
|
|
21
29
|
end
|
|
22
30
|
|
|
23
31
|
# Register a tag for an element class
|
|
@@ -28,9 +36,11 @@ module Markbridge
|
|
|
28
36
|
self
|
|
29
37
|
end
|
|
30
38
|
|
|
31
|
-
# Remove
|
|
32
|
-
#
|
|
33
|
-
#
|
|
39
|
+
# Remove the binding for this exact element class. Lookup then
|
|
40
|
+
# falls back to the nearest ancestor class with a tag; when no
|
|
41
|
+
# ancestor has one — true for every built-in class, since
|
|
42
|
+
# nothing binds +AST::Element+ or +AST::Node+ — the renderer
|
|
43
|
+
# falls through to +render_children+. See +Renderer#render+.
|
|
34
44
|
#
|
|
35
45
|
# @param element_class [Class]
|
|
36
46
|
# @return [self]
|
|
@@ -41,7 +51,7 @@ module Markbridge
|
|
|
41
51
|
|
|
42
52
|
# Merge a Hash of class → Tag mappings on top of this library
|
|
43
53
|
# in-place. A +nil+ value unregisters the corresponding class
|
|
44
|
-
# (
|
|
54
|
+
# (see {#unregister} for what lookup does then).
|
|
45
55
|
#
|
|
46
56
|
# Named with a trailing +!+ because it mutates +self+ —
|
|
47
57
|
# mirroring Ruby's Hash#merge / Hash#merge! convention. Use
|
|
@@ -67,6 +77,26 @@ module Markbridge
|
|
|
67
77
|
@tags[element_class]
|
|
68
78
|
end
|
|
69
79
|
|
|
80
|
+
# Find the tag for +element_class+ through its ancestry: walk the
|
|
81
|
+
# superclass chain, starting at +element_class.superclass+, and
|
|
82
|
+
# return the first registered tag. The walk stays inside the
|
|
83
|
+
# +AST::Node+ hierarchy, so a tag registered for a class outside
|
|
84
|
+
# the AST is never found. The exact-class lookup is {#[]};
|
|
85
|
+
# callers check that first.
|
|
86
|
+
#
|
|
87
|
+
# @param element_class [Class]
|
|
88
|
+
# @return [Tag, nil]
|
|
89
|
+
def resolve(element_class)
|
|
90
|
+
klass = element_class.superclass
|
|
91
|
+
while klass && klass <= AST::Node
|
|
92
|
+
tag = self[klass]
|
|
93
|
+
return tag if tag
|
|
94
|
+
|
|
95
|
+
klass = klass.superclass
|
|
96
|
+
end
|
|
97
|
+
# The while loop's own value is nil, so a miss returns nil.
|
|
98
|
+
end
|
|
99
|
+
|
|
70
100
|
# Iterate over registered (element_class, tag) pairs.
|
|
71
101
|
# Useful for debugging custom libraries — e.g. confirming an override
|
|
72
102
|
# has stuck. Iteration order matches registration order.
|
|
@@ -122,10 +152,35 @@ module Markbridge
|
|
|
122
152
|
# Freeze the library together with its internal Hash so that
|
|
123
153
|
# registration on a shared instance fails loudly instead of
|
|
124
154
|
# silently mutating state visible to every renderer.
|
|
155
|
+
#
|
|
156
|
+
# Freezing also flattens ancestry resolution: every known AST
|
|
157
|
+
# class without an explicit binding whose ancestry resolves to a
|
|
158
|
+
# tag gets that tag copied into the internal Hash, so lookup on a
|
|
159
|
+
# frozen library is a single exact Hash hit for those classes
|
|
160
|
+
# too. +AST::Node.descendants+ is boot-time state, so only
|
|
161
|
+
# classes defined before the freeze are covered; later classes
|
|
162
|
+
# keep working through the renderer's lazy {#resolve} fallback.
|
|
125
163
|
def freeze
|
|
164
|
+
flatten_ancestry!
|
|
126
165
|
@tags.freeze
|
|
127
166
|
super
|
|
128
167
|
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# See {#freeze}. Records what it added in +@flattened_classes+ so
|
|
172
|
+
# {#initialize_copy} can drop those entries from a copy again.
|
|
173
|
+
def flatten_ancestry!
|
|
174
|
+
AST::Node.descendants.each do |klass|
|
|
175
|
+
next if @tags.key?(klass)
|
|
176
|
+
|
|
177
|
+
tag = resolve(klass)
|
|
178
|
+
next unless tag
|
|
179
|
+
|
|
180
|
+
(@flattened_classes ||= []) << klass
|
|
181
|
+
@tags[klass] = tag
|
|
182
|
+
end
|
|
183
|
+
end
|
|
129
184
|
end
|
|
130
185
|
end
|
|
131
186
|
end
|
|
@@ -9,6 +9,10 @@ module Markbridge
|
|
|
9
9
|
child_context = interface.with_parent(element)
|
|
10
10
|
content = interface.render_children(element, context: child_context)
|
|
11
11
|
|
|
12
|
+
# An empty element renders to nothing — a bare `` pair or an
|
|
13
|
+
# empty fence would only add noise to the output.
|
|
14
|
+
return "" if content.empty?
|
|
15
|
+
|
|
12
16
|
if interface.block_context?(element)
|
|
13
17
|
if interface.html_mode?
|
|
14
18
|
render_html_block(content, element.language)
|
|
@@ -25,12 +25,16 @@ module Markbridge
|
|
|
25
25
|
|
|
26
26
|
def render(element, interface)
|
|
27
27
|
child_context = interface.with_parent(element)
|
|
28
|
-
content = interface.render_children(element, context: child_context)
|
|
28
|
+
content = interface.render_children(element, context: child_context).strip
|
|
29
|
+
|
|
30
|
+
# A details block with nothing to show renders to nothing —
|
|
31
|
+
# an empty [details] shell would only add noise to the output.
|
|
32
|
+
return "" if content.empty?
|
|
29
33
|
|
|
30
34
|
return render_html(element.title, content) if interface.html_mode?
|
|
31
35
|
|
|
32
36
|
opener = element.title ? %([details="#{element.title}"]) : "[details]"
|
|
33
|
-
"\n\n#{opener}\n#{content
|
|
37
|
+
"\n\n#{opener}\n#{content}\n[/details]\n\n"
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
private
|
|
@@ -11,6 +11,10 @@ module Markbridge
|
|
|
11
11
|
child_context = interface.with_parent(element)
|
|
12
12
|
content = interface.render_children(element, context: child_context)
|
|
13
13
|
|
|
14
|
+
# A spoiler with nothing to hide renders to nothing — an empty
|
|
15
|
+
# [spoiler] shell would only add noise to the output.
|
|
16
|
+
return "" if content.empty?
|
|
17
|
+
|
|
14
18
|
return render_html(element.title, content) if interface.html_mode?
|
|
15
19
|
|
|
16
20
|
if element.title
|
|
@@ -7,6 +7,7 @@ require_relative "discourse/rendering_interface"
|
|
|
7
7
|
require_relative "discourse/markdown_escaper"
|
|
8
8
|
require_relative "discourse/identity_escaper"
|
|
9
9
|
require_relative "discourse/html_escaper"
|
|
10
|
+
require_relative "discourse/html_block_safety"
|
|
10
11
|
require_relative "discourse/postprocessor"
|
|
11
12
|
|
|
12
13
|
# Builders
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../markbridge"
|
|
4
|
+
|
|
5
|
+
# Shared RSpec examples for consumers who write their own renderer
|
|
6
|
+
# tags. Require this file from your spec setup (RSpec itself must
|
|
7
|
+
# already be loaded):
|
|
8
|
+
#
|
|
9
|
+
# require "markbridge/rspec"
|
|
10
|
+
#
|
|
11
|
+
# and put a custom tag under the html_mode contract:
|
|
12
|
+
#
|
|
13
|
+
# RSpec.describe MyQuoteTag do
|
|
14
|
+
# it_behaves_like "an html_mode safe tag" do
|
|
15
|
+
# let(:tag) { described_class.new }
|
|
16
|
+
# let(:element) do
|
|
17
|
+
# element = MyQuote.new
|
|
18
|
+
# element << Markbridge::AST::Text.new("body *with* sigils")
|
|
19
|
+
# element
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# The example renders +element+ with +tag+ in html_mode and fails when
|
|
25
|
+
# the output would break inside a CommonMark HTML block — the same
|
|
26
|
+
# check Markbridge runs against its own tags. Give +element+ children
|
|
27
|
+
# whose text contains Markdown sigils, so a tag that passes them
|
|
28
|
+
# through unprotected is caught. When the tag needs a customized
|
|
29
|
+
# renderer to resolve its children (for example a custom tag library),
|
|
30
|
+
# override +markbridge_renderer+ with your configured renderer.
|
|
31
|
+
RSpec.shared_examples "an html_mode safe tag" do
|
|
32
|
+
let(:markbridge_renderer) { Markbridge::Renderers::Discourse::Renderer.new }
|
|
33
|
+
|
|
34
|
+
let(:markbridge_html_mode_interface) do
|
|
35
|
+
context = Markbridge::Renderers::Discourse::RenderContext.new([], html_mode: true)
|
|
36
|
+
Markbridge::Renderers::Discourse::RenderingInterface.new(markbridge_renderer, context)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "renders html_mode output that is safe inside an HTML block" do
|
|
40
|
+
output = tag.render(element, markbridge_html_mode_interface)
|
|
41
|
+
|
|
42
|
+
expect(Markbridge::Renderers::Discourse::HtmlBlockSafety.safe?(output)).to be(true),
|
|
43
|
+
"Expected #{tag.class} to render raw HTML or a \\n\\n-wrapped " \
|
|
44
|
+
"Markdown island in html_mode, got: #{output.inspect}"
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/markbridge/version.rb
CHANGED
data/lib/markbridge.rb
CHANGED
|
@@ -276,8 +276,10 @@ module Markbridge
|
|
|
276
276
|
# library to start from. Defaults to a fresh {TagLibrary.default}.
|
|
277
277
|
# When supplied, it is +dup+'d before any +tags:+ / +unregister:+
|
|
278
278
|
# mutation, so the caller's library is left untouched.
|
|
279
|
-
# @param unregister [Array<Class>, nil] AST classes
|
|
280
|
-
# the library
|
|
279
|
+
# @param unregister [Array<Class>, nil] AST classes whose binding to
|
|
280
|
+
# drop from the library. A built-in class then renders as just its
|
|
281
|
+
# children; a subclass of a bound class falls back to the nearest
|
|
282
|
+
# ancestor's tag (see {Renderers::Discourse::TagLibrary#unregister}).
|
|
281
283
|
# @param escaper [#escape, nil] when given, used as-is; +escape:+,
|
|
282
284
|
# +escape_hard_line_breaks:+, and +allow:+ are then ignored.
|
|
283
285
|
# @param escape [Boolean] when +false+, the renderer is built with
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Discourse Team
|
|
@@ -103,6 +103,7 @@ files:
|
|
|
103
103
|
- lib/markbridge/parsers/html.rb
|
|
104
104
|
- lib/markbridge/parsers/html/handler_registry.rb
|
|
105
105
|
- lib/markbridge/parsers/html/handlers/base_handler.rb
|
|
106
|
+
- lib/markbridge/parsers/html/handlers/heading_handler.rb
|
|
106
107
|
- lib/markbridge/parsers/html/handlers/image_handler.rb
|
|
107
108
|
- lib/markbridge/parsers/html/handlers/list_handler.rb
|
|
108
109
|
- lib/markbridge/parsers/html/handlers/list_item_handler.rb
|
|
@@ -137,6 +138,7 @@ files:
|
|
|
137
138
|
- lib/markbridge/parsers/text_formatter/parser.rb
|
|
138
139
|
- lib/markbridge/renderers/discourse.rb
|
|
139
140
|
- lib/markbridge/renderers/discourse/builders/list_item_builder.rb
|
|
141
|
+
- lib/markbridge/renderers/discourse/html_block_safety.rb
|
|
140
142
|
- lib/markbridge/renderers/discourse/html_escaper.rb
|
|
141
143
|
- lib/markbridge/renderers/discourse/identity_escaper.rb
|
|
142
144
|
- lib/markbridge/renderers/discourse/markdown_escaper.rb
|
|
@@ -176,6 +178,7 @@ files:
|
|
|
176
178
|
- lib/markbridge/renderers/discourse/tags/underline_tag.rb
|
|
177
179
|
- lib/markbridge/renderers/discourse/tags/upload_tag.rb
|
|
178
180
|
- lib/markbridge/renderers/discourse/tags/url_tag.rb
|
|
181
|
+
- lib/markbridge/rspec.rb
|
|
179
182
|
- lib/markbridge/textformatter.rb
|
|
180
183
|
- lib/markbridge/version.rb
|
|
181
184
|
homepage: https://github.com/discourse/markbridge
|