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.
- checksums.yaml +7 -0
- data/.memory-bank/activeContext.md +84 -0
- data/.memory-bank/productContext.md +51 -0
- data/.memory-bank/progress.md +118 -0
- data/.memory-bank/projectbrief.md +39 -0
- data/.memory-bank/systemPatterns.md +122 -0
- data/.memory-bank/techContext.md +188 -0
- data/.rspec +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +121 -0
- data/CONTRIBUTING.md +80 -0
- data/LICENSE.txt +21 -0
- data/README.md +113 -0
- data/Rakefile +4 -0
- data/adoc/samples/demo.adoc +19 -0
- data/adoc/samples/nested_ulist.adoc +14 -0
- data/adoc/samples/unordered-lists.adoc +34 -0
- data/docs/conversion-guidelines.md +84 -0
- data/docs/development-guide.md +95 -0
- data/docs/line-break-challenges.md +29 -0
- data/docs/mdpp-specification/includes.md +66 -0
- data/docs/mdpp-specification/multi-line-tables.md +235 -0
- data/docs/mdpp-specification/overview.md +13 -0
- data/lib/asciidoctor/converter/mdpp.rb +622 -0
- data/lib/asciidoctor/mdpp/version.rb +7 -0
- data/lib/asciidoctor/mdpp.rb +11 -0
- data/lib/asciidoctor-mdpp.rb +1 -0
- data/scripts/convert-mdpp-file.sh +41 -0
- data/scripts/convert-mdpp.sh +29 -0
- data/sig/asciidocter/mdpp.rbs +6 -0
- metadata +91 -0
@@ -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 = ""
|
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 = ""
|
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 @@
|
|
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
|