markbridge 0.1.0 → 0.1.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/table.rb +67 -0
- data/lib/markbridge/ast.rb +1 -0
- data/lib/markbridge/parsers/bbcode/handler_registry.rb +5 -0
- data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +13 -11
- data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +40 -33
- data/lib/markbridge/parsers/bbcode/handlers/table_cell_handler.rb +26 -0
- data/lib/markbridge/parsers/bbcode/handlers/table_handler.rb +32 -0
- data/lib/markbridge/parsers/bbcode/handlers/table_row_handler.rb +35 -0
- data/lib/markbridge/parsers/bbcode/parser.rb +1 -1
- data/lib/markbridge/parsers/bbcode.rb +3 -0
- data/lib/markbridge/parsers/html/handler_registry.rb +5 -0
- data/lib/markbridge/parsers/html/handlers/base_handler.rb +0 -1
- data/lib/markbridge/parsers/html/handlers/table_cell_handler.rb +24 -0
- data/lib/markbridge/parsers/html/handlers/table_handler.rb +24 -0
- data/lib/markbridge/parsers/html/handlers/table_row_handler.rb +24 -0
- data/lib/markbridge/parsers/html/parser.rb +13 -2
- data/lib/markbridge/parsers/html.rb +3 -0
- data/lib/markbridge/parsers/media_wiki/inline_parser.rb +105 -130
- data/lib/markbridge/parsers/media_wiki/parser.rb +128 -0
- data/lib/markbridge/parsers/text_formatter/handler_registry.rb +6 -0
- data/lib/markbridge/parsers/text_formatter/handlers/table_cell_handler.rb +26 -0
- data/lib/markbridge/parsers/text_formatter.rb +1 -0
- data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +96 -84
- data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +12 -0
- data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +0 -10
- data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +0 -10
- data/lib/markbridge/processors/discourse_markdown/scanner.rb +19 -16
- data/lib/markbridge/renderers/discourse/markdown_escaper.rb +237 -180
- data/lib/markbridge/renderers/discourse/renderer.rb +1 -0
- data/lib/markbridge/renderers/discourse/tags/align_tag.rb +1 -1
- data/lib/markbridge/renderers/discourse/tags/attachment_tag.rb +1 -1
- data/lib/markbridge/renderers/discourse/tags/code_tag.rb +2 -1
- data/lib/markbridge/renderers/discourse/tags/event_tag.rb +3 -5
- data/lib/markbridge/renderers/discourse/tags/image_tag.rb +1 -1
- data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +1 -1
- data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +3 -5
- data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +15 -11
- data/lib/markbridge/renderers/discourse/tags/table_cell_tag.rb +18 -0
- data/lib/markbridge/renderers/discourse/tags/table_row_tag.rb +18 -0
- data/lib/markbridge/renderers/discourse/tags/table_tag.rb +124 -0
- data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +1 -1
- data/lib/markbridge/renderers/discourse.rb +3 -0
- data/lib/markbridge/version.rb +1 -1
- data/lib/markbridge.rb +20 -55
- metadata +12 -1
|
@@ -36,6 +36,9 @@ module Markbridge
|
|
|
36
36
|
# breaks disabled by default.
|
|
37
37
|
def initialize(escape_hard_line_breaks: false)
|
|
38
38
|
@escape_hard_line_breaks = escape_hard_line_breaks
|
|
39
|
+
@inline_content = nil
|
|
40
|
+
@inline_result = nil
|
|
41
|
+
@inline_len = 0
|
|
39
42
|
end
|
|
40
43
|
|
|
41
44
|
# Fast-path check: any character that might need escaping
|
|
@@ -137,7 +140,8 @@ module Markbridge
|
|
|
137
140
|
return escape_line(lines[0], false) if lines.size == 1
|
|
138
141
|
|
|
139
142
|
# Pre-allocate result buffer
|
|
140
|
-
|
|
143
|
+
bytesize = text.bytesize
|
|
144
|
+
result = String.new(capacity: bytesize + bytesize / 3, encoding: text.encoding)
|
|
141
145
|
prev_was_paragraph = false
|
|
142
146
|
first = true
|
|
143
147
|
|
|
@@ -160,14 +164,16 @@ module Markbridge
|
|
|
160
164
|
return escape_indented_code(line) if INDENTED_CODE.match?(line)
|
|
161
165
|
|
|
162
166
|
# Extract 0-3 space indent
|
|
167
|
+
line_length = line.length
|
|
163
168
|
indent_len = 0
|
|
164
|
-
while indent_len < 3 && indent_len <
|
|
169
|
+
while indent_len < 3 && indent_len < line_length && line.getbyte(indent_len) == SPACE
|
|
165
170
|
indent_len += 1
|
|
166
171
|
end
|
|
167
172
|
|
|
168
|
-
return line if indent_len >=
|
|
173
|
+
return line if indent_len >= line_length
|
|
169
174
|
|
|
170
|
-
|
|
175
|
+
has_indent = indent_len > 0
|
|
176
|
+
content = has_indent ? line[indent_len..] : line
|
|
171
177
|
|
|
172
178
|
# Apply block-level escaping (which may also do inline escaping)
|
|
173
179
|
escaped, skip_inline = escape_block_level(content, prev_was_paragraph)
|
|
@@ -176,12 +182,12 @@ module Markbridge
|
|
|
176
182
|
escaped = escape_inline(escaped) unless skip_inline
|
|
177
183
|
|
|
178
184
|
# Prepend indent if present, preserve encoding
|
|
179
|
-
if
|
|
180
|
-
|
|
185
|
+
if has_indent
|
|
186
|
+
encoding = line.encoding
|
|
187
|
+
result = String.new(encoding:)
|
|
181
188
|
result << line[0, indent_len] << escaped
|
|
182
189
|
result
|
|
183
190
|
else
|
|
184
|
-
# Preserve original encoding
|
|
185
191
|
escaped.is_a?(String) ? escaped.force_encoding(line.encoding) : escaped
|
|
186
192
|
end
|
|
187
193
|
end
|
|
@@ -197,21 +203,22 @@ module Markbridge
|
|
|
197
203
|
# - Content doesn't start at valid block position (no lists, headings, etc.)
|
|
198
204
|
# - Visual indentation is preserved (NBSP renders as space)
|
|
199
205
|
# We still escape inline content since it's no longer protected.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
206
|
+
line_length = line.length
|
|
207
|
+
ws_end = 0
|
|
208
|
+
while ws_end < line_length
|
|
209
|
+
byte = line.getbyte(ws_end)
|
|
210
|
+
break if byte != SPACE && byte != TAB
|
|
211
|
+
ws_end += 1
|
|
205
212
|
end
|
|
206
213
|
|
|
207
|
-
return line if
|
|
208
|
-
return line if
|
|
214
|
+
return line if ws_end == 0 # No leading whitespace (shouldn't happen, but safe)
|
|
215
|
+
return line if ws_end >= line_length # Whitespace-only line
|
|
209
216
|
|
|
210
217
|
# Convert leading whitespace to NBSP (tab = 4 NBSP for visual consistency)
|
|
211
218
|
nbsp_indent = String.new(encoding: line.encoding)
|
|
212
|
-
line[0,
|
|
219
|
+
line[0, ws_end].each_char { |char| nbsp_indent << (char == "\t" ? (NBSP * 4) : NBSP) }
|
|
213
220
|
|
|
214
|
-
content = line[
|
|
221
|
+
content = line[ws_end..]
|
|
215
222
|
"#{nbsp_indent}#{escape_inline(content)}"
|
|
216
223
|
end
|
|
217
224
|
|
|
@@ -220,22 +227,15 @@ module Markbridge
|
|
|
220
227
|
|
|
221
228
|
case first_byte
|
|
222
229
|
when HASH
|
|
223
|
-
return
|
|
230
|
+
return escape_first_char_inline(content, "\\#") if ATX_HEADING.match?(content)
|
|
224
231
|
when GT
|
|
225
|
-
return
|
|
232
|
+
return escape_first_char_inline(content, "\\>")
|
|
226
233
|
when DASH
|
|
227
|
-
|
|
228
|
-
(prev_was_paragraph && SETEXT_UNDERLINE_DASH.match?(content))
|
|
229
|
-
return escape_all_chars(content, DASH, "\\-"), true
|
|
230
|
-
end
|
|
231
|
-
return "\\-#{escape_inline(content[1..])}", true if BULLET_LIST.match?(content)
|
|
234
|
+
return escape_block_dash(content, prev_was_paragraph)
|
|
232
235
|
when PLUS
|
|
233
|
-
return
|
|
236
|
+
return escape_first_char_inline(content, "\\+") if BULLET_LIST.match?(content)
|
|
234
237
|
when STAR
|
|
235
|
-
|
|
236
|
-
return escape_all_chars(content, STAR, "\\*"), true
|
|
237
|
-
end
|
|
238
|
-
return "\\*#{escape_inline(content[1..])}", true if BULLET_LIST.match?(content)
|
|
238
|
+
return escape_block_star(content)
|
|
239
239
|
when UNDERSCORE
|
|
240
240
|
if THEMATIC_BREAK_UNDERSCORE.match?(content)
|
|
241
241
|
return escape_all_chars(content, UNDERSCORE, "\\_"), true
|
|
@@ -246,162 +246,214 @@ module Markbridge
|
|
|
246
246
|
end
|
|
247
247
|
when BACKTICK
|
|
248
248
|
if FENCED_CODE_BACKTICK.match?(content)
|
|
249
|
-
# Escape ALL backticks to prevent code span interpretation
|
|
250
|
-
# e.g., ```` becomes \`\`\`\` not \```` (which would be \` + ```)
|
|
251
249
|
return escape_all_chars(content, BACKTICK, "\\`"), true
|
|
252
250
|
end
|
|
253
251
|
when TILDE
|
|
254
252
|
return "\\#{content}", true if FENCED_CODE_TILDE.match?(content)
|
|
255
253
|
when BRACKET_OPEN
|
|
256
|
-
return "\\[
|
|
254
|
+
return escape_first_char_inline(content, "\\[")
|
|
257
255
|
when PIPE
|
|
258
|
-
return
|
|
256
|
+
return escape_first_char_inline(content, "\\|")
|
|
259
257
|
when DIGIT_0..DIGIT_9
|
|
260
|
-
|
|
261
|
-
prefix = m[1]
|
|
262
|
-
delim = m[2]
|
|
263
|
-
rest = content[m[0].length..]
|
|
264
|
-
return "#{prefix}\\#{delim}#{escape_inline(rest)}", true
|
|
265
|
-
end
|
|
258
|
+
return escape_block_ordered_list(content)
|
|
266
259
|
end
|
|
267
260
|
|
|
268
261
|
[content, false]
|
|
269
262
|
end
|
|
270
263
|
|
|
264
|
+
# Escape the first character and inline-escape the rest.
|
|
265
|
+
def escape_first_char_inline(content, escaped_char)
|
|
266
|
+
["#{escaped_char}#{escape_inline(content[1..])}", true]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def escape_block_dash(content, prev_was_paragraph)
|
|
270
|
+
if THEMATIC_BREAK_DASH.match?(content) ||
|
|
271
|
+
(prev_was_paragraph && SETEXT_UNDERLINE_DASH.match?(content))
|
|
272
|
+
return escape_all_chars(content, DASH, "\\-"), true
|
|
273
|
+
end
|
|
274
|
+
return escape_first_char_inline(content, "\\-") if BULLET_LIST.match?(content)
|
|
275
|
+
[content, false]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def escape_block_star(content)
|
|
279
|
+
return escape_all_chars(content, STAR, "\\*"), true if THEMATIC_BREAK_STAR.match?(content)
|
|
280
|
+
return escape_first_char_inline(content, "\\*") if BULLET_LIST.match?(content)
|
|
281
|
+
[content, false]
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def escape_block_ordered_list(content)
|
|
285
|
+
if (match = ORDERED_LIST.match(content))
|
|
286
|
+
rest = content[match[0].length..]
|
|
287
|
+
return "#{match[1]}\\#{match[2]}#{escape_inline(rest)}", true
|
|
288
|
+
end
|
|
289
|
+
[content, false]
|
|
290
|
+
end
|
|
291
|
+
|
|
271
292
|
def escape_all_chars(str, byte_val, escaped)
|
|
272
293
|
result = String.new(capacity: str.bytesize * 2, encoding: str.encoding)
|
|
273
|
-
str.each_byte do |
|
|
274
|
-
if
|
|
294
|
+
str.each_byte do |byte|
|
|
295
|
+
if byte == byte_val
|
|
275
296
|
result << escaped
|
|
276
297
|
else
|
|
277
|
-
result <<
|
|
298
|
+
result << byte
|
|
278
299
|
end
|
|
279
300
|
end
|
|
280
301
|
result
|
|
281
302
|
end
|
|
282
303
|
|
|
283
304
|
def escape_inline(content)
|
|
284
|
-
# Quick check - if no special chars, return as-is
|
|
285
305
|
return content unless INLINE_SPECIAL.match?(content)
|
|
286
306
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
b = content.getbyte(i)
|
|
297
|
-
|
|
298
|
-
case b
|
|
299
|
-
when BACKSLASH # \
|
|
300
|
-
if i + 1 < len && ascii_punctuation?(content.getbyte(i + 1))
|
|
301
|
-
# Escape the backslash, but let the next char be processed on its own
|
|
302
|
-
result << "\\\\"
|
|
303
|
-
i += 1
|
|
304
|
-
elsif i + 1 == len # backslash at end (hard break)
|
|
305
|
-
result << "\\\\"
|
|
306
|
-
i += 1
|
|
307
|
-
else
|
|
308
|
-
result << b
|
|
309
|
-
i += 1
|
|
310
|
-
end
|
|
311
|
-
when DASH # -
|
|
312
|
-
if i + 1 < len && content.getbyte(i + 1) == DASH
|
|
313
|
-
# Consecutive dashes - escape each for Discourse ndash prevention
|
|
314
|
-
while i < len && content.getbyte(i) == DASH
|
|
315
|
-
result << "\\-"
|
|
316
|
-
i += 1
|
|
317
|
-
end
|
|
318
|
-
else
|
|
319
|
-
result << b
|
|
320
|
-
i += 1
|
|
321
|
-
end
|
|
322
|
-
when TILDE # ~
|
|
323
|
-
if i + 1 < len && content.getbyte(i + 1) == TILDE
|
|
324
|
-
result << "\\~\\~"
|
|
325
|
-
i += 2
|
|
326
|
-
else
|
|
327
|
-
result << b
|
|
328
|
-
i += 1
|
|
329
|
-
end
|
|
330
|
-
when STAR # *
|
|
331
|
-
while i < len && content.getbyte(i) == STAR
|
|
332
|
-
result << "\\*"
|
|
333
|
-
i += 1
|
|
334
|
-
end
|
|
335
|
-
when UNDERSCORE # _
|
|
336
|
-
while i < len && content.getbyte(i) == UNDERSCORE
|
|
337
|
-
result << "\\_"
|
|
338
|
-
i += 1
|
|
339
|
-
end
|
|
340
|
-
when BACKTICK # `
|
|
341
|
-
while i < len && content.getbyte(i) == BACKTICK
|
|
342
|
-
result << "\\`"
|
|
343
|
-
i += 1
|
|
344
|
-
end
|
|
345
|
-
when BANG # !
|
|
346
|
-
if i + 1 < len && content.getbyte(i + 1) == BRACKET_OPEN
|
|
347
|
-
result << "\\!\\["
|
|
348
|
-
i += 2
|
|
349
|
-
else
|
|
350
|
-
result << b
|
|
351
|
-
i += 1
|
|
352
|
-
end
|
|
353
|
-
when BRACKET_OPEN # [
|
|
354
|
-
result << "\\["
|
|
355
|
-
i += 1
|
|
356
|
-
when PIPE # |
|
|
357
|
-
result << "\\|"
|
|
358
|
-
i += 1
|
|
359
|
-
when LT # <
|
|
360
|
-
remaining = content.byteslice(i, len - i)
|
|
361
|
-
# Check for autolinks first - pass through entirely unchanged
|
|
362
|
-
if (m = AUTOLINK.match(remaining))
|
|
363
|
-
result << m[0]
|
|
364
|
-
i += m[0].bytesize
|
|
365
|
-
# Escape complete HTML tags (include tag in output for readability)
|
|
366
|
-
# Also escape backticks inside the tag to prevent code span interpretation
|
|
367
|
-
elsif (m = HTML_TAG.match(remaining))
|
|
368
|
-
escaped_tag = m[0].gsub("`") { "\\`" }
|
|
369
|
-
result << "\\" << escaped_tag
|
|
370
|
-
i += m[0].bytesize
|
|
371
|
-
# Escape HTML-like constructs: processing instructions, SGML declarations,
|
|
372
|
-
# and potential tag starts (including multi-line and custom elements)
|
|
373
|
-
elsif HTML_TAG_START.match?(remaining)
|
|
374
|
-
result << "\\<"
|
|
375
|
-
i += 1
|
|
376
|
-
else
|
|
377
|
-
# Not HTML-like (comparison operator, etc.)
|
|
378
|
-
result << b
|
|
379
|
-
i += 1
|
|
380
|
-
end
|
|
381
|
-
when AMP # &
|
|
382
|
-
remaining = content.byteslice(i, len - i)
|
|
383
|
-
if (m = ENTITY_REF.match(remaining))
|
|
384
|
-
result << "\\" << m[0]
|
|
385
|
-
i += m[0].bytesize
|
|
386
|
-
else
|
|
387
|
-
result << b
|
|
388
|
-
i += 1
|
|
389
|
-
end
|
|
390
|
-
else
|
|
391
|
-
# Regular character - handle multi-byte UTF-8
|
|
392
|
-
if b < 128
|
|
393
|
-
result << b
|
|
394
|
-
i += 1
|
|
395
|
-
else
|
|
396
|
-
char_len = utf8_char_length(b)
|
|
397
|
-
end_i = [i + char_len, len].min
|
|
398
|
-
result << content.byteslice(i, end_i - i)
|
|
399
|
-
i = end_i
|
|
400
|
-
end
|
|
401
|
-
end
|
|
307
|
+
bytesize = content.bytesize
|
|
308
|
+
@inline_content = content
|
|
309
|
+
@inline_result = String.new(capacity: bytesize + bytesize / 4, encoding: content.encoding)
|
|
310
|
+
@inline_len = bytesize
|
|
311
|
+
pos = 0
|
|
312
|
+
|
|
313
|
+
while pos < @inline_len
|
|
314
|
+
byte = @inline_content.getbyte(pos)
|
|
315
|
+
pos = dispatch_inline_byte(byte, pos)
|
|
402
316
|
end
|
|
403
317
|
|
|
404
|
-
|
|
318
|
+
@inline_result
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def dispatch_inline_byte(byte, pos)
|
|
322
|
+
case byte
|
|
323
|
+
when BACKSLASH
|
|
324
|
+
escape_backslash(pos)
|
|
325
|
+
when DASH
|
|
326
|
+
escape_consecutive_pair(pos, DASH, "\\-")
|
|
327
|
+
when TILDE
|
|
328
|
+
escape_tilde_pair(pos)
|
|
329
|
+
when STAR
|
|
330
|
+
escape_char_run(pos, STAR, "\\*")
|
|
331
|
+
when UNDERSCORE
|
|
332
|
+
escape_char_run(pos, UNDERSCORE, "\\_")
|
|
333
|
+
when BACKTICK
|
|
334
|
+
escape_char_run(pos, BACKTICK, "\\`")
|
|
335
|
+
when BANG
|
|
336
|
+
escape_image_open(pos)
|
|
337
|
+
when BRACKET_OPEN
|
|
338
|
+
@inline_result << "\\["
|
|
339
|
+
pos + 1
|
|
340
|
+
when PIPE
|
|
341
|
+
@inline_result << "\\|"
|
|
342
|
+
pos + 1
|
|
343
|
+
when LT
|
|
344
|
+
escape_lt(pos)
|
|
345
|
+
when AMP
|
|
346
|
+
escape_amp(pos)
|
|
347
|
+
else
|
|
348
|
+
escape_regular_char(byte, pos)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Escape backslash before ASCII punctuation or at end of content.
|
|
353
|
+
def escape_backslash(pos)
|
|
354
|
+
next_pos = pos + 1
|
|
355
|
+
if next_pos >= @inline_len || ascii_punctuation?(@inline_content.getbyte(next_pos))
|
|
356
|
+
@inline_result << "\\\\"
|
|
357
|
+
else
|
|
358
|
+
@inline_result << BACKSLASH
|
|
359
|
+
end
|
|
360
|
+
next_pos
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Escape consecutive pairs (e.g., -- for ndash prevention) or pass single through.
|
|
364
|
+
def escape_consecutive_pair(pos, byte_val, escaped)
|
|
365
|
+
next_pos = pos + 1
|
|
366
|
+
if next_pos < @inline_len && @inline_content.getbyte(next_pos) == byte_val
|
|
367
|
+
escape_char_run(pos, byte_val, escaped)
|
|
368
|
+
else
|
|
369
|
+
@inline_result << byte_val
|
|
370
|
+
next_pos
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Escape ~~ pairs, pass single ~ through.
|
|
375
|
+
def escape_tilde_pair(pos)
|
|
376
|
+
next_pos = pos + 1
|
|
377
|
+
if next_pos < @inline_len && @inline_content.getbyte(next_pos) == TILDE
|
|
378
|
+
@inline_result << "\\~\\~"
|
|
379
|
+
pos + 2
|
|
380
|
+
else
|
|
381
|
+
@inline_result << TILDE
|
|
382
|
+
next_pos
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Escape all consecutive occurrences of a repeatable character (*, _, `).
|
|
387
|
+
def escape_char_run(pos, byte_val, escaped)
|
|
388
|
+
while pos < @inline_len && @inline_content.getbyte(pos) == byte_val
|
|
389
|
+
@inline_result << escaped
|
|
390
|
+
pos += 1
|
|
391
|
+
end
|
|
392
|
+
pos
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Escape ![ image syntax, pass standalone ! through.
|
|
396
|
+
def escape_image_open(pos)
|
|
397
|
+
next_pos = pos + 1
|
|
398
|
+
if next_pos < @inline_len && @inline_content.getbyte(next_pos) == BRACKET_OPEN
|
|
399
|
+
@inline_result << "\\!\\["
|
|
400
|
+
pos + 2
|
|
401
|
+
else
|
|
402
|
+
@inline_result << BANG
|
|
403
|
+
next_pos
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Handle < for autolinks (preserved), HTML tags (escaped), and other constructs.
|
|
408
|
+
def escape_lt(pos)
|
|
409
|
+
remaining = remaining_content(pos)
|
|
410
|
+
|
|
411
|
+
if (match = AUTOLINK.match(remaining))
|
|
412
|
+
matched = match[0]
|
|
413
|
+
@inline_result << matched
|
|
414
|
+
pos + matched.bytesize
|
|
415
|
+
elsif (match = HTML_TAG.match(remaining))
|
|
416
|
+
matched = match[0]
|
|
417
|
+
@inline_result << "\\" << matched.gsub("`") { "\\`" }
|
|
418
|
+
pos + matched.bytesize
|
|
419
|
+
elsif HTML_TAG_START.match?(remaining)
|
|
420
|
+
@inline_result << "\\<"
|
|
421
|
+
pos + 1
|
|
422
|
+
else
|
|
423
|
+
@inline_result << LT
|
|
424
|
+
pos + 1
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Handle & for entity references.
|
|
429
|
+
def escape_amp(pos)
|
|
430
|
+
remaining = remaining_content(pos)
|
|
431
|
+
|
|
432
|
+
if (match = ENTITY_REF.match(remaining))
|
|
433
|
+
matched = match[0]
|
|
434
|
+
@inline_result << "\\" << matched
|
|
435
|
+
pos + matched.bytesize
|
|
436
|
+
else
|
|
437
|
+
@inline_result << AMP
|
|
438
|
+
pos + 1
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def remaining_content(pos)
|
|
443
|
+
@inline_content.byteslice(pos, @inline_len - pos)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Handle regular characters including multi-byte UTF-8.
|
|
447
|
+
def escape_regular_char(byte, pos)
|
|
448
|
+
if byte < 128
|
|
449
|
+
@inline_result << byte
|
|
450
|
+
pos + 1
|
|
451
|
+
else
|
|
452
|
+
char_len = utf8_char_length(byte)
|
|
453
|
+
end_pos = [pos + char_len, @inline_len].min
|
|
454
|
+
@inline_result << @inline_content.byteslice(pos, end_pos - pos)
|
|
455
|
+
end_pos
|
|
456
|
+
end
|
|
405
457
|
end
|
|
406
458
|
|
|
407
459
|
def ascii_punctuation?(byte)
|
|
@@ -424,43 +476,48 @@ module Markbridge
|
|
|
424
476
|
def paragraph_line?(line)
|
|
425
477
|
return false if line.empty?
|
|
426
478
|
|
|
427
|
-
|
|
479
|
+
line_length = line.length
|
|
428
480
|
first_non_space = 0
|
|
429
|
-
while first_non_space <
|
|
481
|
+
while first_non_space < line_length && line.getbyte(first_non_space) == SPACE
|
|
430
482
|
first_non_space += 1
|
|
431
483
|
end
|
|
432
|
-
return false if first_non_space >=
|
|
484
|
+
return false if first_non_space >= line_length || line.getbyte(first_non_space) == TAB
|
|
433
485
|
|
|
434
|
-
# Check if this is a block construct
|
|
435
486
|
content = first_non_space <= 3 ? line[first_non_space..] : line
|
|
436
|
-
return false if content.nil? || content.empty?
|
|
437
487
|
|
|
488
|
+
# Lines starting with [ get escaped to \[, which IS paragraph content
|
|
489
|
+
# So setext headings CAN follow them
|
|
490
|
+
return true if content.getbyte(0) == BRACKET_OPEN
|
|
491
|
+
|
|
492
|
+
!block_construct?(content) && !INDENTED_CODE.match?(line)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Checks whether content starts with a block-level markdown construct.
|
|
496
|
+
# Used by both escape_block_level (to decide what to escape) and
|
|
497
|
+
# paragraph_line? (to decide if setext underlines can follow).
|
|
498
|
+
def block_construct?(content)
|
|
438
499
|
first_byte = content.getbyte(0)
|
|
439
500
|
|
|
440
501
|
case first_byte
|
|
441
502
|
when HASH
|
|
442
|
-
|
|
503
|
+
ATX_HEADING.match?(content)
|
|
443
504
|
when GT
|
|
444
|
-
|
|
505
|
+
true
|
|
445
506
|
when DASH, PLUS, STAR
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
507
|
+
BULLET_LIST.match?(content) ||
|
|
508
|
+
(first_byte == DASH && THEMATIC_BREAK_DASH.match?(content)) ||
|
|
509
|
+
(first_byte == STAR && THEMATIC_BREAK_STAR.match?(content))
|
|
449
510
|
when UNDERSCORE
|
|
450
|
-
|
|
451
|
-
when BACKTICK
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
when BRACKET_OPEN
|
|
456
|
-
# Lines starting with [ get escaped to \[, which IS paragraph content
|
|
457
|
-
# So setext headings CAN follow them
|
|
458
|
-
return true
|
|
511
|
+
THEMATIC_BREAK_UNDERSCORE.match?(content)
|
|
512
|
+
when BACKTICK
|
|
513
|
+
FENCED_CODE_BACKTICK.match?(content)
|
|
514
|
+
when TILDE
|
|
515
|
+
FENCED_CODE_TILDE.match?(content)
|
|
459
516
|
when DIGIT_0..DIGIT_9
|
|
460
|
-
|
|
517
|
+
ORDERED_LIST.match?(content)
|
|
518
|
+
else
|
|
519
|
+
false
|
|
461
520
|
end
|
|
462
|
-
|
|
463
|
-
!INDENTED_CODE.match?(line)
|
|
464
521
|
end
|
|
465
522
|
end
|
|
466
523
|
end
|
|
@@ -12,7 +12,7 @@ module Markbridge
|
|
|
12
12
|
content = interface.render_children(element, context: child_context)
|
|
13
13
|
|
|
14
14
|
if element.alignment
|
|
15
|
-
"<div align=\"#{element.alignment}\">#{content}</div
|
|
15
|
+
"<div align=\"#{element.alignment}\">#{content}</div>\n\n"
|
|
16
16
|
else
|
|
17
17
|
content
|
|
18
18
|
end
|
|
@@ -22,7 +22,7 @@ module Markbridge
|
|
|
22
22
|
# library = Markbridge::Renderers::Discourse::TagLibrary.default
|
|
23
23
|
# library.register(Markbridge::AST::Attachment, MyAttachmentTag.new)
|
|
24
24
|
class AttachmentTag < Tag
|
|
25
|
-
def render(element,
|
|
25
|
+
def render(element, _interface)
|
|
26
26
|
# Build metadata comment for downstream processing
|
|
27
27
|
metadata = build_metadata(element)
|
|
28
28
|
"<!-- ATTACHMENT: #{metadata} -->"
|
|
@@ -28,7 +28,8 @@ module Markbridge
|
|
|
28
28
|
fence = calculate_fence(content)
|
|
29
29
|
lang = language || ""
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
# Blank line keeps adjacent fences from merging.
|
|
32
|
+
"#{fence}#{lang}\n#{content}\n#{fence}\n\n"
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def calculate_fence(content)
|
|
@@ -18,11 +18,9 @@ module Markbridge
|
|
|
18
18
|
# end
|
|
19
19
|
# end
|
|
20
20
|
class EventTag < Tag
|
|
21
|
-
def render(element,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
build_event_bbcode(element)
|
|
21
|
+
def render(element, _interface)
|
|
22
|
+
body = element.raw || build_event_bbcode(element)
|
|
23
|
+
"#{body}\n\n"
|
|
26
24
|
end
|
|
27
25
|
|
|
28
26
|
private
|
|
@@ -7,7 +7,7 @@ module Markbridge
|
|
|
7
7
|
# Tag for rendering images
|
|
8
8
|
# Renders to Markdown image syntax with optional Discourse sizing
|
|
9
9
|
class ImageTag < Tag
|
|
10
|
-
def render(element,
|
|
10
|
+
def render(element, _interface)
|
|
11
11
|
src = element.src || ""
|
|
12
12
|
width = element.width
|
|
13
13
|
height = element.height
|
|
@@ -18,11 +18,9 @@ module Markbridge
|
|
|
18
18
|
# end
|
|
19
19
|
# end
|
|
20
20
|
class PollTag < Tag
|
|
21
|
-
def render(element,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
build_poll_bbcode(element)
|
|
21
|
+
def render(element, _interface)
|
|
22
|
+
body = element.raw || build_poll_bbcode(element)
|
|
23
|
+
"#{body}\n\n"
|
|
26
24
|
end
|
|
27
25
|
|
|
28
26
|
private
|
|
@@ -13,17 +13,21 @@ module Markbridge
|
|
|
13
13
|
|
|
14
14
|
# Build Discourse quote BBCode
|
|
15
15
|
# Format: [quote="username, post:123, topic:456"]content[/quote]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
16
|
+
body =
|
|
17
|
+
if element.post && element.topic && element.username
|
|
18
|
+
# Full Discourse quote with context
|
|
19
|
+
"[quote=\"#{element.username}, post:#{element.post}, topic:#{element.topic}\"]\n#{content}\n[/quote]"
|
|
20
|
+
elsif element.author
|
|
21
|
+
# Quote with author attribution only
|
|
22
|
+
"[quote=\"#{element.author}\"]\n#{content}\n[/quote]"
|
|
23
|
+
else
|
|
24
|
+
# Plain quote rendered as Markdown blockquote
|
|
25
|
+
content.split("\n").map { |line| "> #{line}" }.join("\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Trailing blank line so consecutive quotes don't merge and
|
|
29
|
+
# following content starts a new paragraph.
|
|
30
|
+
"#{body}\n\n"
|
|
27
31
|
end
|
|
28
32
|
end
|
|
29
33
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markbridge
|
|
4
|
+
module Renderers
|
|
5
|
+
module Discourse
|
|
6
|
+
module Tags
|
|
7
|
+
# Tag for rendering table cells (passthrough - renders children only)
|
|
8
|
+
# The TableTag handles cells directly; this is a safety net for standalone rendering.
|
|
9
|
+
class TableCellTag < Tag
|
|
10
|
+
def render(element, interface)
|
|
11
|
+
child_context = interface.with_parent(element)
|
|
12
|
+
interface.render_children(element, context: child_context)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|