asciidoctor-mdpp 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.
@@ -0,0 +1,622 @@
1
+ require 'asciidoctor'
2
+ require 'asciidoctor/converter'
3
+ require 'asciidoctor/extensions'
4
+
5
+ # Extension: intercept AsciiDoc include directives and convert to Markdown++ include tags
6
+ Asciidoctor::Extensions.register do
7
+ include_processor do
8
+ # Handle all include targets
9
+ process do |doc, reader, target, attributes|
10
+ # Convert file extension to .md
11
+ md_path = target.sub(/\.[^.]+$/, '.md')
12
+ include_tag = "<!--include:#{md_path}-->"
13
+ # Push include tag into reader as literal content
14
+ reader.push_include include_tag + "\n", nil, target, reader.lineno, {}
15
+ end
16
+ end
17
+ end
18
+
19
+ class MarkdownPPConverter < Asciidoctor::Converter::Base
20
+ register_for 'mdpp', filetype: 'md', outfilesuffix: '.md'
21
+
22
+ def convert(node, transform = node.node_name)
23
+ method = "convert_#{transform}"
24
+ respond_to?(method) ? send(method, node) : "<!-- TODO: #{transform} -->"
25
+ end
26
+
27
+ # Render the document title (from = Title) as a Markdown++ setext header
28
+ def convert_header(header)
29
+ title = header.title
30
+ underline = '=' * title.length
31
+ "#{title}\n#{underline}"
32
+ end
33
+
34
+ # Render an ordered list with proper indentation for nested levels
35
+ def convert_olist(olist)
36
+ # indent nested ordered lists (two spaces per nested level) only when under another list item
37
+ if olist.parent.respond_to?(:node_name) && olist.parent.node_name == 'list_item'
38
+ indent = ' ' * (olist.level - 1)
39
+ else
40
+ indent = ''
41
+ end
42
+ olist.items.each_with_index.map do |li, idx|
43
+ index = idx + 1
44
+ prefix = "#{indent}#{index}. "
45
+ # Attempt to recover inline breaks from raw source when AST loses continuation
46
+ raw_conv = nil
47
+ if li.respond_to?(:source_location) && (loc = li.source_location)
48
+ # Determine source file path (absolute if available, otherwise relative)
49
+ raw_path = loc.respond_to?(:file) && loc.file ? loc.file : loc.path
50
+ ln = loc.lineno
51
+ raw_lines = nil
52
+ if raw_path
53
+ if File.exist?(raw_path)
54
+ raw_lines = File.readlines(raw_path)
55
+ else
56
+ if (docfile = li.document.attr('docfile'))
57
+ base = File.dirname(docfile)
58
+ candidate = File.join(base, raw_path)
59
+ raw_lines = File.readlines(candidate) if File.exist?(candidate)
60
+ end
61
+ end
62
+ end
63
+ if raw_lines
64
+ # Check for trailing '+' on the first list-item line
65
+ first_line = raw_lines[ln - 1].chomp("\n")
66
+ if first_line.rstrip.end_with?('+')
67
+ # Collect all lines belonging to this list item continuation
68
+ i = ln - 1
69
+ segments = []
70
+ while i < raw_lines.size
71
+ line = raw_lines[i].chomp("\n")
72
+ # Stop at next list-item marker or blank line
73
+ break if line.lstrip =~ /^(?:\d+\.\s|\.\s)/ || line.strip.empty?
74
+ # Remove trailing '+' if present
75
+ seg = line.rstrip
76
+ seg = seg.chomp('+').rstrip if seg.end_with?('+')
77
+ segments << seg
78
+ i += 1
79
+ end
80
+ if segments.any?
81
+ # Determine marker prefix from the first segment
82
+ if (m = segments[0].match(/^\s*(?:\d+\.\s|\.\s)/))
83
+ marker = m[0]
84
+ else
85
+ marker = prefix
86
+ end
87
+ # Build recovered lines: first with marker, continuations indented
88
+ lines_out = []
89
+ # first segment text after marker
90
+ text0 = segments[0][marker.length..-1].lstrip
91
+ lines_out << "#{marker}#{text0}"
92
+ # subsequent segments
93
+ segments[1..-1].to_a.each do |seg|
94
+ lines_out << (' ' * marker.length) + seg.lstrip
95
+ end
96
+ raw_conv = lines_out.join("\n")
97
+ end
98
+ end
99
+ end
100
+ end
101
+ # Use recovered content if available, otherwise default conversion
102
+ if raw_conv
103
+ raw_conv
104
+ else
105
+ converted = convert(li, 'list_item')
106
+ body = converted.gsub(/\n/, "\n" + ' ' * prefix.length)
107
+ "#{prefix}#{body}"
108
+ end
109
+ end.join("\n")
110
+ end
111
+
112
+ # Render the document: title and top-level blocks (preamble, sections, etc.)
113
+ def convert_document(doc)
114
+ # Prepare document title or fallback to first section title
115
+ parts = []
116
+ # Copy blocks to avoid mutating original
117
+ blocks = doc.blocks.dup
118
+ if doc.header && doc.header.title && !doc.header.title.empty?
119
+ # Use document title
120
+ parts << convert(doc.header, 'header')
121
+ rest_blocks = blocks
122
+ elsif blocks.first && blocks.first.node_name == 'section'
123
+ # Promote first section title as document title
124
+ first_sec = blocks.shift
125
+ title = first_sec.title
126
+ parts << "#{title}\n#{'=' * title.length}"
127
+ # Render child blocks of that first section, then any remaining top-level blocks
128
+ rest_blocks = first_sec.blocks + blocks
129
+ else
130
+ rest_blocks = blocks
131
+ end
132
+ # Render each remaining block
133
+ rest_blocks.each do |blk|
134
+ parts << convert(blk)
135
+ end
136
+ # Join parts with blank lines
137
+ result = parts.compact.join("\n\n")
138
+ # Append trailing newline if last document block is a standalone image macro paragraph
139
+ if doc.blocks.any? && doc.blocks.last.node_name == 'paragraph' && doc.blocks.last.lines.size == 1 && doc.blocks.last.lines.first.strip.start_with?('image:')
140
+ result << "\n"
141
+ end
142
+ result
143
+ end
144
+
145
+ # Render the document preamble (blocks before the first section)
146
+ def convert_preamble(preamble)
147
+ # Convert each child block in the preamble
148
+ preamble.blocks.map { |blk| convert(blk) }.compact.join("\n\n")
149
+ end
150
+
151
+ # Render a section header, and include an explicit anchor comment if an id was set via a block anchor
152
+ def convert_section(sec)
153
+ # Build the Markdown header line
154
+ header_line = '#' * sec.level + ' ' + sec.title
155
+ # Start with optional anchor comment for explicit anchors
156
+ output = ''
157
+ # Include comment only when section id was explicitly set (does not begin with auto-generated prefix)
158
+ id = sec.id.to_s
159
+ # Default id prefix as defined by document (defaults to '_')
160
+ prefix = sec.document.attributes['idprefix'] || '_'
161
+ if !id.empty? && !id.start_with?(prefix)
162
+ output << "<!-- ##{id} -->\n"
163
+ end
164
+ # Add the header
165
+ output << header_line
166
+ # Append child blocks, separated by a blank line
167
+ sec.blocks.each do |b|
168
+ output << "\n\n" << convert(b)
169
+ end
170
+ output
171
+ end
172
+ # Render a paragraph. Handle inline image macros specially, otherwise process inline macros.
173
+ def convert_paragraph(par)
174
+ # If this paragraph is a Markdown++ include tag, emit it raw
175
+ if par.lines.size == 1 && par.lines.first.strip =~ /<!--\s*include:[^>]+-->/
176
+ return par.lines.first.strip
177
+ end
178
+ # Process raw lines to handle trailing '+' hard breaks
179
+ lines = par.lines.map do |line|
180
+ l = line.chomp("\n")
181
+ if l.rstrip.end_with?('+')
182
+ l.chomp('+')
183
+ else
184
+ l
185
+ end
186
+ end
187
+ text = lines.join("\n")
188
+ # Convert any inline image macros in this paragraph
189
+ if text.include?('image:')
190
+ text = text.gsub(/image:(\S+?)\[([^\]]*)\]/) do
191
+ src = Regexp.last_match(1)
192
+ positional = []
193
+ named = {}
194
+ Regexp.last_match(2).split(',').map(&:strip).each do |param|
195
+ next if param.empty?
196
+ if param.include?('=')
197
+ k, v = param.split('=', 2)
198
+ v = v.gsub(/^"|"$|^'|'$/, '')
199
+ named[k] = v
200
+ else
201
+ positional << param
202
+ end
203
+ end
204
+ alt = positional[0] || ''
205
+ width = named['width'] || positional[1]
206
+ height = named['height'] || positional[2]
207
+ style_parts = []
208
+ if width && !width.empty?
209
+ w = width.to_s
210
+ style_parts << (w.end_with?('%') ? "w#{w.chomp('%')}percent" : "w#{w}")
211
+ end
212
+ if height && !height.empty?
213
+ h = height.to_s
214
+ style_parts << (h.end_with?('%') ? "h#{h.chomp('%')}percent" : "h#{h}")
215
+ end
216
+ style = style_parts.join
217
+ img = "![#{alt}](#{src})"
218
+ style.empty? ? img : "<!-- style:#{style} -->#{img}"
219
+ end
220
+ end
221
+ # Convert inline xref anchors: <<id, text>> to Markdown++ links
222
+ text = text.gsub(/<<([^,>]+),\s*([^>]+?)>>/) { "[#{$2}](##{$1})" }
223
+ # Convert inline quoted text: *text* to bold **text**
224
+ text = text.gsub(/\*(.+?)\*/) { "**#{$1}**" }
225
+ text
226
+ end
227
+
228
+ # Render an unordered list with proper indentation for nested levels
229
+ def convert_ulist(ulist)
230
+ # indent nested unordered lists (two spaces per nested level) only when under another list item
231
+ if ulist.parent.respond_to?(:node_name) && ulist.parent.node_name == 'list_item'
232
+ indent = ' ' * (ulist.level - 1)
233
+ else
234
+ indent = ''
235
+ end
236
+ # Render unordered list items, indenting nested lines, and ensure trailing newline
237
+ ulist.items.map do |li|
238
+ # render item and indent subsequent lines
239
+ body = convert(li, 'list_item').gsub(/\n/, "\n#{indent} ")
240
+ "#{indent}- #{body}"
241
+ end.join("\n")
242
+ end
243
+
244
+ # Render a list item: include its text and any nested blocks
245
+ def convert_list_item(node)
246
+ parts = [node.text]
247
+ node.blocks.each do |b|
248
+ parts << convert(b)
249
+ end
250
+ parts.join("\n")
251
+ end
252
+
253
+ # Render inline anchor macros: xrefs and explicit anchors
254
+ def convert_inline_anchor(node)
255
+ case node.type
256
+ when :xref
257
+ # Render cross-reference as a Markdown++ link
258
+ text = node.text || node.target
259
+ # node.target may include a leading '#', so do not add an extra
260
+ "[#{text}](#{node.target})"
261
+ when :ref
262
+ # Render an explicit anchor id as a comment
263
+ "<!-- ##{node.id} -->"
264
+ else
265
+ # Unknown inline anchor, omit
266
+ ""
267
+ end
268
+ end
269
+
270
+ # Render a page break directive (<<<) as blank space (skip into next page)
271
+ def convert_page_break(node)
272
+ ''
273
+ end
274
+
275
+ # Render a thematic break (''' delimited) as a horizontal rule
276
+ def convert_thematic_break(node)
277
+ '---'
278
+ end
279
+
280
+ # Render a video macro as a YouTube iframe embed
281
+ def convert_video(node)
282
+ # Extract video id and dimensions
283
+ id = node.attr('target')
284
+ width = node.attr('width')
285
+ height = node.attr('height')
286
+ # Build iframe embed
287
+ lines = []
288
+ lines << '<iframe '
289
+ lines << " width=\"#{width}\""
290
+ lines << " height=\"#{height}\""
291
+ lines << " src=\"https://www.youtube.com/embed/#{id}\""
292
+ lines << ' frameborder="0"'
293
+ lines << ' allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"'
294
+ lines << ' allowfullscreen>'
295
+ lines << '</iframe>'
296
+ lines.join("\n")
297
+ end
298
+
299
+ # Render a block-level or inline image as a Markdown image with optional dimensions
300
+ def convert_image(node)
301
+ # Determine alternate text: use explicit first positional attribute if provided; otherwise, no alt text
302
+ alt = node.attributes.key?(1) ? node.attributes[1].to_s : ''
303
+ # Determine source URL or path
304
+ src = node.inline? ? node.target : node.attr('target')
305
+ # Extract width and height (may include percentage units)
306
+ width = node.attr('width') || node.attributes[2]
307
+ height = node.attr('height') || node.attributes[3]
308
+ # Build style name: handle numeric and percentage dimensions
309
+ style_parts = []
310
+ if width
311
+ w = width.to_s
312
+ style_parts << (w.end_with?('%') ? "w#{w.chomp('%')}percent" : "w#{w}")
313
+ end
314
+ if height
315
+ h = height.to_s
316
+ style_parts << (h.end_with?('%') ? "h#{h.chomp('%')}percent" : "h#{h}")
317
+ end
318
+ style = style_parts.join
319
+ # Assemble Markdown image
320
+ img = "![#{alt}](#{src})"
321
+ # Prepend style comment only if dimensions were specified
322
+ style.empty? ? img : "<!-- style:#{style} -->#{img}"
323
+ end
324
+
325
+ # Render an inline image using the same logic as block-level image
326
+ alias convert_inline_image convert_image
327
+
328
+ # Render inline quoted text (e.g., *text*) as Markdown++ strong syntax
329
+ def convert_inline_quoted(node)
330
+ # Always use double asterisks to denote quoted text
331
+ "**#{node.text}**"
332
+ end
333
+
334
+ # Render an admonition block as a Markdown++ styled block
335
+ # Emit a style comment and a blockquote for each content line
336
+ def convert_admonition(node)
337
+ # Build style tag (e.g., AdmonitionNote, AdmonitionTip)
338
+ style = "Admonition#{node.caption}"
339
+ # Gather content lines: use nested blocks if present, else raw lines
340
+ if node.blocks.any?
341
+ # convert each child block and accumulate its lines
342
+ content = node.blocks.map { |b| convert(b) }.join("\n")
343
+ lines = content.lines.map(&:chomp)
344
+ suffix = ''
345
+ else
346
+ # fallback to raw source lines for short-form admonition
347
+ lines = node.lines.map(&:chomp)
348
+ suffix = "\n"
349
+ end
350
+ # Quote each line
351
+ quoted = lines.map { |line| "> #{line}" }.join("\n")
352
+ # Prepend style comment and content, with optional suffix
353
+ "<!-- style:#{style} -->\n" + quoted + suffix
354
+ end
355
+
356
+ # Render an example block (==== delimited) as nested blockquote lines
357
+ def convert_example(node)
358
+ # indent level for example block
359
+ indent_str = '>'
360
+ prefix_str = indent_str + ' '
361
+ lines = []
362
+ node.blocks.each_with_index do |b, idx|
363
+ if b.node_name == 'listing'
364
+ # nested listing block: increase indent
365
+ content = convert_listing(b)
366
+ nested_indent = '>' * 2
367
+ nested_prefix = nested_indent + ' '
368
+ content.split("\n", -1).each do |line|
369
+ if line.empty?
370
+ lines << nested_prefix
371
+ else
372
+ lines << nested_prefix + line
373
+ end
374
+ end
375
+ else
376
+ content = convert(b)
377
+ content.split("\n", -1).each do |line|
378
+ if line.empty?
379
+ lines << indent_str
380
+ else
381
+ lines << prefix_str + line
382
+ end
383
+ end
384
+ end
385
+ # separator blank line between blocks
386
+ if idx < node.blocks.size - 1
387
+ # first separator without space, subsequent with space
388
+ if idx == 0
389
+ lines << indent_str
390
+ else
391
+ lines << prefix_str
392
+ end
393
+ end
394
+ end
395
+ lines.join("\n")
396
+ end
397
+
398
+ # Render a literal block (.... delimited) as a code fence
399
+ def convert_literal(node)
400
+ lines = node.lines
401
+ # Wrap literal block in Markdown++ code fence
402
+ "```\n" + lines.join("\n") + "\n```"
403
+ end
404
+
405
+ # Render a listing block (---- delimited) or source blocks as Markdown++ code fences
406
+ def convert_listing(node)
407
+ # Simple listing blocks outside of example context: no language code fence
408
+ if node.style == 'listing' && node.parent.node_name != 'example'
409
+ return "```\n" + node.lines.join("\n") + "\n```"
410
+ end
411
+ # Named source code fence outside of example context: include language
412
+ if node.style == 'source' && node.parent.node_name != 'example'
413
+ lang = node.attr('language') || node.attributes[2]
414
+ return "```#{lang}\n" + node.lines.join("\n") + "\n```"
415
+ end
416
+ # Fallback: convert content groups into paragraphs or headings
417
+ lines = node.lines
418
+ groups = []
419
+ current = []
420
+ lines.each do |line|
421
+ if line.strip.empty?
422
+ groups << current unless current.empty?
423
+ current = []
424
+ else
425
+ current << line
426
+ end
427
+ end
428
+ groups << current unless current.empty?
429
+ parts = groups.map do |group|
430
+ if group.size == 1 && (m = group[0].match(/^(=+)\s*(.*)/))
431
+ level = m[1].length
432
+ "#{'#' * level} #{m[2]}"
433
+ else
434
+ group.join(' ')
435
+ end
436
+ end
437
+ parts.join("\n\n")
438
+ end
439
+
440
+ # Render a table as a Markdown++ table, handling both simple and multiline cases
441
+ def convert_table(node)
442
+ # Determine Markdown++ style tag
443
+ style = node.attr('role')
444
+ # Fallback: simple table conversion via AST for tables with more than 2 columns
445
+ ast_rows = node.rows
446
+ if (ast_rows.respond_to?(:head) && ast_rows.respond_to?(:body) ? (ast_rows.head.first || []).size > 2 : (node.attr('cols') || '').split(',').size > 2)
447
+ # AST-based simple table: pad columns to equal width
448
+ header_cells = ast_rows.respond_to?(:head) ? (ast_rows.head.first || []) : []
449
+ body_ast = ast_rows.respond_to?(:body) ? ast_rows.body : (begin arr = []; node.rows.each { |r| arr << r.cells }; arr.drop(1) end)
450
+ # Extract texts
451
+ header_texts = header_cells.map(&:text)
452
+ body_texts = body_ast.map { |cells| cells.map(&:text) }
453
+ # Compute max length per column, defaulting missing entries to 0
454
+ max_lens = header_texts.map(&:length)
455
+ body_texts.each do |row|
456
+ row.each_with_index do |text, idx|
457
+ current = max_lens[idx] || 0
458
+ max_lens[idx] = text.length if text.length > current
459
+ end
460
+ end
461
+ # Column widths with padding
462
+ widths = max_lens.map { |l| l + 2 }
463
+ # Build header line
464
+ hdr_cells = header_texts.each_with_index.map { |h, i| h.ljust(widths[i] - 2) }
465
+ header_line = "| " + hdr_cells.join(' | ') + " |"
466
+ # Build alignment line
467
+ align_line = '|' + widths.map { |w| '-' * w }.join('|') + '|'
468
+ # Build body lines
469
+ body_lines = body_texts.map do |row|
470
+ cells = row.each_with_index.map { |text, i| text.ljust(widths[i] - 2) }
471
+ "| " + cells.join(' | ') + " |"
472
+ end
473
+ # Style comment
474
+ comment = style ? "<!-- style:#{style} -->" : ''
475
+ return [comment, header_line, align_line, *body_lines].reject(&:empty?).join("\n")
476
+ end
477
+ # Locate raw source file and read lines; if file unreadable, fallback to simple AST conversion
478
+ src_file = node.document.attr('docfile')
479
+ begin
480
+ src_lines = File.readlines(src_file)
481
+ rescue
482
+ # Fallback to simple AST-based table conversion
483
+ header_cells = ast_rows.respond_to?(:head) ? (ast_rows.head.first || []) : []
484
+ body_ast = ast_rows.respond_to?(:body) ? ast_rows.body : (begin arr = []; node.rows.each { |r| arr << r.cells }; arr.drop(1) end)
485
+ header_texts = header_cells.map(&:text)
486
+ body_texts = body_ast.map { |cells| cells.map(&:text) }
487
+ max_lens = header_texts.map(&:length)
488
+ body_texts.each do |row|
489
+ row.each_with_index { |text, idx| max_lens[idx] = text.length if text.length > max_lens[idx] }
490
+ end
491
+ widths = max_lens.map { |l| l + 2 }
492
+ hdr_cells = header_texts.each_with_index.map { |h, i| h.ljust(widths[i] - 2) }
493
+ header_line = "| " + hdr_cells.join(' | ') + " |"
494
+ align_line = '|' + widths.map { |w| '-' * w }.join('|') + '|'
495
+ body_lines = body_texts.map do |row|
496
+ cells = row.each_with_index.map { |text, i| text.ljust(widths[i] - 2) }
497
+ "| " + cells.join(' | ') + " |"
498
+ end
499
+ comment = style ? "<!-- style:#{style} -->" : ''
500
+ return [comment, header_line, align_line, *body_lines].reject(&:empty?).join("\n")
501
+ end
502
+ # If table is not fenced with grid markers, fallback to simple AST conversion
503
+ unless src_lines.any? { |l| l.strip == '|===' }
504
+ header_cells = ast_rows.respond_to?(:head) ? (ast_rows.head.first || []) : []
505
+ body_ast = ast_rows.respond_to?(:body) ? ast_rows.body : (begin arr = []; node.rows.each { |r| arr << r.cells }; arr.drop(1) end)
506
+ header_texts = header_cells.map(&:text)
507
+ body_texts = body_ast.map { |cells| cells.map(&:text) }
508
+ # Safe AST-based fallback: compute column widths
509
+ max_lens = header_texts.map(&:length)
510
+ body_texts.each do |row|
511
+ row.each_with_index do |text, idx|
512
+ current = max_lens[idx] || 0
513
+ max_lens[idx] = text.length if text.length > current
514
+ end
515
+ end
516
+ widths = max_lens.map { |l| (l || 0) + 2 }
517
+ hdr_cells = header_texts.each_with_index.map { |h, i| h.ljust(widths[i] - 2) }
518
+ header_line = "| " + hdr_cells.join(' | ') + " |"
519
+ align_line = '|' + widths.map { |w| '-' * w }.join('|') + '|'
520
+ body_lines = body_texts.map do |row|
521
+ cells = row.each_with_index.map { |text, i| text.ljust(widths[i] - 2) }
522
+ "| " + cells.join(' | ') + " |"
523
+ end
524
+ comment = style ? "<!-- style:#{style} -->" : ''
525
+ return [comment, header_line, align_line, *body_lines].reject(&:empty?).join("\n")
526
+ end
527
+ # Identify table boundaries (fenced with |===)
528
+ start_idx = src_lines.index { |l| l.strip == '|===' }
529
+ end_rel = src_lines[(start_idx + 1)..-1].index { |l| l.strip == '|===' }
530
+ end_idx = start_idx + 1 + (end_rel || 0)
531
+ raw = src_lines[(start_idx + 1)...end_idx]
532
+ # Parse header row
533
+ hdr_line = raw.find { |l| l.strip.start_with?('|') }
534
+ hdr_cols = hdr_line.strip.sub(/^\|/, '').split('|').map(&:strip)
535
+ # Parse body rows: each '| ' marks a new row; details follow until next '|' or end
536
+ rows = []
537
+ body = raw.drop_while { |l| l != hdr_line }[1..] || []
538
+ i = 0
539
+ while i < body.size
540
+ line = body[i]
541
+ if line.strip.start_with?('|')
542
+ # New row header
543
+ header_text = line.strip.sub(/^\|/, '').chomp('+').strip
544
+ # Collect detail lines until next row or table end
545
+ details = []
546
+ i += 1
547
+ while i < body.size && !body[i].strip.start_with?('|')
548
+ txt = body[i].rstrip
549
+ details << txt unless txt.strip.empty?
550
+ i += 1
551
+ end
552
+ rows << { header: header_text, details: details }
553
+ else
554
+ i += 1
555
+ end
556
+ end
557
+ # Post-process details for code and admonition rows
558
+ rows.each do |row|
559
+ details = row[:details]
560
+ # Code block detection
561
+ if details.first =~ /^\[source,.*\]$/
562
+ # Extract lines between '----' fences
563
+ start_idx = details.index('----')
564
+ if start_idx
565
+ end_idx = details[(start_idx + 1)..].index('----')
566
+ code_lines = if end_idx
567
+ details[(start_idx + 1)...(start_idx + 1 + end_idx)]
568
+ else
569
+ details[(start_idx + 1)..]
570
+ end
571
+ else
572
+ code_lines = details.drop(1)
573
+ end
574
+ row[:details] = ['```'] + code_lines + ['```']
575
+ elsif details.first == '===='
576
+ # Admonition block detection
577
+ detail = details[1] || ''
578
+ cap = detail.split(':', 2).first
579
+ style_name = cap.downcase.capitalize
580
+ row[:details] = ["<!-- style:Admonition#{style_name} -->", "> #{detail}"]
581
+ end
582
+ end
583
+ # Compute column widths: column1 based on headers and row headers; column2 based on header and details
584
+ col1_max = ([hdr_cols[0].length] + rows.map { |r| r[:header].length }).max
585
+ col2_max = ([hdr_cols[1].length] + rows.flat_map { |r| r[:details].map(&:length) }).max
586
+ widths = [col1_max + 2, col2_max + 2]
587
+ # Build style comment: add 'multiline' if any detail contains more than one line
588
+ tags = []
589
+ tags << "style:#{style}" if style
590
+ tags << 'multiline' if rows.any? { |r| r[:details].size > 1 }
591
+ comment = tags.empty? ? '' : "<!-- #{tags.join('; ')} -->"
592
+ # Build header and alignment rows
593
+ header_md = "| " + hdr_cols.each_with_index.map { |h, j| h.ljust(widths[j] - 2) }.join(' | ') + " |"
594
+ # Alignment line: adjust second column width for multiline tables
595
+ align_parts = widths.map.with_index do |w, idx|
596
+ '-' * (idx.zero? ? w : w + 1)
597
+ end
598
+ align_md = '|' + align_parts.join('|') + '|'
599
+ # Assemble table lines
600
+ md = []
601
+ md << comment
602
+ md << header_md
603
+ md << align_md
604
+ rows.each_with_index do |row, idx|
605
+ # First line: header and first detail (or blank)
606
+ md << '|' + ' ' + row[:header].ljust(widths[0] - 2) + ' | ' + (row[:details][0] || '').ljust(widths[1] - 2) + ' |'
607
+ # Additional detail lines
608
+ row[:details][1..].to_a.each do |det|
609
+ md << '|' + ' ' * widths[0] + '|' + ' ' + det.ljust(widths[1] - 2) + ' |'
610
+ end
611
+ # Separator blank row between logical row groups
612
+ unless idx == rows.size - 1
613
+ # Blank separator row: adjust second column width as alignment line
614
+ blank_parts = widths.map.with_index do |w, idx|
615
+ ' ' * (idx.zero? ? w : w + 1)
616
+ end
617
+ md << '|' + blank_parts.join('|') + '|'
618
+ end
619
+ end
620
+ md.join("\n")
621
+ end
622
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asciidoctor
4
+ module Mdpp
5
+ VERSION = "0.1.1"
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mdpp/version"
4
+ require_relative "converter/mdpp"
5
+
6
+ module Asciidoctor
7
+ module Mdpp
8
+ class Error < StandardError; end
9
+ # Your code goes here...
10
+ end
11
+ end
@@ -0,0 +1 @@
1
+ require_relative "asciidoctor/mdpp"
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Usage: convert-mdpp-file.sh <INPUT.adoc>
5
+ if [ $# -ne 1 ]; then
6
+ echo "Usage: $0 <INPUT.adoc>"
7
+ exit 1
8
+ fi
9
+
10
+ INPUT="$1"
11
+
12
+ # Verify the input file exists
13
+ if [ ! -f "$INPUT" ]; then
14
+ echo "Error: File not found: $INPUT"
15
+ exit 1
16
+ fi
17
+
18
+ # Ensure correct extension
19
+ EXT="${INPUT##*.}"
20
+ if [[ "$EXT" != "adoc" && "$EXT" != "asciidoc" ]]; then
21
+ echo "Error: Input file must have .adoc or .asciidoc extension"
22
+ exit 1
23
+ fi
24
+
25
+ # Locate converter script relative to this script
26
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
27
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
28
+ CONVERTER="$PROJECT_ROOT/lib/asciidoctor/converter/mdpp.rb"
29
+
30
+ # Determine output file path (.md extension in same directory)
31
+ DIR="$(dirname "$INPUT")"
32
+ BASE="$(basename "$INPUT" ."$EXT")"
33
+ OUTPUT="$DIR/$BASE.md"
34
+
35
+ echo "Converting: $INPUT -> $OUTPUT"
36
+ asciidoctor -r "$CONVERTER" -b mdpp -o "$OUTPUT" "$INPUT"
37
+
38
+ # Ensure output ends with a newline
39
+ if [ -s "$OUTPUT" ] && [ "$(tail -c1 "$OUTPUT")" != $'\n' ]; then
40
+ printf "\n" >> "$OUTPUT"
41
+ fi