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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/lib/markbridge/ast/table.rb +67 -0
  3. data/lib/markbridge/ast.rb +1 -0
  4. data/lib/markbridge/parsers/bbcode/handler_registry.rb +5 -0
  5. data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +13 -11
  6. data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +40 -33
  7. data/lib/markbridge/parsers/bbcode/handlers/table_cell_handler.rb +26 -0
  8. data/lib/markbridge/parsers/bbcode/handlers/table_handler.rb +32 -0
  9. data/lib/markbridge/parsers/bbcode/handlers/table_row_handler.rb +35 -0
  10. data/lib/markbridge/parsers/bbcode/parser.rb +1 -1
  11. data/lib/markbridge/parsers/bbcode.rb +3 -0
  12. data/lib/markbridge/parsers/html/handler_registry.rb +5 -0
  13. data/lib/markbridge/parsers/html/handlers/base_handler.rb +0 -1
  14. data/lib/markbridge/parsers/html/handlers/table_cell_handler.rb +24 -0
  15. data/lib/markbridge/parsers/html/handlers/table_handler.rb +24 -0
  16. data/lib/markbridge/parsers/html/handlers/table_row_handler.rb +24 -0
  17. data/lib/markbridge/parsers/html/parser.rb +13 -2
  18. data/lib/markbridge/parsers/html.rb +3 -0
  19. data/lib/markbridge/parsers/media_wiki/inline_parser.rb +105 -130
  20. data/lib/markbridge/parsers/media_wiki/parser.rb +128 -0
  21. data/lib/markbridge/parsers/text_formatter/handler_registry.rb +6 -0
  22. data/lib/markbridge/parsers/text_formatter/handlers/table_cell_handler.rb +26 -0
  23. data/lib/markbridge/parsers/text_formatter.rb +1 -0
  24. data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +96 -84
  25. data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +12 -0
  26. data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +0 -10
  27. data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +0 -10
  28. data/lib/markbridge/processors/discourse_markdown/scanner.rb +19 -16
  29. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +237 -180
  30. data/lib/markbridge/renderers/discourse/renderer.rb +1 -0
  31. data/lib/markbridge/renderers/discourse/tags/align_tag.rb +1 -1
  32. data/lib/markbridge/renderers/discourse/tags/attachment_tag.rb +1 -1
  33. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +2 -1
  34. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +3 -5
  35. data/lib/markbridge/renderers/discourse/tags/image_tag.rb +1 -1
  36. data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +1 -1
  37. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +3 -5
  38. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +15 -11
  39. data/lib/markbridge/renderers/discourse/tags/table_cell_tag.rb +18 -0
  40. data/lib/markbridge/renderers/discourse/tags/table_row_tag.rb +18 -0
  41. data/lib/markbridge/renderers/discourse/tags/table_tag.rb +124 -0
  42. data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +1 -1
  43. data/lib/markbridge/renderers/discourse.rb +3 -0
  44. data/lib/markbridge/version.rb +1 -1
  45. data/lib/markbridge.rb +20 -55
  46. 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
- result = String.new(capacity: text.bytesize + text.bytesize / 3, encoding: text.encoding)
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 < line.length && line.getbyte(indent_len) == SPACE
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 >= line.length
173
+ return line if indent_len >= line_length
169
174
 
170
- content = indent_len > 0 ? line[indent_len..] : line
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 indent_len > 0
180
- result = String.new(encoding: line.encoding)
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
- i = 0
201
- while i < line.length
202
- b = line.getbyte(i)
203
- break if b != SPACE && b != TAB
204
- i += 1
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 i == 0 # No leading whitespace (shouldn't happen, but safe)
208
- return line if i >= line.length # Whitespace-only line
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, i].each_char { |c| nbsp_indent << (c == "\t" ? (NBSP * 4) : NBSP) }
219
+ line[0, ws_end].each_char { |char| nbsp_indent << (char == "\t" ? (NBSP * 4) : NBSP) }
213
220
 
214
- content = line[i..]
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 "\\##{escape_inline(content[1..])}", true if ATX_HEADING.match?(content)
230
+ return escape_first_char_inline(content, "\\#") if ATX_HEADING.match?(content)
224
231
  when GT
225
- return "\\>#{escape_inline(content[1..])}", true
232
+ return escape_first_char_inline(content, "\\>")
226
233
  when DASH
227
- if THEMATIC_BREAK_DASH.match?(content) ||
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 "\\+#{escape_inline(content[1..])}", true if BULLET_LIST.match?(content)
236
+ return escape_first_char_inline(content, "\\+") if BULLET_LIST.match?(content)
234
237
  when STAR
235
- if THEMATIC_BREAK_STAR.match?(content)
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 "\\[#{escape_inline(content[1..])}", true
254
+ return escape_first_char_inline(content, "\\[")
257
255
  when PIPE
258
- return "\\|#{escape_inline(content[1..])}", true
256
+ return escape_first_char_inline(content, "\\|")
259
257
  when DIGIT_0..DIGIT_9
260
- if (m = ORDERED_LIST.match(content))
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 |b|
274
- if b == byte_val
294
+ str.each_byte do |byte|
295
+ if byte == byte_val
275
296
  result << escaped
276
297
  else
277
- result << b
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
- result =
288
- String.new(
289
- capacity: content.bytesize + content.bytesize / 4,
290
- encoding: content.encoding,
291
- )
292
- len = content.bytesize
293
- i = 0
294
-
295
- while i < len
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
- result
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
- # Quick whitespace-only check
479
+ line_length = line.length
428
480
  first_non_space = 0
429
- while first_non_space < line.length && line.getbyte(first_non_space) == 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 >= line.length || line.getbyte(first_non_space) == TAB
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
- return false if ATX_HEADING.match?(content)
503
+ ATX_HEADING.match?(content)
443
504
  when GT
444
- return false
505
+ true
445
506
  when DASH, PLUS, STAR
446
- return false if BULLET_LIST.match?(content)
447
- return false if first_byte == DASH && THEMATIC_BREAK_DASH.match?(content)
448
- return false if first_byte == STAR && THEMATIC_BREAK_STAR.match?(content)
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
- return false if THEMATIC_BREAK_UNDERSCORE.match?(content)
451
- when BACKTICK, TILDE
452
- if FENCED_CODE_BACKTICK.match?(content) || FENCED_CODE_TILDE.match?(content)
453
- return false
454
- end
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
- return false if ORDERED_LIST.match?(content)
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
@@ -8,6 +8,7 @@ module Markbridge
8
8
  def initialize(tag_library: nil, escaper: nil)
9
9
  @tag_library = tag_library || TagLibrary.default
10
10
  @escaper = escaper || MarkdownEscaper.new
11
+ @interface_cache = nil
11
12
  end
12
13
 
13
14
  # Render a node to Markdown
@@ -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, interface)
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
- "#{fence}#{lang}\n#{content}\n#{fence}"
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, interface)
22
- # Return raw BBCode if available, otherwise reconstruct
23
- return element.raw if element.raw
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, interface)
10
+ def render(element, _interface)
11
11
  src = element.src || ""
12
12
  width = element.width
13
13
  height = element.height
@@ -24,7 +24,7 @@ module Markbridge
24
24
  # end
25
25
  # end
26
26
  class MentionTag < Tag
27
- def render(element, interface)
27
+ def render(element, _interface)
28
28
  "@#{element.name}"
29
29
  end
30
30
  end
@@ -18,11 +18,9 @@ module Markbridge
18
18
  # end
19
19
  # end
20
20
  class PollTag < Tag
21
- def render(element, interface)
22
- # Return raw BBCode if available, otherwise reconstruct
23
- return element.raw if element.raw
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
- if element.post && element.topic && element.username
17
- # Full Discourse quote with context
18
- "[quote=\"#{element.username}, post:#{element.post}, topic:#{element.topic}\"]\n#{content}\n[/quote]"
19
- elsif element.author
20
- # Quote with author attribution only
21
- "[quote=\"#{element.author}\"]\n#{content}\n[/quote]"
22
- else
23
- # Plain quote - could use Markdown blockquote or BBCode
24
- # Using Markdown blockquote for plain quotes
25
- content.split("\n").map { |line| "> #{line}" }.join("\n")
26
- end
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