markbridge 0.2.1 → 0.3.1
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/element.rb +28 -0
- 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/normalizer/report.rb +41 -0
- data/lib/markbridge/normalizer/rule_set.rb +89 -0
- data/lib/markbridge/normalizer/text_projection.rb +37 -0
- data/lib/markbridge/normalizer/walker.rb +250 -0
- data/lib/markbridge/normalizer.rb +180 -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/event_tag.rb +2 -4
- data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +2 -4
- 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
- data/lib/markbridge.rb +61 -11
- metadata +6 -1
|
@@ -178,6 +178,12 @@ module Markbridge
|
|
|
178
178
|
end
|
|
179
179
|
|
|
180
180
|
def escape_text(text)
|
|
181
|
+
# Single-line fast path (the common case for inline text nodes):
|
|
182
|
+
# skip the split and its Array + line-String allocations. A lone
|
|
183
|
+
# `\r` without `\n` stays on the line either way — `/\r?\n/`
|
|
184
|
+
# needs the `\n` — so `include?("\n")` alone decides correctly.
|
|
185
|
+
return escape_line(text, false) unless text.include?("\n")
|
|
186
|
+
|
|
181
187
|
# On CRLF input, consume `\r` as part of the line terminator instead
|
|
182
188
|
# of leaving it on the line. A trailing `\r` breaks line-end anchored
|
|
183
189
|
# regexes (e.g. SETEXT_UNDERLINE_*) and the `ws_end >= line_length`
|
|
@@ -186,7 +192,6 @@ module Markbridge
|
|
|
186
192
|
# LF-only fast path on a string split (regex split is ~20% slower
|
|
187
193
|
# on the indented-code hot path).
|
|
188
194
|
lines = text.include?("\r") ? text.split(/\r?\n/, -1) : text.split("\n", -1)
|
|
189
|
-
return escape_line(lines[0], false) if lines.size == 1
|
|
190
195
|
|
|
191
196
|
# Pre-allocate result buffer
|
|
192
197
|
bytesize = text.bytesize
|
|
@@ -231,6 +236,12 @@ module Markbridge
|
|
|
231
236
|
result = String.new(encoding: line.encoding)
|
|
232
237
|
result << line[0, indent_len] << escaped
|
|
233
238
|
result
|
|
239
|
+
elsif escaped.equal?(line)
|
|
240
|
+
# Nothing needed escaping, so `escaped` IS the input line: its
|
|
241
|
+
# encoding is already right, and since escape_text's
|
|
242
|
+
# single-line fast path that input can be the caller's own
|
|
243
|
+
# (possibly frozen) string — force_encoding would raise.
|
|
244
|
+
line
|
|
234
245
|
else
|
|
235
246
|
escaped.force_encoding(line.encoding)
|
|
236
247
|
end
|
|
@@ -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
|
|
@@ -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
|
|
@@ -18,11 +18,9 @@ module Markbridge
|
|
|
18
18
|
# end
|
|
19
19
|
# end
|
|
20
20
|
class PollTag < Tag
|
|
21
|
-
def render(element,
|
|
21
|
+
def render(element, _interface)
|
|
22
22
|
body = element.raw || build_poll_bbcode(element)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"#{body}\n\n"
|
|
23
|
+
"\n\n#{body}\n\n"
|
|
26
24
|
end
|
|
27
25
|
|
|
28
26
|
private
|
|
@@ -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
data/lib/markbridge.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "markbridge/parse"
|
|
|
5
5
|
require_relative "markbridge/conversion"
|
|
6
6
|
|
|
7
7
|
require_relative "markbridge/ast"
|
|
8
|
+
require_relative "markbridge/normalizer"
|
|
8
9
|
require_relative "markbridge/renderers/discourse"
|
|
9
10
|
|
|
10
11
|
module Markbridge
|
|
@@ -44,11 +45,21 @@ module Markbridge
|
|
|
44
45
|
# {Conversion} with an empty +markdown+ string, and surface the
|
|
45
46
|
# exceptions via {Conversion#errors}.
|
|
46
47
|
# @yieldparam ast [AST::Document] mutate before rendering (optional)
|
|
48
|
+
# @param normalize [Boolean, Normalizer] apply target-format nesting
|
|
49
|
+
# rules between the +yield+ hook and render. +true+ (default) uses the
|
|
50
|
+
# shared default normalizer; a {Normalizer} is used as-is; +false+
|
|
51
|
+
# skips normalization. See {Normalizer}.
|
|
47
52
|
# @return [Conversion]
|
|
48
|
-
def bbcode_to_markdown(
|
|
53
|
+
def bbcode_to_markdown(
|
|
54
|
+
input,
|
|
55
|
+
handlers: nil,
|
|
56
|
+
renderer: nil,
|
|
57
|
+
raise_on_error: true,
|
|
58
|
+
normalize: true
|
|
59
|
+
)
|
|
49
60
|
parse = parse_bbcode(input, handlers:)
|
|
50
61
|
yield(parse.ast) if block_given?
|
|
51
|
-
build_conversion(parse, renderer:, raise_on_error:)
|
|
62
|
+
build_conversion(parse, renderer:, raise_on_error:, normalize:)
|
|
52
63
|
end
|
|
53
64
|
|
|
54
65
|
# Parse HTML to AST.
|
|
@@ -85,11 +96,12 @@ module Markbridge
|
|
|
85
96
|
# {Conversion} with an empty +markdown+ string, and surface the
|
|
86
97
|
# exceptions via {Conversion#errors}.
|
|
87
98
|
# @yieldparam ast [AST::Document] mutate before rendering (optional)
|
|
99
|
+
# @param normalize [Boolean, Normalizer] see {.bbcode_to_markdown}
|
|
88
100
|
# @return [Conversion]
|
|
89
|
-
def html_to_markdown(input, handlers: nil, renderer: nil, raise_on_error: true)
|
|
101
|
+
def html_to_markdown(input, handlers: nil, renderer: nil, raise_on_error: true, normalize: true)
|
|
90
102
|
parse = parse_html(input, handlers:)
|
|
91
103
|
yield(parse.ast) if block_given?
|
|
92
|
-
build_conversion(parse, renderer:, raise_on_error:)
|
|
104
|
+
build_conversion(parse, renderer:, raise_on_error:, normalize:)
|
|
93
105
|
end
|
|
94
106
|
|
|
95
107
|
# Parse s9e/TextFormatter XML to AST.
|
|
@@ -122,11 +134,18 @@ module Markbridge
|
|
|
122
134
|
# @param renderer [Renderers::Discourse::Renderer, nil] custom renderer
|
|
123
135
|
# @param raise_on_error [Boolean] see {.bbcode_to_markdown}
|
|
124
136
|
# @yieldparam ast [AST::Document] mutate before rendering (optional)
|
|
137
|
+
# @param normalize [Boolean, Normalizer] see {.bbcode_to_markdown}
|
|
125
138
|
# @return [Conversion]
|
|
126
|
-
def text_formatter_xml_to_markdown(
|
|
139
|
+
def text_formatter_xml_to_markdown(
|
|
140
|
+
input,
|
|
141
|
+
handlers: nil,
|
|
142
|
+
renderer: nil,
|
|
143
|
+
raise_on_error: true,
|
|
144
|
+
normalize: true
|
|
145
|
+
)
|
|
127
146
|
parse = parse_text_formatter_xml(input, handlers:)
|
|
128
147
|
yield(parse.ast) if block_given?
|
|
129
|
-
build_conversion(parse, renderer:, raise_on_error:)
|
|
148
|
+
build_conversion(parse, renderer:, raise_on_error:, normalize:)
|
|
130
149
|
end
|
|
131
150
|
|
|
132
151
|
# Parse MediaWiki wikitext to AST.
|
|
@@ -155,11 +174,18 @@ module Markbridge
|
|
|
155
174
|
# @param renderer [Renderers::Discourse::Renderer, nil] custom renderer
|
|
156
175
|
# @param raise_on_error [Boolean] see {.bbcode_to_markdown}
|
|
157
176
|
# @yieldparam ast [AST::Document] mutate before rendering (optional)
|
|
177
|
+
# @param normalize [Boolean, Normalizer] see {.bbcode_to_markdown}
|
|
158
178
|
# @return [Conversion]
|
|
159
|
-
def mediawiki_to_markdown(
|
|
179
|
+
def mediawiki_to_markdown(
|
|
180
|
+
input,
|
|
181
|
+
handlers: nil,
|
|
182
|
+
renderer: nil,
|
|
183
|
+
raise_on_error: true,
|
|
184
|
+
normalize: true
|
|
185
|
+
)
|
|
160
186
|
parse = parse_mediawiki(input, handlers:)
|
|
161
187
|
yield(parse.ast) if block_given?
|
|
162
|
-
build_conversion(parse, renderer:, raise_on_error:)
|
|
188
|
+
build_conversion(parse, renderer:, raise_on_error:, normalize:)
|
|
163
189
|
end
|
|
164
190
|
|
|
165
191
|
# Convert input in the given format. Thin dispatcher over the
|
|
@@ -211,8 +237,17 @@ module Markbridge
|
|
|
211
237
|
# @param format [Symbol] :discourse (only renderer currently shipped)
|
|
212
238
|
# @param renderer [Renderers::Discourse::Renderer, nil]
|
|
213
239
|
# @param raise_on_error [Boolean]
|
|
240
|
+
# @param normalize [Boolean, Normalizer] see {.bbcode_to_markdown}.
|
|
241
|
+
# Normalization is idempotent, so re-rendering an already-normalized
|
|
242
|
+
# {Parse} is a no-op.
|
|
214
243
|
# @return [Conversion]
|
|
215
|
-
def render(
|
|
244
|
+
def render(
|
|
245
|
+
parse_or_ast,
|
|
246
|
+
format: :discourse,
|
|
247
|
+
renderer: nil,
|
|
248
|
+
raise_on_error: true,
|
|
249
|
+
normalize: true
|
|
250
|
+
)
|
|
216
251
|
raise ArgumentError, "unknown render format #{format.inspect}" unless format == :discourse
|
|
217
252
|
|
|
218
253
|
parse =
|
|
@@ -228,7 +263,7 @@ module Markbridge
|
|
|
228
263
|
raise ArgumentError, "expected Parse or AST::Node, got #{parse_or_ast.class}"
|
|
229
264
|
end
|
|
230
265
|
|
|
231
|
-
build_conversion(parse, renderer:, raise_on_error:)
|
|
266
|
+
build_conversion(parse, renderer:, raise_on_error:, normalize:)
|
|
232
267
|
end
|
|
233
268
|
|
|
234
269
|
# Build a configured Discourse {Renderers::Discourse::Renderer}
|
|
@@ -297,13 +332,28 @@ module Markbridge
|
|
|
297
332
|
}
|
|
298
333
|
end
|
|
299
334
|
|
|
300
|
-
def build_conversion(parse, renderer:, raise_on_error:)
|
|
335
|
+
def build_conversion(parse, renderer:, raise_on_error:, normalize:)
|
|
336
|
+
parse = apply_normalization(parse, normalize)
|
|
301
337
|
renderer ||= Renderers::Discourse::Renderer.new
|
|
302
338
|
markdown, errors = render_through(renderer, parse.ast, raise_on_error:)
|
|
303
339
|
|
|
304
340
|
Conversion.new(parsed: parse, markdown:, errors:)
|
|
305
341
|
end
|
|
306
342
|
|
|
343
|
+
# Normalize +parse.ast+ in place (target-format nesting rules) and fold
|
|
344
|
+
# any report into +diagnostics[:normalization]+. Returns the +parse+ to
|
|
345
|
+
# render — a new one carrying the report when something changed, else
|
|
346
|
+
# the original unchanged.
|
|
347
|
+
def apply_normalization(parse, normalize)
|
|
348
|
+
return parse unless normalize
|
|
349
|
+
|
|
350
|
+
normalizer = normalize.is_a?(Normalizer) ? normalize : Normalizer.shared_default
|
|
351
|
+
report = normalizer.normalize(parse.ast)
|
|
352
|
+
return parse if report.empty?
|
|
353
|
+
|
|
354
|
+
parse.with(diagnostics: parse.diagnostics.merge(normalization: report))
|
|
355
|
+
end
|
|
356
|
+
|
|
307
357
|
def build_escaper(escape:, escape_hard_line_breaks:, allow:)
|
|
308
358
|
if escape == false
|
|
309
359
|
if escape_hard_line_breaks || allow
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: markbridge
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Discourse Team
|
|
@@ -58,6 +58,11 @@ files:
|
|
|
58
58
|
- lib/markbridge/gem_loader.rb
|
|
59
59
|
- lib/markbridge/html.rb
|
|
60
60
|
- lib/markbridge/mediawiki.rb
|
|
61
|
+
- lib/markbridge/normalizer.rb
|
|
62
|
+
- lib/markbridge/normalizer/report.rb
|
|
63
|
+
- lib/markbridge/normalizer/rule_set.rb
|
|
64
|
+
- lib/markbridge/normalizer/text_projection.rb
|
|
65
|
+
- lib/markbridge/normalizer/walker.rb
|
|
61
66
|
- lib/markbridge/parse.rb
|
|
62
67
|
- lib/markbridge/parsers/bbcode.rb
|
|
63
68
|
- lib/markbridge/parsers/bbcode/closing_strategies/base.rb
|