markbridge 0.2.1 → 0.3.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/quote.rb +45 -15
- data/lib/markbridge/ast/text.rb +7 -4
- data/lib/markbridge/ast/url.rb +15 -0
- data/lib/markbridge/parsers/bbcode/handler_registry.rb +27 -1
- data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +32 -9
- data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +8 -4
- data/lib/markbridge/parsers/bbcode/parser.rb +6 -3
- data/lib/markbridge/parsers/bbcode/scanner.rb +133 -53
- data/lib/markbridge/parsers/html/handler_registry.rb +27 -1
- data/lib/markbridge/parsers/html/parser.rb +54 -13
- data/lib/markbridge/parsers/media_wiki/inline_parser.rb +95 -57
- data/lib/markbridge/parsers/media_wiki/inline_tag_registry.rb +24 -1
- data/lib/markbridge/parsers/media_wiki/parser.rb +5 -2
- data/lib/markbridge/parsers/text_formatter/handler_registry.rb +23 -1
- data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +31 -2
- data/lib/markbridge/parsers/text_formatter/parser.rb +10 -3
- data/lib/markbridge/renderers/discourse/markdown_escaper.rb +12 -1
- data/lib/markbridge/renderers/discourse/render_context.rb +74 -11
- data/lib/markbridge/renderers/discourse/renderer.rb +63 -13
- data/lib/markbridge/renderers/discourse/rendering_interface.rb +27 -2
- data/lib/markbridge/renderers/discourse/tag_library.rb +20 -0
- data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +8 -6
- data/lib/markbridge/renderers/discourse/tags/url_tag.rb +36 -2
- data/lib/markbridge/version.rb +1 -1
- metadata +1 -1
|
@@ -3,30 +3,74 @@
|
|
|
3
3
|
module Markbridge
|
|
4
4
|
module Renderers
|
|
5
5
|
module Discourse
|
|
6
|
-
# Immutable context for rendering
|
|
6
|
+
# Immutable context for rendering, implemented as a linked parent
|
|
7
|
+
# chain: each context holds the nearest parent element plus a link
|
|
8
|
+
# to the enclosing context. {#with_parent} runs once per rendered
|
|
9
|
+
# element — the chain form makes it a single fixed-size allocation
|
|
10
|
+
# instead of copying (and freezing) a parents array per element.
|
|
7
11
|
# Provides query methods to ask about parent elements without the
|
|
8
12
|
# renderer knowing about specific element types.
|
|
9
13
|
class RenderContext
|
|
10
|
-
|
|
14
|
+
# @return [Integer] number of parent elements in the chain
|
|
15
|
+
attr_reader :depth
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
# @return [AST::Element, nil] the nearest parent element (nil at root)
|
|
18
|
+
attr_reader :element
|
|
19
|
+
|
|
20
|
+
# @return [RenderContext, nil] the enclosing context (nil at root)
|
|
21
|
+
attr_reader :parent_context
|
|
22
|
+
|
|
23
|
+
# @param parents [Array<AST::Element>] parent elements in document
|
|
24
|
+
# order (outermost first); convenience form for building a
|
|
25
|
+
# context from scratch. Ignored when +element:+ is given.
|
|
26
|
+
# @param html_mode [Boolean] see {#html_mode?}
|
|
27
|
+
# @param parent [RenderContext, nil] enclosing context (chain form)
|
|
28
|
+
# @param element [AST::Element, nil] nearest parent element (chain form)
|
|
29
|
+
def initialize(parents = [], html_mode: false, parent: nil, element: nil)
|
|
15
30
|
@html_mode = html_mode
|
|
31
|
+
if element
|
|
32
|
+
@parent_context = parent
|
|
33
|
+
@element = element
|
|
34
|
+
@depth = (parent ? parent.depth : 0) + 1
|
|
35
|
+
elsif parents.empty?
|
|
36
|
+
# Root context: @element and @parent_context stay unset and
|
|
37
|
+
# read as nil.
|
|
38
|
+
@depth = 0
|
|
39
|
+
else
|
|
40
|
+
@parent_context = self.class.new(parents[0, parents.size - 1], html_mode:)
|
|
41
|
+
@element = parents.last
|
|
42
|
+
@depth = parents.size
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Parent elements in document order (outermost first), materialized
|
|
47
|
+
# from the chain into a frozen Array. Allocates on every call —
|
|
48
|
+
# prefer {#find_parent} / {#has_parent?} / {#count_parents} on hot
|
|
49
|
+
# paths.
|
|
50
|
+
# @return [Array<AST::Element>]
|
|
51
|
+
def parents
|
|
52
|
+
result = []
|
|
53
|
+
context = self
|
|
54
|
+
while context && (parent = context.element)
|
|
55
|
+
result << parent
|
|
56
|
+
context = context.parent_context
|
|
57
|
+
end
|
|
58
|
+
result.reverse!
|
|
59
|
+
result.freeze
|
|
16
60
|
end
|
|
17
61
|
|
|
18
62
|
# Create new context with element added to parent chain.
|
|
19
63
|
# @param element [AST::Element]
|
|
20
64
|
# @return [RenderContext]
|
|
21
65
|
def with_parent(element)
|
|
22
|
-
self.class.new(@
|
|
66
|
+
self.class.new(html_mode: @html_mode, parent: self, element:)
|
|
23
67
|
end
|
|
24
68
|
|
|
25
69
|
# Create new context with html_mode toggled.
|
|
26
70
|
# @param value [Boolean]
|
|
27
71
|
# @return [RenderContext]
|
|
28
72
|
def with_html_mode(value)
|
|
29
|
-
self.class.new(@
|
|
73
|
+
self.class.new(html_mode: value, parent: @parent_context, element: @element)
|
|
30
74
|
end
|
|
31
75
|
|
|
32
76
|
# @return [Boolean]
|
|
@@ -35,24 +79,43 @@ module Markbridge
|
|
|
35
79
|
end
|
|
36
80
|
|
|
37
81
|
# Find closest parent that is_a? klass (handles subclasses).
|
|
82
|
+
# The chain walks are inlined in each query (instead of a shared
|
|
83
|
+
# yielding helper) — these run several times per rendered text
|
|
84
|
+
# node, and a plain while loop avoids the block invocation.
|
|
38
85
|
# @param klass [Class]
|
|
39
|
-
# @return [AST::Element, nil]
|
|
86
|
+
# @return [AST::Element, nil] nil when no parent matches (implicit
|
|
87
|
+
# from the exhausted while loop)
|
|
40
88
|
def find_parent(klass)
|
|
41
|
-
|
|
89
|
+
context = self
|
|
90
|
+
while context && (parent = context.element)
|
|
91
|
+
return parent if parent.is_a?(klass)
|
|
92
|
+
context = context.parent_context
|
|
93
|
+
end
|
|
42
94
|
end
|
|
43
95
|
|
|
44
96
|
# Count parents that are is_a? klass (handles subclasses).
|
|
45
97
|
# @param klass [Class]
|
|
46
98
|
# @return [Integer]
|
|
47
99
|
def count_parents(klass)
|
|
48
|
-
|
|
100
|
+
count = 0
|
|
101
|
+
context = self
|
|
102
|
+
while context && (parent = context.element)
|
|
103
|
+
count += 1 if parent.is_a?(klass)
|
|
104
|
+
context = context.parent_context
|
|
105
|
+
end
|
|
106
|
+
count
|
|
49
107
|
end
|
|
50
108
|
|
|
51
109
|
# Check if any parent is_a? klass (handles subclasses).
|
|
52
110
|
# @param klass [Class]
|
|
53
111
|
# @return [Boolean]
|
|
54
112
|
def has_parent?(klass)
|
|
55
|
-
|
|
113
|
+
context = self
|
|
114
|
+
while context && (parent = context.element)
|
|
115
|
+
return true if parent.is_a?(klass)
|
|
116
|
+
context = context.parent_context
|
|
117
|
+
end
|
|
118
|
+
false
|
|
56
119
|
end
|
|
57
120
|
|
|
58
121
|
# Check if we're at the root (no parents).
|
|
@@ -8,7 +8,7 @@ module Markbridge
|
|
|
8
8
|
attr_reader :postprocessor
|
|
9
9
|
|
|
10
10
|
def initialize(tag_library: nil, escaper: nil, html_escaper: nil, postprocessor: nil)
|
|
11
|
-
@tag_library = tag_library || TagLibrary.
|
|
11
|
+
@tag_library = tag_library || TagLibrary.shared_default
|
|
12
12
|
@escaper = escaper || MarkdownEscaper.new
|
|
13
13
|
@html_escaper = html_escaper || HtmlEscaper
|
|
14
14
|
@postprocessor = postprocessor || Postprocessor::DEFAULT
|
|
@@ -20,26 +20,56 @@ module Markbridge
|
|
|
20
20
|
# @param node [AST::Node]
|
|
21
21
|
# @param context [RenderContext] rendering context with parent chain
|
|
22
22
|
# @return [String]
|
|
23
|
+
# @raise [TypeError] when the tag bound to the node's class returns
|
|
24
|
+
# something other than a String (a nil from a custom tag would
|
|
25
|
+
# otherwise surface as an inscrutable concatenation error deep
|
|
26
|
+
# inside render_children)
|
|
23
27
|
def render(node, context: RenderContext.new)
|
|
24
28
|
root_call = @interface_cache.nil?
|
|
25
29
|
@interface_cache = {} if root_call
|
|
26
30
|
|
|
27
31
|
tag = @tag_library[node.class]
|
|
28
32
|
if tag
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
result = tag.render(node, interface_for(context))
|
|
34
|
+
unless result.is_a?(String)
|
|
35
|
+
raise TypeError,
|
|
36
|
+
"#{tag.class} rendered #{node.class} to " \
|
|
37
|
+
"#{result.inspect} — tags must return a String " \
|
|
38
|
+
"(use interface.render_default(node) to fall back " \
|
|
39
|
+
"to the stock rendering)"
|
|
40
|
+
end
|
|
41
|
+
return result
|
|
31
42
|
end
|
|
32
43
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
render_without_tag(node, context)
|
|
45
|
+
ensure
|
|
46
|
+
@interface_cache = nil if root_call
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Render a node with the stock tag for its class, ignoring any
|
|
50
|
+
# override registered in this renderer's tag library. Lets a
|
|
51
|
+
# custom tag intercept only the nodes it cares about and delegate
|
|
52
|
+
# the rest:
|
|
53
|
+
#
|
|
54
|
+
# library.register(AST::Quote, Tag.new do |node, interface|
|
|
55
|
+
# next interface.render_default(node) unless node.username&.start_with?("legacy_")
|
|
56
|
+
# ...custom rendering...
|
|
57
|
+
# end)
|
|
58
|
+
#
|
|
59
|
+
# Children still render through this renderer, so overrides for
|
|
60
|
+
# other node classes keep applying inside the delegated subtree.
|
|
61
|
+
#
|
|
62
|
+
# @param node [AST::Node]
|
|
63
|
+
# @param context [RenderContext] rendering context with parent chain
|
|
64
|
+
# @return [String]
|
|
65
|
+
def render_default(node, context: RenderContext.new)
|
|
66
|
+
root_call = @interface_cache.nil?
|
|
67
|
+
@interface_cache = {} if root_call
|
|
68
|
+
|
|
69
|
+
tag = default_tag_library[node.class]
|
|
70
|
+
return tag.render(node, interface_for(context)) if tag
|
|
71
|
+
|
|
72
|
+
render_without_tag(node, context)
|
|
43
73
|
ensure
|
|
44
74
|
@interface_cache = nil if root_call
|
|
45
75
|
end
|
|
@@ -83,6 +113,26 @@ module Markbridge
|
|
|
83
113
|
@interface_cache[context.object_id] ||= RenderingInterface.new(self, context)
|
|
84
114
|
end
|
|
85
115
|
|
|
116
|
+
# Pristine default library backing #render_default. Built lazily —
|
|
117
|
+
# most renders never need it.
|
|
118
|
+
def default_tag_library
|
|
119
|
+
@default_tag_library ||= TagLibrary.default
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The tag-less rendering paths shared by #render and #render_default.
|
|
123
|
+
def render_without_tag(node, context)
|
|
124
|
+
case node
|
|
125
|
+
when AST::Element # Document is an Element subclass
|
|
126
|
+
render_children(node, context:)
|
|
127
|
+
when AST::MarkdownText
|
|
128
|
+
render_markdown_text(node, context)
|
|
129
|
+
when AST::Text
|
|
130
|
+
render_text(node, context)
|
|
131
|
+
else
|
|
132
|
+
""
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
86
136
|
# In html_mode, surround pre-formatted Markdown with blank lines so that
|
|
87
137
|
# CommonMark terminates the enclosing HTML block (e.g. <table>) and
|
|
88
138
|
# parses the content as Markdown before the closing tags reopen another
|
|
@@ -18,6 +18,14 @@ module Markbridge
|
|
|
18
18
|
@renderer.render(node, context:)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# Render a node with the stock tag for its class, bypassing any
|
|
22
|
+
# override in the renderer's tag library. Lets a custom tag handle
|
|
23
|
+
# only the nodes it cares about and delegate the rest — see
|
|
24
|
+
# Renderer#render_default.
|
|
25
|
+
def render_default(node, context: @context)
|
|
26
|
+
@renderer.render_default(node, context:)
|
|
27
|
+
end
|
|
28
|
+
|
|
21
29
|
def render_children(element, context: @context)
|
|
22
30
|
@renderer.render_children(element, context:)
|
|
23
31
|
end
|
|
@@ -63,6 +71,11 @@ module Markbridge
|
|
|
63
71
|
node.children.any? { |c| c.instance_of?(AST::Text) && c.text.include?("\n") }
|
|
64
72
|
end
|
|
65
73
|
|
|
74
|
+
# Leading or trailing whitespace (Unicode-aware, matching the
|
|
75
|
+
# flanking-preservation sub in #wrap_inline).
|
|
76
|
+
EDGE_WHITESPACE = /\A[[:space:]]|[[:space:]]\z/m
|
|
77
|
+
private_constant :EDGE_WHITESPACE
|
|
78
|
+
|
|
66
79
|
# Helper: wrap inline content with markers
|
|
67
80
|
# Handles edge cases like existing markers and whitespace
|
|
68
81
|
def wrap_inline(content, open_marker, close_marker = nil)
|
|
@@ -82,8 +95,20 @@ module Markbridge
|
|
|
82
95
|
end
|
|
83
96
|
end
|
|
84
97
|
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
apply_markers(content, open_marker, close_marker)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Wrap content in markers, keeping leading/trailing whitespace
|
|
104
|
+
# outside the markers (Unicode-aware, since CommonMark's flanking
|
|
105
|
+
# rules treat e.g. nbsp as whitespace).
|
|
106
|
+
def apply_markers(content, open_marker, close_marker)
|
|
107
|
+
# Fast path: no edge whitespace to preserve (the common case), so
|
|
108
|
+
# plain interpolation replaces the capture-group sub below and its
|
|
109
|
+
# MatchData + capture allocations.
|
|
110
|
+
return "#{open_marker}#{content}#{close_marker}" unless content.match?(EDGE_WHITESPACE)
|
|
111
|
+
|
|
87
112
|
content.sub(/\A([[:space:]]*)(.+?)([[:space:]]*)\z/m) do
|
|
88
113
|
match = Regexp.last_match
|
|
89
114
|
"#{match[1]}#{open_marker}#{match[2]}#{close_marker}#{match[3]}"
|
|
@@ -106,6 +106,26 @@ module Markbridge
|
|
|
106
106
|
def self.default
|
|
107
107
|
new.auto_register!
|
|
108
108
|
end
|
|
109
|
+
|
|
110
|
+
# Shared, deep-frozen default library for the no-customization
|
|
111
|
+
# fast path. Built once per process; {Renderer} falls back to it
|
|
112
|
+
# when no +tag_library:+ is given, skipping the constant-scan and
|
|
113
|
+
# ~30 tag instantiations of {.default} on every render. Tags are
|
|
114
|
+
# stateless, so sharing is safe across renderers and threads.
|
|
115
|
+
# +dup+ yields a mutable copy (see {#initialize_copy}).
|
|
116
|
+
#
|
|
117
|
+
# @return [TagLibrary] the same frozen instance on every call
|
|
118
|
+
def self.shared_default
|
|
119
|
+
@shared_default ||= default.freeze
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Freeze the library together with its internal Hash so that
|
|
123
|
+
# registration on a shared instance fails loudly instead of
|
|
124
|
+
# silently mutating state visible to every renderer.
|
|
125
|
+
def freeze
|
|
126
|
+
@tags.freeze
|
|
127
|
+
super
|
|
128
|
+
end
|
|
109
129
|
end
|
|
110
130
|
end
|
|
111
131
|
end
|
|
@@ -14,14 +14,16 @@ module Markbridge
|
|
|
14
14
|
return "<blockquote>#{content}</blockquote>" if interface.html_mode?
|
|
15
15
|
|
|
16
16
|
# Build Discourse quote BBCode
|
|
17
|
-
# Format: [quote="username, post:
|
|
17
|
+
# Format: [quote="username, post:2, topic:456"]content[/quote]
|
|
18
|
+
# (post: is the post number within the topic, topic: the topic id)
|
|
18
19
|
body =
|
|
19
|
-
if element.
|
|
20
|
+
if element.post_number && element.topic_id && element.username
|
|
20
21
|
# Full Discourse quote with context
|
|
21
|
-
"[quote=\"#{element.username}, post:#{element.
|
|
22
|
-
elsif element.author
|
|
23
|
-
#
|
|
24
|
-
|
|
22
|
+
"[quote=\"#{element.username}, post:#{element.post_number}, topic:#{element.topic_id}\"]\n#{content}\n[/quote]"
|
|
23
|
+
elsif element.author || element.username
|
|
24
|
+
# Name-only attribution; a bare post_id/user_id can't
|
|
25
|
+
# produce a valid Discourse post reference.
|
|
26
|
+
"[quote=\"#{element.author || element.username}\"]\n#{content}\n[/quote]"
|
|
25
27
|
else
|
|
26
28
|
# Plain quote rendered as Markdown blockquote
|
|
27
29
|
content.split("\n").map { |line| "> #{line}" }.join("\n")
|
|
@@ -5,20 +5,54 @@ module Markbridge
|
|
|
5
5
|
module Discourse
|
|
6
6
|
module Tags
|
|
7
7
|
# Tag for rendering URLs
|
|
8
|
+
#
|
|
9
|
+
# Bare URLs (a single Text child equal to the href, or no link
|
|
10
|
+
# text at all) render as the plain href instead of a Markdown
|
|
11
|
+
# link — in Discourse a bare URL on its own line oneboxes, a
|
|
12
|
+
# `[text](url)` link does not.
|
|
8
13
|
class UrlTag < Tag
|
|
14
|
+
# Schemes that are safe to link. Anything else that *looks*
|
|
15
|
+
# like a scheme (javascript:, data:, vbscript:, …) is dropped;
|
|
16
|
+
# scheme-less hrefs (relative paths, anchors, protocol-relative
|
|
17
|
+
# URLs) pass through — common in forum exports and harmless.
|
|
18
|
+
ALLOWED_SCHEMES = /\A(?:https?|ftps?|mailto):/i
|
|
19
|
+
SCHEME_LIKE = /\A[a-z][a-z0-9+.-]*:/i
|
|
20
|
+
private_constant :ALLOWED_SCHEMES, :SCHEME_LIKE
|
|
21
|
+
|
|
9
22
|
def render(element, interface)
|
|
10
23
|
child_context = interface.with_parent(element)
|
|
11
24
|
text = interface.render_children(element, context: child_context)
|
|
12
25
|
href = element.href
|
|
13
26
|
|
|
14
|
-
return text unless
|
|
27
|
+
return text unless linkable?(href)
|
|
15
28
|
|
|
16
29
|
if interface.html_mode?
|
|
17
30
|
%(<a href="#{HtmlEscaper.escape(href)}">#{text}</a>)
|
|
31
|
+
elsif element.bare? || text.empty?
|
|
32
|
+
# Url#bare? judges the AST (so label escaping can't confuse
|
|
33
|
+
# it); the rendered-text check additionally catches labels
|
|
34
|
+
# that render to nothing (e.g. an empty formatting child).
|
|
35
|
+
href
|
|
18
36
|
else
|
|
19
|
-
"[#{text}](#{href})"
|
|
37
|
+
"[#{text}](#{markdown_destination(href)})"
|
|
20
38
|
end
|
|
21
39
|
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# CommonMark link destinations cannot contain whitespace unless
|
|
44
|
+
# wrapped in <> — relevant for relative targets like MediaWiki
|
|
45
|
+
# page names ("Main Page").
|
|
46
|
+
def markdown_destination(href)
|
|
47
|
+
href.match?(/\s/) ? "<#{href}>" : href
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def linkable?(href)
|
|
51
|
+
return false if href.nil? || href.empty?
|
|
52
|
+
return true if href.match?(ALLOWED_SCHEMES)
|
|
53
|
+
|
|
54
|
+
!href.match?(SCHEME_LIKE)
|
|
55
|
+
end
|
|
22
56
|
end
|
|
23
57
|
end
|
|
24
58
|
end
|
data/lib/markbridge/version.rb
CHANGED