markbridge 0.2.0 → 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
- data/lib/markbridge.rb +0 -1
- metadata +1 -10
- data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +0 -218
- data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +0 -63
- data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +0 -64
- data/lib/markbridge/processors/discourse_markdown/detectors/mention.rb +0 -57
- data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +0 -52
- data/lib/markbridge/processors/discourse_markdown/detectors/upload.rb +0 -98
- data/lib/markbridge/processors/discourse_markdown/scanner.rb +0 -194
- data/lib/markbridge/processors/discourse_markdown.rb +0 -16
- data/lib/markbridge/processors.rb +0 -8
|
@@ -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
data/lib/markbridge.rb
CHANGED
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.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Discourse Team
|
|
@@ -130,15 +130,6 @@ files:
|
|
|
130
130
|
- lib/markbridge/parsers/text_formatter/handlers/table_cell_handler.rb
|
|
131
131
|
- lib/markbridge/parsers/text_formatter/handlers/url_handler.rb
|
|
132
132
|
- lib/markbridge/parsers/text_formatter/parser.rb
|
|
133
|
-
- lib/markbridge/processors.rb
|
|
134
|
-
- lib/markbridge/processors/discourse_markdown.rb
|
|
135
|
-
- lib/markbridge/processors/discourse_markdown/code_block_tracker.rb
|
|
136
|
-
- lib/markbridge/processors/discourse_markdown/detectors/base.rb
|
|
137
|
-
- lib/markbridge/processors/discourse_markdown/detectors/event.rb
|
|
138
|
-
- lib/markbridge/processors/discourse_markdown/detectors/mention.rb
|
|
139
|
-
- lib/markbridge/processors/discourse_markdown/detectors/poll.rb
|
|
140
|
-
- lib/markbridge/processors/discourse_markdown/detectors/upload.rb
|
|
141
|
-
- lib/markbridge/processors/discourse_markdown/scanner.rb
|
|
142
133
|
- lib/markbridge/renderers/discourse.rb
|
|
143
134
|
- lib/markbridge/renderers/discourse/builders/list_item_builder.rb
|
|
144
135
|
- lib/markbridge/renderers/discourse/html_escaper.rb
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Markbridge
|
|
4
|
-
module Processors
|
|
5
|
-
module DiscourseMarkdown
|
|
6
|
-
# Tracks whether the current position is inside a code block.
|
|
7
|
-
# Handles fenced code blocks (``` or ~~~), indented code blocks (4+ spaces),
|
|
8
|
-
# and inline code (`).
|
|
9
|
-
#
|
|
10
|
-
# Fenced code blocks:
|
|
11
|
-
# - Can have leading whitespace (up to 3 spaces)
|
|
12
|
-
# - Opening fence: 3+ backticks or tildes, optionally followed by language
|
|
13
|
-
# - Closing fence: same or more fence characters as opening
|
|
14
|
-
#
|
|
15
|
-
# Indented code blocks:
|
|
16
|
-
# - Lines indented by 4+ spaces or 1+ tab
|
|
17
|
-
# - Continues until a non-blank line with less indentation
|
|
18
|
-
#
|
|
19
|
-
# Inline code:
|
|
20
|
-
# - Single or multiple backticks as delimiter
|
|
21
|
-
# - Content between matching backticks
|
|
22
|
-
class CodeBlockTracker
|
|
23
|
-
# @return [Boolean] true if currently inside a fenced code block
|
|
24
|
-
attr_reader :in_fenced_block
|
|
25
|
-
|
|
26
|
-
# @return [Boolean] true if currently inside an indented code block
|
|
27
|
-
attr_reader :in_indented_block
|
|
28
|
-
|
|
29
|
-
# @return [Boolean] true if currently inside an inline code span
|
|
30
|
-
attr_reader :in_inline_code
|
|
31
|
-
|
|
32
|
-
def initialize
|
|
33
|
-
@in_fenced_block = false
|
|
34
|
-
@in_indented_block = false
|
|
35
|
-
@in_inline_code = false
|
|
36
|
-
# @fence_char / @fence_length / @inline_delimiter are set by
|
|
37
|
-
# open_fence / open_inline before any helper reads them;
|
|
38
|
-
# they're only consulted when the corresponding in_X flag is
|
|
39
|
-
# true, which requires a prior open_* call.
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Check if currently inside any code context
|
|
43
|
-
# @return [Boolean]
|
|
44
|
-
def in_code?
|
|
45
|
-
@in_fenced_block || @in_indented_block || @in_inline_code
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Check if position is at start of a fenced code block boundary
|
|
49
|
-
# @param input [String] the full input string
|
|
50
|
-
# @param pos [Integer] current position
|
|
51
|
-
# @param line_start [Boolean] true if pos is at the start of a line
|
|
52
|
-
# @return [Integer, nil] end position after fence, or nil if no fence
|
|
53
|
-
def check_fenced_boundary(input, pos, line_start:)
|
|
54
|
-
return nil unless line_start
|
|
55
|
-
|
|
56
|
-
input_length = input.length
|
|
57
|
-
scan_pos = skip_leading_spaces(input, pos)
|
|
58
|
-
# No `scan_pos >= input_length` guard: `input[input_length]` is
|
|
59
|
-
# nil, and `nil == "`"` / `nil == "~"` are both false so the
|
|
60
|
-
# next check returns nil anyway.
|
|
61
|
-
fence_char = input[scan_pos]
|
|
62
|
-
return nil unless fence_char == "`" || fence_char == "~"
|
|
63
|
-
|
|
64
|
-
fence_length, scan_pos = count_fence_chars(input, scan_pos, fence_char, input_length)
|
|
65
|
-
return nil if fence_length < 3
|
|
66
|
-
|
|
67
|
-
if @in_fenced_block
|
|
68
|
-
try_close_fence(input, scan_pos, fence_char, fence_length, input_length)
|
|
69
|
-
else
|
|
70
|
-
open_fence(input, scan_pos, fence_char, fence_length, input_length)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# Check if line at position is an indented code block line.
|
|
75
|
-
# A line is considered indented code if it starts with 4+ spaces or 1+ tab.
|
|
76
|
-
# Blank lines within an indented block are considered part of it.
|
|
77
|
-
#
|
|
78
|
-
# @param input [String] the full input string
|
|
79
|
-
# @param pos [Integer] current position (must be at line start)
|
|
80
|
-
# @param line_start [Boolean] true if pos is at the start of a line
|
|
81
|
-
# @return [Integer, nil] end position after the line, or nil if not indented code
|
|
82
|
-
def check_indented_boundary(input, pos, line_start:)
|
|
83
|
-
return nil unless line_start
|
|
84
|
-
return nil if @in_fenced_block # Fenced blocks take precedence
|
|
85
|
-
|
|
86
|
-
input_length = input.length
|
|
87
|
-
line_end = input.index("\n", pos) || input_length
|
|
88
|
-
line_content = input[pos...line_end]
|
|
89
|
-
is_blank = line_content.match?(/\A\s*\z/)
|
|
90
|
-
has_code_indent = line_content.start_with?(" ") || line_content.start_with?("\t")
|
|
91
|
-
|
|
92
|
-
if @in_indented_block
|
|
93
|
-
if is_blank || has_code_indent
|
|
94
|
-
pos_after_line(line_end, input_length)
|
|
95
|
-
else
|
|
96
|
-
@in_indented_block = false
|
|
97
|
-
nil
|
|
98
|
-
end
|
|
99
|
-
elsif has_code_indent
|
|
100
|
-
@in_indented_block = true
|
|
101
|
-
pos_after_line(line_end, input_length)
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# Check for inline code boundary
|
|
106
|
-
# @param input [String] the full input string
|
|
107
|
-
# @param pos [Integer] current position
|
|
108
|
-
# @return [Integer, nil] end position after inline code, or nil if not at boundary
|
|
109
|
-
def check_inline_boundary(input, pos)
|
|
110
|
-
return nil if @in_fenced_block || @in_indented_block
|
|
111
|
-
# No `pos >= input_length` guard: `input[input_length]` is nil,
|
|
112
|
-
# and `nil != "`"` is true so the next check returns nil anyway.
|
|
113
|
-
return nil if input[pos] != "`"
|
|
114
|
-
|
|
115
|
-
input_length = input.length
|
|
116
|
-
if @in_inline_code
|
|
117
|
-
try_close_inline(input, pos, input_length)
|
|
118
|
-
else
|
|
119
|
-
open_inline(input, pos, input_length)
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
private
|
|
124
|
-
|
|
125
|
-
# Skip up to 3 leading spaces of indentation.
|
|
126
|
-
def skip_leading_spaces(input, pos)
|
|
127
|
-
scan_pos = pos
|
|
128
|
-
spaces = 0
|
|
129
|
-
while spaces < 3 && input[scan_pos] == " "
|
|
130
|
-
spaces += 1
|
|
131
|
-
scan_pos += 1
|
|
132
|
-
end
|
|
133
|
-
scan_pos
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# Count consecutive fence characters and return [count, new_position].
|
|
137
|
-
def count_fence_chars(input, scan_pos, fence_char, input_length)
|
|
138
|
-
fence_length = 0
|
|
139
|
-
while scan_pos < input_length && input[scan_pos] == fence_char
|
|
140
|
-
fence_length += 1
|
|
141
|
-
scan_pos += 1
|
|
142
|
-
end
|
|
143
|
-
[fence_length, scan_pos]
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
# Try to close an open fenced code block. Returns position after fence or nil.
|
|
147
|
-
def try_close_fence(input, scan_pos, fence_char, fence_length, input_length)
|
|
148
|
-
return nil unless fence_char == @fence_char && fence_length >= @fence_length
|
|
149
|
-
|
|
150
|
-
# Closing fence must be followed only by spaces then newline/EOF
|
|
151
|
-
scan_pos += 1 while scan_pos < input_length && input[scan_pos] == " "
|
|
152
|
-
return nil unless scan_pos >= input_length || input[scan_pos] == "\n"
|
|
153
|
-
|
|
154
|
-
# @fence_char / @fence_length are not reset here: they are only
|
|
155
|
-
# consulted while @in_fenced_block is true, and open_fence
|
|
156
|
-
# overwrites them on the next opening.
|
|
157
|
-
@in_fenced_block = false
|
|
158
|
-
pos_after_line(scan_pos, input_length)
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Open a new fenced code block. Returns position after the opening line.
|
|
162
|
-
def open_fence(input, scan_pos, fence_char, fence_length, input_length)
|
|
163
|
-
# Skip to end of line (info string)
|
|
164
|
-
scan_pos += 1 while scan_pos < input_length && input[scan_pos] != "\n"
|
|
165
|
-
|
|
166
|
-
@in_fenced_block = true
|
|
167
|
-
@fence_char = fence_char
|
|
168
|
-
@fence_length = fence_length
|
|
169
|
-
pos_after_line(scan_pos, input_length)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
# Try to close inline code. Returns position after delimiter or nil.
|
|
173
|
-
def try_close_inline(input, pos, input_length)
|
|
174
|
-
delimiter_length = @inline_delimiter.length
|
|
175
|
-
return nil unless input[pos, delimiter_length] == @inline_delimiter
|
|
176
|
-
|
|
177
|
-
# Should not be followed by another backtick
|
|
178
|
-
next_pos = pos + delimiter_length
|
|
179
|
-
return nil if next_pos < input_length && input[next_pos] == "`"
|
|
180
|
-
|
|
181
|
-
# @inline_delimiter is not reset here: it is only consulted
|
|
182
|
-
# while @in_inline_code is true, and open_inline overwrites it
|
|
183
|
-
# on the next opening.
|
|
184
|
-
@in_inline_code = false
|
|
185
|
-
next_pos
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
# Open inline code. Returns position after opening delimiter.
|
|
189
|
-
def open_inline(input, pos, input_length)
|
|
190
|
-
delimiter_start = pos
|
|
191
|
-
pos += 1 while pos < input_length && input[pos] == "`"
|
|
192
|
-
|
|
193
|
-
@inline_delimiter = input[delimiter_start...pos]
|
|
194
|
-
@in_inline_code = true
|
|
195
|
-
pos
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
# Return position after a line (after newline if present, otherwise at end).
|
|
199
|
-
def pos_after_line(line_end, input_length)
|
|
200
|
-
line_end < input_length ? line_end + 1 : line_end
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
public
|
|
204
|
-
|
|
205
|
-
# Reset the tracker state. The @fence_char / @fence_length /
|
|
206
|
-
# @inline_delimiter companions are not cleared: they're only
|
|
207
|
-
# consulted while the corresponding in_* flag is true, and
|
|
208
|
-
# open_fence / open_inline overwrites them on the next
|
|
209
|
-
# opening (same pattern as try_close_fence / try_close_inline).
|
|
210
|
-
def reset!
|
|
211
|
-
@in_fenced_block = false
|
|
212
|
-
@in_indented_block = false
|
|
213
|
-
@in_inline_code = false
|
|
214
|
-
end
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
end
|