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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e077560817f865c035c136edffd868cbabb934b6a8986614c5e1c48139b05b9b
4
- data.tar.gz: 6511b321b0245010d0df81cea6e5b19950de430753a34aea54f4c99c2fa10fc9
3
+ metadata.gz: 6d958f4d4c04108de2f92388f6a62ac37ab126700f535af2c6cf38f832928e20
4
+ data.tar.gz: 86510bc530a71fefb9f30e350a7800d159dd9e5fa77231f2f6259253373c11b4
5
5
  SHA512:
6
- metadata.gz: 7e807c4b7fc8eae29bbcfea9fa6c855f2b627caf2403965e5e0e4d1792a39612e74db2a37e85be72e60f2b3a9e200a70cedf695ed57256e27c03b2569bc9ba26
7
- data.tar.gz: 77f38f9577c366005a6b06c25a6287b3631d8d2f4dbcfe54043a0c882587600f95c94f10e4110356573f72a1041e6e7ab0b05fc40bbf9d353258ee7a5dc1fc24
6
+ metadata.gz: 828a0d60a75ac730dd882e7825e2ea2e0fec53ca6b2efea6329cac03030157fba4ea086118476445ea9505835ab28031221ed786be526bdfb1b2340e2c570c56
7
+ data.tar.gz: 3df501c15e4050edcbd2225d356440e4672d30a5feb8d85a3b225af0daa4cd66f09f743044ac41b7e75b01a8378951aceac7784078833facb4d42cefcfc3aa24
@@ -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
- def initialize(language: nil)
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
@@ -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
- # Matching is exact-class (equivalent to +instance_of?+): rules are
8
- # keyed by +Class+ and looked up via +node.class+, so an anonymous
9
- # +Class.new(AST::Element)+ or any future subclass never accidentally
10
- # matches a rule written for the base class. Registering a rule for a
11
- # +(parent, child)+ pair that already has one replaces it, so later
12
- # layers (Discourse, a consumer's +#rule+) override earlier ones.
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 whose class has a rule for +child+'s class, or
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
- child_class = child.class
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 unless @child_classes.include?(child_class)
79
+ return NO_MATCH if candidates.empty?
48
80
 
49
- scan_ancestors(child_class, 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 matching ancestor
66
- # wins. It is split from the skip check in {#resolve} so the scan can be
67
- # tested on its own.
68
- def scan_ancestors(child_class, ancestors)
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
- strategies = @by_parent[ancestor.class]
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 stay
52
- # inline and are not listed (Code is handled by {KEEP_INLINE_CODE}).
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 a
72
- # Text child has a newline (the language alone does not make it a block).
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 = node.children.any? { |c| c.instance_of?(AST::Text) && c.text.include?("\n") }
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
- collect_violations(ast, EMPTY_STACK, 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, {})
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
- # 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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Markbridge
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
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 to drop from
280
- # the library so they fall through to +render_children+.
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.3.1
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