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
|
@@ -0,0 +1,187 @@
|
|
|
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 without
|
|
52
|
+
# a forced +block+ flag stay inline and are not listed (Code is handled by
|
|
53
|
+
# {KEEP_INLINE_CODE}).
|
|
54
|
+
BLOCK_NODES = [
|
|
55
|
+
AST::Quote,
|
|
56
|
+
AST::Heading,
|
|
57
|
+
AST::List,
|
|
58
|
+
AST::ListItem,
|
|
59
|
+
AST::Table,
|
|
60
|
+
AST::TableRow,
|
|
61
|
+
AST::TableCell,
|
|
62
|
+
AST::Details,
|
|
63
|
+
AST::Paragraph,
|
|
64
|
+
AST::HorizontalRule,
|
|
65
|
+
AST::Align,
|
|
66
|
+
AST::Poll,
|
|
67
|
+
AST::Event,
|
|
68
|
+
].freeze
|
|
69
|
+
|
|
70
|
+
# A code span may stay inside an inline container while it is on one line.
|
|
71
|
+
# A fenced or multi-line block is moved out. This matches
|
|
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).
|
|
75
|
+
KEEP_INLINE_CODE =
|
|
76
|
+
lambda do |_boundary, node|
|
|
77
|
+
block =
|
|
78
|
+
node.block ||
|
|
79
|
+
node.children.any? { |c| c.instance_of?(AST::Text) && c.text.include?("\n") }
|
|
80
|
+
block ? :hoist_after : :keep
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class << self
|
|
84
|
+
# A fresh, customizable normalizer with the default rules. Add more with
|
|
85
|
+
# {#rule}.
|
|
86
|
+
# @return [Normalizer]
|
|
87
|
+
def default
|
|
88
|
+
new(build_rules)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The default normalizer, built once and frozen, reused across
|
|
92
|
+
# conversions. +#normalize+ and +#violations+ keep no state on the
|
|
93
|
+
# instance, so one frozen instance is safe to reuse, also across threads.
|
|
94
|
+
# @return [Normalizer] the same frozen instance on every call
|
|
95
|
+
def shared_default
|
|
96
|
+
@shared_default ||= default.freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def build_rules
|
|
102
|
+
rules = RuleSet.new
|
|
103
|
+
|
|
104
|
+
# §6.3 A link may not contain another link, at any depth. Unwrap the
|
|
105
|
+
# inner link and keep its text.
|
|
106
|
+
rules.add(parent: AST::Url, child: AST::Url, strategy: :unwrap)
|
|
107
|
+
|
|
108
|
+
INLINE_CONTAINERS.each do |container|
|
|
109
|
+
rules.add(parent: container, child: AST::Code, strategy: KEEP_INLINE_CODE)
|
|
110
|
+
|
|
111
|
+
BLOCK_NODES.each do |block|
|
|
112
|
+
next if container == block
|
|
113
|
+
|
|
114
|
+
rules.add(parent: container, child: block, strategy: :hoist_after)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
rules
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @param rule_set [RuleSet]
|
|
123
|
+
def initialize(rule_set)
|
|
124
|
+
@rules = rule_set
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Add or override a rule. Chainable. A rule for a +(parent, child)+ pair
|
|
128
|
+
# that already exists is replaced. Raises on a frozen ({shared_default})
|
|
129
|
+
# instance; build a fresh one with {.default}.
|
|
130
|
+
#
|
|
131
|
+
# @param parent [Class] ancestor AST class
|
|
132
|
+
# @param child [Class] contained AST class
|
|
133
|
+
# @param strategy [Symbol, #call] one of {STRATEGIES} or a callable
|
|
134
|
+
# @return [self]
|
|
135
|
+
def rule(parent:, child:, strategy:)
|
|
136
|
+
@rules.add(parent:, child:, strategy:)
|
|
137
|
+
self
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Rewrite +ast+ in place so it satisfies the rules.
|
|
141
|
+
#
|
|
142
|
+
# @param ast [AST::Document, AST::Element]
|
|
143
|
+
# @return [Array<Hash>] a report of what changed (empty when nothing did),
|
|
144
|
+
# one +{parent:, child:, strategy:, count:}+ row per distinct change.
|
|
145
|
+
def normalize(ast)
|
|
146
|
+
report = Report.new
|
|
147
|
+
Walker.new(@rules, report).call(ast)
|
|
148
|
+
report.to_a
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# List the violations in +ast+ without changing it.
|
|
152
|
+
#
|
|
153
|
+
# @param ast [AST::Document, AST::Element]
|
|
154
|
+
# @return [Array<Hash>] +{parent:, child:, strategy:}+ per occurrence
|
|
155
|
+
def violations(ast)
|
|
156
|
+
found = []
|
|
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, {})
|
|
161
|
+
found
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def freeze
|
|
165
|
+
@rules.freeze
|
|
166
|
+
super
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def collect_violations(element, ancestors, found, walk_cache)
|
|
172
|
+
stack = ancestors + [element]
|
|
173
|
+
element.children.each do |child|
|
|
174
|
+
strategy, boundary = @rules.resolve(child, stack, walk_cache)
|
|
175
|
+
strategy = strategy.call(boundary, child) if strategy.respond_to?(:call)
|
|
176
|
+
unless strategy.nil? || strategy == :keep
|
|
177
|
+
found << { parent: demodulize(boundary.class), child: demodulize(child.class), strategy: }
|
|
178
|
+
end
|
|
179
|
+
collect_violations(child, stack, found, walk_cache) if child.is_a?(AST::Element)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def demodulize(klass)
|
|
184
|
+
klass.name.split("::").last
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -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
|
|
@@ -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
|