markbridge 0.1.3 → 0.2.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/LICENSE.txt +1 -1
- data/lib/markbridge/ast/details.rb +24 -0
- data/lib/markbridge/ast/element.rb +63 -0
- data/lib/markbridge/ast.rb +1 -0
- data/lib/markbridge/conversion.rb +40 -0
- data/lib/markbridge/parse.rb +20 -0
- data/lib/markbridge/parsers/bbcode/handler_registry.rb +25 -2
- data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +13 -2
- data/lib/markbridge/parsers/html/handler_registry.rb +97 -17
- data/lib/markbridge/parsers/html/handlers/self_closing_handler.rb +26 -0
- data/lib/markbridge/parsers/html/handlers/span_handler.rb +74 -0
- data/lib/markbridge/parsers/html/parser.rb +88 -18
- data/lib/markbridge/parsers/html.rb +2 -0
- data/lib/markbridge/parsers/media_wiki/inline_parser.rb +21 -8
- data/lib/markbridge/parsers/media_wiki/parser.rb +13 -5
- data/lib/markbridge/parsers/text_formatter/handler_registry.rb +27 -4
- data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +1 -1
- data/lib/markbridge/parsers/text_formatter/handlers/attribute_handler.rb +1 -1
- data/lib/markbridge/parsers/text_formatter/handlers/base_handler.rb +1 -1
- data/lib/markbridge/parsers/text_formatter/handlers/code_handler.rb +1 -1
- data/lib/markbridge/parsers/text_formatter/handlers/email_handler.rb +1 -1
- data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +1 -1
- data/lib/markbridge/parsers/text_formatter/handlers/list_handler.rb +1 -1
- data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +1 -1
- data/lib/markbridge/parsers/text_formatter/handlers/simple_handler.rb +1 -1
- data/lib/markbridge/parsers/text_formatter/handlers/table_cell_handler.rb +1 -1
- data/lib/markbridge/parsers/text_formatter/handlers/url_handler.rb +1 -1
- data/lib/markbridge/parsers/text_formatter/parser.rb +17 -3
- data/lib/markbridge/renderers/discourse/identity_escaper.rb +37 -0
- data/lib/markbridge/renderers/discourse/markdown_escaper.rb +83 -8
- data/lib/markbridge/renderers/discourse/postprocessor.rb +53 -0
- data/lib/markbridge/renderers/discourse/render_context.rb +14 -40
- data/lib/markbridge/renderers/discourse/renderer.rb +15 -5
- data/lib/markbridge/renderers/discourse/rendering_interface.rb +4 -3
- data/lib/markbridge/renderers/discourse/tag_library.rb +42 -2
- data/lib/markbridge/renderers/discourse/tags/align_tag.rb +2 -2
- data/lib/markbridge/renderers/discourse/tags/code_tag.rb +5 -3
- data/lib/markbridge/renderers/discourse/tags/details_tag.rb +46 -0
- data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +1 -1
- data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +5 -2
- data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +4 -3
- data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +13 -0
- data/lib/markbridge/renderers/discourse.rb +3 -0
- data/lib/markbridge/version.rb +1 -1
- data/lib/markbridge.rb +274 -111
- metadata +9 -11
- data/lib/markbridge/configuration.rb +0 -11
- 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
|
@@ -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
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Markbridge
|
|
4
|
-
module Processors
|
|
5
|
-
module DiscourseMarkdown
|
|
6
|
-
module Detectors
|
|
7
|
-
# Result of a successful detection
|
|
8
|
-
# @attr_reader start_pos [Integer] start position in input
|
|
9
|
-
# @attr_reader end_pos [Integer] end position in input (exclusive)
|
|
10
|
-
# @attr_reader node [AST::Node] the AST node representing the detected construct
|
|
11
|
-
Match = Data.define(:start_pos, :end_pos, :node)
|
|
12
|
-
|
|
13
|
-
# Base class for construct detectors.
|
|
14
|
-
# Subclasses implement detection logic for specific constructs
|
|
15
|
-
# (mentions, polls, events, uploads).
|
|
16
|
-
#
|
|
17
|
-
# @abstract Subclass and implement {#detect}
|
|
18
|
-
class Base
|
|
19
|
-
# Attempt to detect a construct at the given position.
|
|
20
|
-
#
|
|
21
|
-
# @param input [String] the full input string
|
|
22
|
-
# @param pos [Integer] current position to check
|
|
23
|
-
# @return [Match, nil] match result or nil if no match
|
|
24
|
-
def detect(input, pos)
|
|
25
|
-
raise NotImplementedError, "#{self.class} must implement #detect"
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
# Helper to check if position is at a word boundary (for mentions, etc.)
|
|
31
|
-
# @param input [String] the input string
|
|
32
|
-
# @param pos [Integer] position to check
|
|
33
|
-
# @return [Boolean] true if at word boundary
|
|
34
|
-
def word_boundary?(input, pos)
|
|
35
|
-
return true if pos == 0
|
|
36
|
-
|
|
37
|
-
prev_char = input[pos - 1]
|
|
38
|
-
!prev_char.match?(/\w/)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
WORD_PATTERN = /\A[\w\-]*/
|
|
42
|
-
private_constant :WORD_PATTERN
|
|
43
|
-
|
|
44
|
-
# Helper to extract a word starting at position. Caller must ensure
|
|
45
|
-
# pos is within input bounds (`pos <= input.length`).
|
|
46
|
-
# @param input [String] the input string
|
|
47
|
-
# @param pos [Integer] starting position
|
|
48
|
-
# @return [String] the word (may be empty)
|
|
49
|
-
def extract_word(input, pos)
|
|
50
|
-
input[pos..].match(WORD_PATTERN)[0]
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Parse key="value" or key='value' attribute pairs from a string.
|
|
54
|
-
# @param attr_string [String] the attribute string to parse
|
|
55
|
-
# @return [Hash<String, String>] parsed attributes with downcased keys
|
|
56
|
-
def parse_attributes(attr_string)
|
|
57
|
-
attr_string.scan(/(\w+)=["']([^"']*)["']/).to_h.transform_keys(&:downcase)
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Markbridge
|
|
4
|
-
module Processors
|
|
5
|
-
module DiscourseMarkdown
|
|
6
|
-
module Detectors
|
|
7
|
-
# Detects Discourse event blocks [event]...[/event].
|
|
8
|
-
#
|
|
9
|
-
# @example
|
|
10
|
-
# detector = Event.new
|
|
11
|
-
# input = '[event name="Meeting" start="2025-12-15 14:00"][/event]'
|
|
12
|
-
# match = detector.detect(input, 0)
|
|
13
|
-
# match.node.name # => "Meeting"
|
|
14
|
-
class Event < Base
|
|
15
|
-
OPEN_TAG_PATTERN = /\[event(?<attrs>[^\]]*)\]/i
|
|
16
|
-
CLOSE_TAG_PATTERN = %r{\[/event\]}i
|
|
17
|
-
|
|
18
|
-
# Attempt to detect an event at the given position.
|
|
19
|
-
#
|
|
20
|
-
# @param input [String] the full input string
|
|
21
|
-
# @param pos [Integer] current position to check
|
|
22
|
-
# @return [Match, nil] match result or nil if no match
|
|
23
|
-
def detect(input, pos)
|
|
24
|
-
remaining = input[pos..]
|
|
25
|
-
open_match = OPEN_TAG_PATTERN.match(remaining)
|
|
26
|
-
return nil unless open_match&.begin(0)&.zero?
|
|
27
|
-
|
|
28
|
-
# Find closing tag. The opening tag pattern forbids `]` between
|
|
29
|
-
# `[event` and its closing `]`, so `[/event]` cannot appear inside
|
|
30
|
-
# the opening tag - no need to skip past it.
|
|
31
|
-
close_match = CLOSE_TAG_PATTERN.match(remaining)
|
|
32
|
-
return nil unless close_match
|
|
33
|
-
|
|
34
|
-
# Extract raw content
|
|
35
|
-
end_pos = pos + close_match.end(0)
|
|
36
|
-
raw = input[pos...end_pos]
|
|
37
|
-
|
|
38
|
-
# Parse attributes from opening tag
|
|
39
|
-
attrs = parse_attributes(open_match[:attrs])
|
|
40
|
-
|
|
41
|
-
# Validate required attributes
|
|
42
|
-
name = attrs["name"]
|
|
43
|
-
starts_at = attrs["start"]
|
|
44
|
-
return nil if name.nil? || starts_at.nil?
|
|
45
|
-
|
|
46
|
-
node =
|
|
47
|
-
AST::Event.new(
|
|
48
|
-
name:,
|
|
49
|
-
starts_at:,
|
|
50
|
-
ends_at: attrs["end"],
|
|
51
|
-
status: attrs["status"],
|
|
52
|
-
timezone: attrs["timezone"],
|
|
53
|
-
raw:,
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
Match.new(start_pos: pos, end_pos:, node:)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
private
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Markbridge
|
|
4
|
-
module Processors
|
|
5
|
-
module DiscourseMarkdown
|
|
6
|
-
module Detectors
|
|
7
|
-
# Detects user and group mentions (@username, @groupname).
|
|
8
|
-
#
|
|
9
|
-
# @example Basic usage
|
|
10
|
-
# detector = Mention.new
|
|
11
|
-
# match = detector.detect("Hello @gerhard!", 6)
|
|
12
|
-
# match.node.name # => "gerhard"
|
|
13
|
-
# match.node.type # => :user (default)
|
|
14
|
-
#
|
|
15
|
-
# @example With type resolver
|
|
16
|
-
# resolver = ->(name) { name == "Testers" ? :group : :user }
|
|
17
|
-
# detector = Mention.new(type_resolver: resolver)
|
|
18
|
-
# match = detector.detect("@Testers", 0)
|
|
19
|
-
# match.node.type # => :group
|
|
20
|
-
class Mention < Base
|
|
21
|
-
# @param type_resolver [#call, nil] callable that takes a name and returns :user or :group
|
|
22
|
-
def initialize(type_resolver: nil)
|
|
23
|
-
@type_resolver = type_resolver
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Attempt to detect a mention at the given position.
|
|
27
|
-
#
|
|
28
|
-
# @param input [String] the full input string
|
|
29
|
-
# @param pos [Integer] current position to check
|
|
30
|
-
# @return [Match, nil] match result or nil if no match
|
|
31
|
-
def detect(input, pos)
|
|
32
|
-
return nil unless input[pos] == "@"
|
|
33
|
-
return nil unless word_boundary?(input, pos)
|
|
34
|
-
|
|
35
|
-
# Extract the username/group name
|
|
36
|
-
name = extract_word(input, pos + 1)
|
|
37
|
-
return nil if name.empty?
|
|
38
|
-
|
|
39
|
-
end_pos = pos + 1 + name.length
|
|
40
|
-
type = resolve_type(name)
|
|
41
|
-
node = AST::Mention.new(name:, type:)
|
|
42
|
-
|
|
43
|
-
Match.new(start_pos: pos, end_pos:, node:)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def resolve_type(name)
|
|
49
|
-
return :user unless @type_resolver
|
|
50
|
-
|
|
51
|
-
@type_resolver.call(name) || :user
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Markbridge
|
|
4
|
-
module Processors
|
|
5
|
-
module DiscourseMarkdown
|
|
6
|
-
module Detectors
|
|
7
|
-
# Detects Discourse poll blocks [poll]...[/poll].
|
|
8
|
-
#
|
|
9
|
-
# @example
|
|
10
|
-
# detector = Poll.new
|
|
11
|
-
# input = "[poll type=\"regular\"]\n* A\n* B\n[/poll]"
|
|
12
|
-
# match = detector.detect(input, 0)
|
|
13
|
-
# match.node.type # => "regular"
|
|
14
|
-
class Poll < Base
|
|
15
|
-
TAG_PATTERN = %r{\A\[poll(?<attrs>[^\]]*)\](?<content>.*?)\[/poll\]}im
|
|
16
|
-
|
|
17
|
-
# Attempt to detect a poll at the given position.
|
|
18
|
-
#
|
|
19
|
-
# @param input [String] the full input string
|
|
20
|
-
# @param pos [Integer] current position to check
|
|
21
|
-
# @return [Match, nil] match result or nil if no match
|
|
22
|
-
def detect(input, pos)
|
|
23
|
-
match = TAG_PATTERN.match(input[pos..])
|
|
24
|
-
return nil unless match
|
|
25
|
-
|
|
26
|
-
attrs = parse_attributes(match[:attrs])
|
|
27
|
-
node =
|
|
28
|
-
AST::Poll.new(
|
|
29
|
-
name: attrs["name"] || "poll",
|
|
30
|
-
type: attrs["type"],
|
|
31
|
-
results: attrs["results"],
|
|
32
|
-
public: attrs["public"] == "true",
|
|
33
|
-
chart_type: attrs["charttype"],
|
|
34
|
-
options: extract_options(match[:content]),
|
|
35
|
-
raw: match[0],
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
Match.new(start_pos: pos, end_pos: pos + match.end(0), node:)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
private
|
|
42
|
-
|
|
43
|
-
OPTION_PATTERN = /\A\s*(?:\*\s|-\s|\d+\.\s+)(?<value>.+?)\s*\z/
|
|
44
|
-
|
|
45
|
-
def extract_options(content)
|
|
46
|
-
content.each_line.filter_map { |line| OPTION_PATTERN.match(line)&.[](:value) }
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Markbridge
|
|
4
|
-
module Processors
|
|
5
|
-
module DiscourseMarkdown
|
|
6
|
-
module Detectors
|
|
7
|
-
# Detects Discourse upload references using upload:// URLs.
|
|
8
|
-
#
|
|
9
|
-
# Supports two formats:
|
|
10
|
-
# - Images: 
|
|
11
|
-
# - Attachments: [filename|attachment](upload://sha1.ext) (size)
|
|
12
|
-
#
|
|
13
|
-
# @example Image
|
|
14
|
-
# detector = Upload.new
|
|
15
|
-
# input = ""
|
|
16
|
-
# match = detector.detect(input, 0)
|
|
17
|
-
# match.node.type # => :image
|
|
18
|
-
#
|
|
19
|
-
# @example Attachment
|
|
20
|
-
# detector = Upload.new
|
|
21
|
-
# input = "[doc.pdf|attachment](upload://xyz789.pdf) (1.2 MB)"
|
|
22
|
-
# match = detector.detect(input, 0)
|
|
23
|
-
# match.node.type # => :attachment
|
|
24
|
-
class Upload < Base
|
|
25
|
-
# Image: 
|
|
26
|
-
IMAGE_PATTERN =
|
|
27
|
-
%r{\A!\[(?<alt>[^|\]]*)(?:\|(?<dimensions>[^\]]*))?\]\(upload://(?<url>[^)]+)\)}
|
|
28
|
-
|
|
29
|
-
# Attachment: [filename|attachment](upload://sha1.ext) (size)
|
|
30
|
-
ATTACHMENT_PATTERN =
|
|
31
|
-
%r{
|
|
32
|
-
\A
|
|
33
|
-
\[(?<filename>[^|\]]*)\|attachment\]
|
|
34
|
-
\(upload://(?<url>[^)]+)\)
|
|
35
|
-
(?:\s*\((?<size>[^)]+)\))?
|
|
36
|
-
}xi
|
|
37
|
-
|
|
38
|
-
# Attempt to detect an upload at the given position.
|
|
39
|
-
#
|
|
40
|
-
# @param input [String] the full input string
|
|
41
|
-
# @param pos [Integer] current position to check
|
|
42
|
-
# @return [Match, nil] match result or nil if no match
|
|
43
|
-
def detect(input, pos)
|
|
44
|
-
remaining = input[pos..]
|
|
45
|
-
case input[pos]
|
|
46
|
-
when "!"
|
|
47
|
-
detect_image(remaining, pos)
|
|
48
|
-
when "["
|
|
49
|
-
detect_attachment(remaining, pos)
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
private
|
|
54
|
-
|
|
55
|
-
def detect_image(remaining, pos)
|
|
56
|
-
match = IMAGE_PATTERN.match(remaining)
|
|
57
|
-
return nil unless match
|
|
58
|
-
|
|
59
|
-
sha1, filename = parse_upload_url(match[:url])
|
|
60
|
-
alt = match[:alt]
|
|
61
|
-
alt = nil if alt.empty?
|
|
62
|
-
|
|
63
|
-
# `type: :image` is omitted because it is AST::Upload's default -
|
|
64
|
-
# passing it explicitly was an equivalent-mutation surface.
|
|
65
|
-
node =
|
|
66
|
-
AST::Upload.new(sha1:, filename:, alt:, dimensions: match[:dimensions], raw: match[0])
|
|
67
|
-
|
|
68
|
-
Match.new(start_pos: pos, end_pos: pos + match[0].length, node:)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def detect_attachment(remaining, pos)
|
|
72
|
-
match = ATTACHMENT_PATTERN.match(remaining)
|
|
73
|
-
return nil unless match
|
|
74
|
-
|
|
75
|
-
sha1, = parse_upload_url(match[:url])
|
|
76
|
-
|
|
77
|
-
node =
|
|
78
|
-
AST::Upload.new(
|
|
79
|
-
sha1:,
|
|
80
|
-
filename: match[:filename],
|
|
81
|
-
type: :attachment,
|
|
82
|
-
size: match[:size],
|
|
83
|
-
raw: match[0],
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
Match.new(start_pos: pos, end_pos: pos + match[0].length, node:)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# URL format: sha1.ext or just sha1. Returns [sha1, filename-or-nil].
|
|
90
|
-
def parse_upload_url(url_part)
|
|
91
|
-
sha1, _, ext = url_part.partition(".")
|
|
92
|
-
[sha1, ext.empty? ? nil : url_part]
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|