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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/lib/markbridge/ast/code.rb +8 -1
  3. data/lib/markbridge/ast/element.rb +28 -0
  4. data/lib/markbridge/ast/node.rb +41 -0
  5. data/lib/markbridge/normalizer/report.rb +41 -0
  6. data/lib/markbridge/normalizer/rule_set.rb +163 -0
  7. data/lib/markbridge/normalizer/text_projection.rb +37 -0
  8. data/lib/markbridge/normalizer/walker.rb +255 -0
  9. data/lib/markbridge/normalizer.rb +187 -0
  10. data/lib/markbridge/parsers/bbcode/handlers/code_handler.rb +17 -0
  11. data/lib/markbridge/parsers/html/handler_registry.rb +1 -0
  12. data/lib/markbridge/parsers/html/handlers/heading_handler.rb +29 -0
  13. data/lib/markbridge/parsers/html/handlers/raw_handler.rb +55 -4
  14. data/lib/markbridge/parsers/html.rb +1 -0
  15. data/lib/markbridge/parsers/media_wiki/parser.rb +2 -2
  16. data/lib/markbridge/parsers/text_formatter/handlers/code_handler.rb +3 -1
  17. data/lib/markbridge/renderers/discourse/html_block_safety.rb +31 -0
  18. data/lib/markbridge/renderers/discourse/renderer.rb +36 -6
  19. data/lib/markbridge/renderers/discourse/rendering_interface.rb +4 -0
  20. data/lib/markbridge/renderers/discourse/tag.rb +10 -0
  21. data/lib/markbridge/renderers/discourse/tag_library.rb +59 -4
  22. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +4 -0
  23. data/lib/markbridge/renderers/discourse/tags/details_tag.rb +6 -2
  24. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +2 -4
  25. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +2 -4
  26. data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +4 -0
  27. data/lib/markbridge/renderers/discourse.rb +1 -0
  28. data/lib/markbridge/rspec.rb +46 -0
  29. data/lib/markbridge/version.rb +1 -1
  30. data/lib/markbridge.rb +65 -13
  31. 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
- # Extract language from class or lang attribute
18
- language = element["class"] || element["lang"]
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
- node = AST::Code.new(language: lang)
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 is lazily initialized in #render's top-level
16
- # call and reset to nil after the call completes.
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
- tag = @tag_library[node.class]
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
- @interface_cache = nil if root_call
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
- @interface_cache = nil if root_call
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 a tag binding so the renderer falls through to
32
- # +render_children+ for that element class. See
33
- # +Renderer#render+ for the auto-passthrough path.
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
- # (so the default auto-passthrough kicks in).
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.strip}\n[/details]\n\n"
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, interface)
21
+ def render(element, _interface)
22
22
  body = element.raw || build_event_bbcode(element)
23
- return "\n\n#{body}\n\n" if interface.html_mode?
24
-
25
- "#{body}\n\n"
23
+ "\n\n#{body}\n\n"
26
24
  end
27
25
 
28
26
  private