coradoc-markdown 1.0.0
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/LICENSE.txt +21 -0
- data/lib/coradoc/markdown/errors.rb +28 -0
- data/lib/coradoc/markdown/model/abbreviation.rb +27 -0
- data/lib/coradoc/markdown/model/attribute_list.rb +98 -0
- data/lib/coradoc/markdown/model/base.rb +86 -0
- data/lib/coradoc/markdown/model/blockquote.rb +21 -0
- data/lib/coradoc/markdown/model/code.rb +11 -0
- data/lib/coradoc/markdown/model/code_block.rb +24 -0
- data/lib/coradoc/markdown/model/definition_item.rb +24 -0
- data/lib/coradoc/markdown/model/definition_list.rb +47 -0
- data/lib/coradoc/markdown/model/definition_term.rb +21 -0
- data/lib/coradoc/markdown/model/document.rb +39 -0
- data/lib/coradoc/markdown/model/emphasis.rb +11 -0
- data/lib/coradoc/markdown/model/extension.rb +92 -0
- data/lib/coradoc/markdown/model/footnote.rb +31 -0
- data/lib/coradoc/markdown/model/footnote_reference.rb +22 -0
- data/lib/coradoc/markdown/model/heading.rb +44 -0
- data/lib/coradoc/markdown/model/highlight.rb +18 -0
- data/lib/coradoc/markdown/model/horizontal_rule.rb +16 -0
- data/lib/coradoc/markdown/model/image.rb +19 -0
- data/lib/coradoc/markdown/model/link.rb +19 -0
- data/lib/coradoc/markdown/model/list.rb +22 -0
- data/lib/coradoc/markdown/model/list_item.rb +29 -0
- data/lib/coradoc/markdown/model/math.rb +50 -0
- data/lib/coradoc/markdown/model/paragraph.rb +28 -0
- data/lib/coradoc/markdown/model/strikethrough.rb +18 -0
- data/lib/coradoc/markdown/model/strong.rb +11 -0
- data/lib/coradoc/markdown/model/table.rb +13 -0
- data/lib/coradoc/markdown/model/text.rb +15 -0
- data/lib/coradoc/markdown/parser/ast_processor.rb +543 -0
- data/lib/coradoc/markdown/parser/block_parser.rb +745 -0
- data/lib/coradoc/markdown/parser/html_entities.rb +2149 -0
- data/lib/coradoc/markdown/parser/inline_parser.rb +274 -0
- data/lib/coradoc/markdown/parser/parslet_extras.rb +215 -0
- data/lib/coradoc/markdown/parser.rb +11 -0
- data/lib/coradoc/markdown/parser_util.rb +90 -0
- data/lib/coradoc/markdown/serializer.rb +199 -0
- data/lib/coradoc/markdown/toc_generator.rb +215 -0
- data/lib/coradoc/markdown/transform/from_core_model.rb +325 -0
- data/lib/coradoc/markdown/transform/text_extraction.rb +19 -0
- data/lib/coradoc/markdown/transform/to_core_model.rb +287 -0
- data/lib/coradoc/markdown/transformer.rb +463 -0
- data/lib/coradoc/markdown/version.rb +7 -0
- data/lib/coradoc/markdown.rb +190 -0
- metadata +173 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'parslet'
|
|
4
|
+
|
|
5
|
+
module Coradoc
|
|
6
|
+
module Markdown
|
|
7
|
+
autoload :ParserUtil, "#{__dir__}/parser_util"
|
|
8
|
+
|
|
9
|
+
# Transformer converts Parslet AST into Markdown Document Model objects.
|
|
10
|
+
#
|
|
11
|
+
# This transformer takes the raw output from the BlockParser/InlineParser
|
|
12
|
+
# and converts it into semantic model objects (Heading, Paragraph, etc.)
|
|
13
|
+
#
|
|
14
|
+
class Transformer < Parslet::Transform
|
|
15
|
+
# ATX Heading: # Heading
|
|
16
|
+
rule(heading: simple(:heading), text: simple(:text)) do
|
|
17
|
+
Heading.new(level: heading.to_s.length, text: text.to_s.strip)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# ATX Heading without text (just #)
|
|
21
|
+
rule(heading: simple(:heading)) do
|
|
22
|
+
Heading.new(level: heading.to_s.length, text: '')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Thematic break (horizontal rule)
|
|
26
|
+
rule(hr: simple(:hr)) do
|
|
27
|
+
HorizontalRule.new(style: '---')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Code block (fenced or indented)
|
|
31
|
+
rule(code_block: sequence(:lines)) do
|
|
32
|
+
code = lines.map { |l| l.is_a?(Hash) ? (l[:ln] || '') : l.to_s }.join("\n")
|
|
33
|
+
CodeBlock.new(code: "#{code.rstrip}\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
rule(code_block: simple(:line)) do
|
|
37
|
+
code = line.is_a?(Hash) ? (line[:ln] || '') : line.to_s
|
|
38
|
+
CodeBlock.new(code: "#{code}\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Block quote
|
|
42
|
+
rule(block_quote: subtree(:content)) do
|
|
43
|
+
# Recursively transform block quote content
|
|
44
|
+
transformed = content.is_a?(Array) ? content.map { |c| transform_element(c) } : [transform_element(content)]
|
|
45
|
+
text = transformed.map { |c| c.is_a?(Base) && c.class.attributes.key?(:text) ? c.text : c.to_s }.join("\n")
|
|
46
|
+
Blockquote.new(content: text)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Paragraph
|
|
50
|
+
rule(p: { ln: simple(:text) }) do
|
|
51
|
+
Paragraph.new(text: text.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
rule(p: sequence(:lines)) do
|
|
55
|
+
text = lines.map { |l| l.is_a?(Hash) ? (l[:ln] || '') : l.to_s }.join("\n")
|
|
56
|
+
Paragraph.new(text: text)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Setext heading (underline style)
|
|
60
|
+
rule(text: subtree(:text_content), heading: simple(:heading)) do
|
|
61
|
+
# Extract text from the text_content
|
|
62
|
+
lines = text_content.is_a?(Array) ? text_content : [text_content]
|
|
63
|
+
text = lines.map do |l|
|
|
64
|
+
case l
|
|
65
|
+
when Hash then l[:ln] || ''
|
|
66
|
+
else l.to_s
|
|
67
|
+
end
|
|
68
|
+
end.join("\n")
|
|
69
|
+
|
|
70
|
+
level = heading.to_s.start_with?('=') ? 1 : 2
|
|
71
|
+
Heading.new(level: level, text: text.strip)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Inline elements
|
|
75
|
+
rule(text: simple(:text)) do
|
|
76
|
+
Text.new(content: text.to_s)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
rule(code: simple(:code)) do
|
|
80
|
+
Code.new(text: code.to_s)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
rule(emph: subtree(:content)) do
|
|
84
|
+
text = extract_text(content)
|
|
85
|
+
Emphasis.new(text: text)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
rule(strong: subtree(:content)) do
|
|
89
|
+
text = extract_text(content)
|
|
90
|
+
Strong.new(text: text)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ===== KRAMDOWN EXTENSIONS =====
|
|
94
|
+
|
|
95
|
+
# IAL (Inline Attribute List): {:.class #id key="value"}
|
|
96
|
+
rule(ial: simple(:ial_content)) do
|
|
97
|
+
AttributeList.parse("{:#{ial_content}}")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ALD (Attribute List Definition): {:name: #id .class}
|
|
101
|
+
rule(ald_name: simple(:name), ial: simple(:ial_content)) do
|
|
102
|
+
AttributeList.new(
|
|
103
|
+
name: name.to_s,
|
|
104
|
+
**parse_ial_content(ial_content.to_s)
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Extension (self-closing): {::toc /} or {::options key="value" /}
|
|
109
|
+
rule(extension: {
|
|
110
|
+
ext_name: simple(:name),
|
|
111
|
+
ext_options: simple(:opts)
|
|
112
|
+
}) do
|
|
113
|
+
Extension.new(
|
|
114
|
+
name: name.to_s,
|
|
115
|
+
options: parse_extension_options(opts.to_s)
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
rule(extension: {
|
|
120
|
+
ext_name: simple(:name),
|
|
121
|
+
ext_options: simple(:opts),
|
|
122
|
+
ext_body: simple(:body)
|
|
123
|
+
}) do
|
|
124
|
+
Extension.new(
|
|
125
|
+
name: name.to_s,
|
|
126
|
+
options: parse_extension_options(opts.to_s),
|
|
127
|
+
content: body.to_s
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Block math: $$...$$
|
|
132
|
+
rule(math_content: simple(:content)) do
|
|
133
|
+
Math.block(content.to_s.strip)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Fallback for unmatched elements
|
|
137
|
+
rule(simple(:value)) do
|
|
138
|
+
Text.new(content: value.to_s)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
class << self
|
|
142
|
+
# ALD storage - maps name to AttributeList
|
|
143
|
+
attr_accessor :ald_registry
|
|
144
|
+
|
|
145
|
+
# Transform AST into a Document model
|
|
146
|
+
#
|
|
147
|
+
# @param ast [Array] The parsed AST from BlockParser
|
|
148
|
+
# @return [Coradoc::Markdown::Document] The document model
|
|
149
|
+
def transform_document(ast)
|
|
150
|
+
@ald_registry = {}
|
|
151
|
+
blocks = Array(ast).map { |element| transform_element(element) }.compact
|
|
152
|
+
Document.new(blocks: blocks)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Transform a single element
|
|
156
|
+
def transform_element(element)
|
|
157
|
+
return nil if element.nil?
|
|
158
|
+
|
|
159
|
+
case element
|
|
160
|
+
when Hash
|
|
161
|
+
# Handle ALD first (register it)
|
|
162
|
+
if element.key?(:ald_name)
|
|
163
|
+
register_ald(element)
|
|
164
|
+
return nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Handle IAL on its own line (reference or standalone)
|
|
168
|
+
if element.key?(:ial) && !element.key?(:p) && !element.key?(:heading)
|
|
169
|
+
ial = parse_ial_element(element[:ial])
|
|
170
|
+
# Check if it's a reference to an ALD
|
|
171
|
+
return @ald_registry[ial] if ial.is_a?(String) && @ald_registry.key?(ial)
|
|
172
|
+
|
|
173
|
+
return ial
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Handle extension
|
|
177
|
+
return transform_extension(element[:extension]) if element.key?(:extension)
|
|
178
|
+
|
|
179
|
+
# Handle extension (direct key)
|
|
180
|
+
return transform_extension(element) if element.key?(:ext_name)
|
|
181
|
+
|
|
182
|
+
# Handle math
|
|
183
|
+
return Math.block(extract_text(element[:math_content])) if element.key?(:math_content)
|
|
184
|
+
|
|
185
|
+
# Handle footnote reference (inline)
|
|
186
|
+
return FootnoteReference.new(id: element[:fn_ref].to_s) if element.key?(:fn_ref)
|
|
187
|
+
|
|
188
|
+
# Try to transform using rules
|
|
189
|
+
transformed = try_transform(element)
|
|
190
|
+
return transformed if transformed
|
|
191
|
+
|
|
192
|
+
# If no rule matches, try to extract text
|
|
193
|
+
if element.key?(:ln)
|
|
194
|
+
Paragraph.new(text: element[:ln].to_s)
|
|
195
|
+
elsif element.key?(:text)
|
|
196
|
+
Text.new(content: element[:text].to_s)
|
|
197
|
+
end
|
|
198
|
+
when Array
|
|
199
|
+
# Transform each item
|
|
200
|
+
element.map { |e| transform_element(e) }.compact
|
|
201
|
+
when Parslet::Slice
|
|
202
|
+
Text.new(content: element.to_s)
|
|
203
|
+
else
|
|
204
|
+
Text.new(content: element.to_s)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Register an ALD (Attribute List Definition)
|
|
209
|
+
def register_ald(element)
|
|
210
|
+
name = element[:ald_name].to_s
|
|
211
|
+
ial_content = element[:ial].to_s
|
|
212
|
+
attrs = parse_ial_content(ial_content)
|
|
213
|
+
@ald_registry[name] = AttributeList.new(name: name, **attrs)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Transform extension element
|
|
217
|
+
def transform_extension(element)
|
|
218
|
+
name = element[:ext_name].to_s
|
|
219
|
+
opts = element[:ext_options]
|
|
220
|
+
# Handle empty array from parser
|
|
221
|
+
options = if opts.is_a?(Array) && opts.empty?
|
|
222
|
+
{}
|
|
223
|
+
elsif opts
|
|
224
|
+
parse_extension_options(opts.to_s)
|
|
225
|
+
else
|
|
226
|
+
{}
|
|
227
|
+
end
|
|
228
|
+
body = element[:ext_body]
|
|
229
|
+
|
|
230
|
+
Extension.new(
|
|
231
|
+
name: name,
|
|
232
|
+
options: options,
|
|
233
|
+
content: body&.to_s
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Try to transform using the defined rules
|
|
238
|
+
def try_transform(element)
|
|
239
|
+
return nil unless element.is_a?(Hash)
|
|
240
|
+
|
|
241
|
+
# Check for known patterns and transform them
|
|
242
|
+
if element.key?(:heading)
|
|
243
|
+
level = element[:heading].to_s.length
|
|
244
|
+
text = element[:text] ? element[:text].to_s.strip : ''
|
|
245
|
+
heading = Heading.new(level: level, text: text)
|
|
246
|
+
# Apply IAL if present
|
|
247
|
+
apply_ial_to_element(heading, element[:ial]) if element.key?(:ial)
|
|
248
|
+
return heading
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
return HorizontalRule.new(style: '---') if element.key?(:hr)
|
|
252
|
+
|
|
253
|
+
# Fenced code block with language info
|
|
254
|
+
if element.key?(:info) && element.key?(:code_block)
|
|
255
|
+
language = element[:info].to_s.strip
|
|
256
|
+
code = extract_code(element[:code_block])
|
|
257
|
+
code_block = CodeBlock.new(language: language, code: code)
|
|
258
|
+
apply_ial_to_element(code_block, element[:ial]) if element.key?(:ial)
|
|
259
|
+
return code_block
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if element.key?(:code_block)
|
|
263
|
+
code = extract_code(element[:code_block])
|
|
264
|
+
code_block = CodeBlock.new(code: code)
|
|
265
|
+
apply_ial_to_element(code_block, element[:ial]) if element.key?(:ial)
|
|
266
|
+
return code_block
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
if element.key?(:block_quote)
|
|
270
|
+
content = element[:block_quote]
|
|
271
|
+
transformed = content.is_a?(Array) ? content.map { |c| transform_element(c) } : [transform_element(content)]
|
|
272
|
+
text = transformed.compact.map do |c|
|
|
273
|
+
c.is_a?(Base) && c.class.attributes.key?(:text) ? c.text : c.to_s
|
|
274
|
+
end.join("\n")
|
|
275
|
+
blockquote = Blockquote.new(content: text)
|
|
276
|
+
apply_ial_to_element(blockquote, element[:ial]) if element.key?(:ial)
|
|
277
|
+
return blockquote
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
if element.key?(:p)
|
|
281
|
+
text = extract_text_from_p(element[:p])
|
|
282
|
+
paragraph = Paragraph.new(text: text)
|
|
283
|
+
apply_ial_to_element(paragraph, element[:ial]) if element.key?(:ial)
|
|
284
|
+
return paragraph
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Definition list
|
|
288
|
+
return transform_definition_list(element[:dl]) if element.key?(:dl)
|
|
289
|
+
|
|
290
|
+
# Footnote definition
|
|
291
|
+
if element.key?(:fn_id)
|
|
292
|
+
content = if element[:fn_content_continued]
|
|
293
|
+
lines = [element[:fn_content]]
|
|
294
|
+
lines += Array(element[:fn_content_continued])
|
|
295
|
+
lines.map { |l| extract_text(l) }.join("\n")
|
|
296
|
+
else
|
|
297
|
+
extract_text(element[:fn_content])
|
|
298
|
+
end
|
|
299
|
+
return Footnote.new(id: element[:fn_id].to_s, content: content.strip)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Abbreviation definition
|
|
303
|
+
if element.key?(:abbr_term)
|
|
304
|
+
return Abbreviation.new(
|
|
305
|
+
term: element[:abbr_term].to_s,
|
|
306
|
+
definition: element[:abbr_def].to_s.strip
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Transform definition list
|
|
314
|
+
def transform_definition_list(dl_content)
|
|
315
|
+
# The parser outputs term and definition as separate items
|
|
316
|
+
# We need to group them: [{:def_term=>...}, {:def_content=>...}, ...]
|
|
317
|
+
items = []
|
|
318
|
+
current_term = nil
|
|
319
|
+
current_definitions = []
|
|
320
|
+
|
|
321
|
+
Array(dl_content).each do |item|
|
|
322
|
+
next unless item.is_a?(Hash)
|
|
323
|
+
|
|
324
|
+
if item.key?(:def_term)
|
|
325
|
+
# Save previous term if exists
|
|
326
|
+
if current_term
|
|
327
|
+
items << DefinitionTerm.new(
|
|
328
|
+
text: current_term.strip,
|
|
329
|
+
definitions: current_definitions
|
|
330
|
+
)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Start new term
|
|
334
|
+
current_term = extract_text(item[:def_term])
|
|
335
|
+
current_definitions = []
|
|
336
|
+
elsif item.key?(:def_content)
|
|
337
|
+
# Add definition to current term
|
|
338
|
+
content = extract_text(item[:def_content])
|
|
339
|
+
current_definitions << DefinitionItem.new(content: content.strip)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Don't forget the last term
|
|
344
|
+
if current_term
|
|
345
|
+
items << DefinitionTerm.new(
|
|
346
|
+
text: current_term.strip,
|
|
347
|
+
definitions: current_definitions
|
|
348
|
+
)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
DefinitionList.new(items: items)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Apply IAL attributes to an element
|
|
355
|
+
def apply_ial_to_element(element, ial_content)
|
|
356
|
+
attrs = parse_ial_content(ial_content.to_s)
|
|
357
|
+
element.id = attrs[:id] if attrs[:id]
|
|
358
|
+
element.classes = attrs[:classes] if attrs[:classes]
|
|
359
|
+
element.attributes = attrs[:attributes] if attrs[:attributes]
|
|
360
|
+
element
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Parse IAL content string into components
|
|
364
|
+
# Delegates to shared IalParser for consistent parsing
|
|
365
|
+
def parse_ial_content(content)
|
|
366
|
+
ParserUtil::IalParser.parse_to_hash(content)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Parse extension options string into hash
|
|
370
|
+
def parse_extension_options(content)
|
|
371
|
+
return {} if content.nil? || content.empty?
|
|
372
|
+
|
|
373
|
+
result = {}
|
|
374
|
+
scanner = StringScanner.new(content.strip)
|
|
375
|
+
|
|
376
|
+
until scanner.eos?
|
|
377
|
+
scanner.skip(/\s+/)
|
|
378
|
+
break if scanner.eos?
|
|
379
|
+
|
|
380
|
+
if scanner.scan(/(\w[\w-]*)\s*=\s*/)
|
|
381
|
+
key = scanner[1]
|
|
382
|
+
value = if scanner.scan(/"([^"\\]*)"/)
|
|
383
|
+
scanner[1]
|
|
384
|
+
elsif scanner.scan(/'([^'\\]*)'/)
|
|
385
|
+
scanner[1]
|
|
386
|
+
elsif scanner.scan(/(\S+)/)
|
|
387
|
+
scanner[1]
|
|
388
|
+
else
|
|
389
|
+
''
|
|
390
|
+
end
|
|
391
|
+
result[key] = value
|
|
392
|
+
else
|
|
393
|
+
# Skip unrecognized character to avoid infinite loop
|
|
394
|
+
scanner.scan(/./)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
result
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Parse IAL element (can be a reference or full IAL)
|
|
402
|
+
def parse_ial_element(ial_content)
|
|
403
|
+
content = ial_content.to_s.strip
|
|
404
|
+
# Check if it's just a name reference (no . or #)
|
|
405
|
+
if content =~ /\A\w+\z/ && @ald_registry.key?(content)
|
|
406
|
+
@ald_registry[content]
|
|
407
|
+
else
|
|
408
|
+
attrs = parse_ial_content(content)
|
|
409
|
+
AttributeList.new(
|
|
410
|
+
id: attrs[:id],
|
|
411
|
+
classes: attrs[:classes],
|
|
412
|
+
attributes: attrs[:attributes]
|
|
413
|
+
)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Extract text from paragraph structure
|
|
418
|
+
def extract_text_from_p(p)
|
|
419
|
+
case p
|
|
420
|
+
when Hash
|
|
421
|
+
p[:ln].to_s
|
|
422
|
+
when Array
|
|
423
|
+
p.map { |l| l.is_a?(Hash) ? l[:ln].to_s : l.to_s }.join("\n")
|
|
424
|
+
else
|
|
425
|
+
p.to_s
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Extract code from code_block structure
|
|
430
|
+
def extract_code(code_block)
|
|
431
|
+
case code_block
|
|
432
|
+
when Array
|
|
433
|
+
code_block.map { |l| l.is_a?(Hash) ? l[:ln].to_s : l.to_s }.join("\n")
|
|
434
|
+
when Hash
|
|
435
|
+
code_block[:ln].to_s
|
|
436
|
+
else
|
|
437
|
+
code_block.to_s
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Extract text content from nested structures
|
|
442
|
+
def extract_text(content)
|
|
443
|
+
case content
|
|
444
|
+
when Array
|
|
445
|
+
content.map { |c| extract_text(c) }.join
|
|
446
|
+
when Hash
|
|
447
|
+
if content.key?(:text)
|
|
448
|
+
content[:text].to_s
|
|
449
|
+
elsif content.key?(:ln)
|
|
450
|
+
content[:ln].to_s
|
|
451
|
+
else
|
|
452
|
+
content.values.map { |v| extract_text(v) }.join
|
|
453
|
+
end
|
|
454
|
+
when Parslet::Slice
|
|
455
|
+
content.to_s
|
|
456
|
+
else
|
|
457
|
+
content.to_s
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# coradoc-markdown - Markdown document model, parser, and serializer
|
|
4
|
+
#
|
|
5
|
+
# This gem provides Markdown support for the Coradoc document processing library.
|
|
6
|
+
# It includes:
|
|
7
|
+
# - Markdown Document Model (Coradoc::Markdown::*)
|
|
8
|
+
# - Markdown Parser (CommonMark-compliant, Parslet-based)
|
|
9
|
+
# - Markdown Serializer (round-trip capable)
|
|
10
|
+
# - Kramdown extensions support (IAL, ALD, math, TOC)
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# require 'coradoc/markdown'
|
|
14
|
+
#
|
|
15
|
+
# # Parse Markdown content
|
|
16
|
+
# document = Coradoc::Markdown.parse("# Title\n\nContent")
|
|
17
|
+
#
|
|
18
|
+
# # Serialize back to Markdown
|
|
19
|
+
# output = Coradoc::Markdown.serialize(document)
|
|
20
|
+
|
|
21
|
+
require 'parslet'
|
|
22
|
+
require 'lutaml/model'
|
|
23
|
+
|
|
24
|
+
module Coradoc
|
|
25
|
+
module Markdown
|
|
26
|
+
# Error classes
|
|
27
|
+
autoload :Errors, 'coradoc/markdown/errors'
|
|
28
|
+
|
|
29
|
+
# Autoload model classes
|
|
30
|
+
autoload :Base, 'coradoc/markdown/model/base'
|
|
31
|
+
autoload :Document, 'coradoc/markdown/model/document'
|
|
32
|
+
autoload :Heading, 'coradoc/markdown/model/heading'
|
|
33
|
+
autoload :Paragraph, 'coradoc/markdown/model/paragraph'
|
|
34
|
+
autoload :Text, 'coradoc/markdown/model/text'
|
|
35
|
+
autoload :List, 'coradoc/markdown/model/list'
|
|
36
|
+
autoload :ListItem, 'coradoc/markdown/model/list_item'
|
|
37
|
+
autoload :CodeBlock, 'coradoc/markdown/model/code_block'
|
|
38
|
+
autoload :Blockquote, 'coradoc/markdown/model/blockquote'
|
|
39
|
+
autoload :Link, 'coradoc/markdown/model/link'
|
|
40
|
+
autoload :Image, 'coradoc/markdown/model/image'
|
|
41
|
+
autoload :HorizontalRule, 'coradoc/markdown/model/horizontal_rule'
|
|
42
|
+
autoload :Table, 'coradoc/markdown/model/table'
|
|
43
|
+
autoload :Emphasis, 'coradoc/markdown/model/emphasis'
|
|
44
|
+
autoload :Strong, 'coradoc/markdown/model/strong'
|
|
45
|
+
autoload :Code, 'coradoc/markdown/model/code'
|
|
46
|
+
autoload :DefinitionList, 'coradoc/markdown/model/definition_list'
|
|
47
|
+
autoload :DefinitionTerm, 'coradoc/markdown/model/definition_term'
|
|
48
|
+
autoload :DefinitionItem, 'coradoc/markdown/model/definition_item'
|
|
49
|
+
autoload :Footnote, 'coradoc/markdown/model/footnote'
|
|
50
|
+
autoload :FootnoteReference, 'coradoc/markdown/model/footnote_reference'
|
|
51
|
+
autoload :Abbreviation, 'coradoc/markdown/model/abbreviation'
|
|
52
|
+
autoload :AttributeList, 'coradoc/markdown/model/attribute_list'
|
|
53
|
+
autoload :Math, 'coradoc/markdown/model/math'
|
|
54
|
+
autoload :Extension, 'coradoc/markdown/model/extension'
|
|
55
|
+
autoload :Strikethrough, 'coradoc/markdown/model/strikethrough'
|
|
56
|
+
autoload :Highlight, 'coradoc/markdown/model/highlight'
|
|
57
|
+
|
|
58
|
+
# Serializer
|
|
59
|
+
autoload :Serializer, 'coradoc/markdown/serializer'
|
|
60
|
+
|
|
61
|
+
# TOC Generator
|
|
62
|
+
autoload :TocGenerator, 'coradoc/markdown/toc_generator'
|
|
63
|
+
|
|
64
|
+
# Transformer (AST to Model)
|
|
65
|
+
autoload :Transformer, 'coradoc/markdown/transformer'
|
|
66
|
+
|
|
67
|
+
# CoreModel transformers
|
|
68
|
+
module Transform
|
|
69
|
+
autoload :TextExtraction, 'coradoc/markdown/transform/text_extraction'
|
|
70
|
+
autoload :ToCoreModel, 'coradoc/markdown/transform/to_core_model'
|
|
71
|
+
autoload :FromCoreModel, 'coradoc/markdown/transform/from_core_model'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Parser module namespace
|
|
75
|
+
module Parser
|
|
76
|
+
autoload :BlockParser, 'coradoc/markdown/parser/block_parser'
|
|
77
|
+
autoload :InlineParser, 'coradoc/markdown/parser/inline_parser'
|
|
78
|
+
autoload :ParsletExtras, 'coradoc/markdown/parser/parslet_extras'
|
|
79
|
+
autoload :HTML_ENTITIES, 'coradoc/markdown/parser/html_entities'
|
|
80
|
+
autoload :AstProcessor, 'coradoc/markdown/parser/ast_processor'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Shared parser utilities
|
|
84
|
+
autoload :ParserUtil, 'coradoc/markdown/parser_util'
|
|
85
|
+
|
|
86
|
+
# Convenience accessors for kramdown extension models
|
|
87
|
+
class << self
|
|
88
|
+
# Access AttributeList class
|
|
89
|
+
def AttributeList
|
|
90
|
+
@AttributeList ||= const_get(:AttributeList)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Access Math class
|
|
94
|
+
def Math
|
|
95
|
+
@Math ||= const_get(:Math)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Access Extension class
|
|
99
|
+
def Extension
|
|
100
|
+
@Extension ||= const_get(:Extension)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class << self
|
|
105
|
+
# Parse Markdown content into a Document model
|
|
106
|
+
#
|
|
107
|
+
# @param content [String] The Markdown content to parse
|
|
108
|
+
# @param options [Hash] Parsing options
|
|
109
|
+
# @return [Coradoc::Markdown::Document] The parsed document model
|
|
110
|
+
def parse(content, _options = {})
|
|
111
|
+
ast = Parser::BlockParser.new.parse(content)
|
|
112
|
+
Transformer.transform_document(ast)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Parse raw AST (for debugging)
|
|
116
|
+
#
|
|
117
|
+
# @param content [String] The Markdown content to parse
|
|
118
|
+
# @return [Array] The raw AST
|
|
119
|
+
def parse_ast(content)
|
|
120
|
+
Parser::BlockParser.new.parse(content)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Parse Markdown from a file
|
|
124
|
+
#
|
|
125
|
+
# @param filename [String] Path to the Markdown file
|
|
126
|
+
# @param options [Hash] Parsing options (see #parse)
|
|
127
|
+
# @return [Array] The parsed AST
|
|
128
|
+
def from_file(filename, **options)
|
|
129
|
+
content = File.read(filename)
|
|
130
|
+
parse(content, **options)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Parse inline Markdown content
|
|
134
|
+
#
|
|
135
|
+
# @param content [String] The inline Markdown content to parse
|
|
136
|
+
# @return [Array] The parsed inline elements
|
|
137
|
+
def parse_inline(content)
|
|
138
|
+
Parser::InlineParser.new.parse(content)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Serialize a document model to Markdown string
|
|
142
|
+
#
|
|
143
|
+
# @param document [Coradoc::Markdown::Document] The document to serialize
|
|
144
|
+
# @param options [Hash] Serialization options
|
|
145
|
+
# @return [String] The Markdown output
|
|
146
|
+
def serialize(document, options = {})
|
|
147
|
+
Serializer.serialize(document, options)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Check if this format can transform the given model to CoreModel
|
|
151
|
+
#
|
|
152
|
+
# @param model [Object] The model to check
|
|
153
|
+
# @return [Boolean] true if this format handles the model type
|
|
154
|
+
def handles_model?(model)
|
|
155
|
+
model.is_a?(Coradoc::Markdown::Base)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Transform Markdown model to CoreModel
|
|
159
|
+
#
|
|
160
|
+
# @param document [Coradoc::Markdown::Document] The Markdown document
|
|
161
|
+
# @return [Coradoc::CoreModel::StructuralElement] The CoreModel document
|
|
162
|
+
def to_core_model(document)
|
|
163
|
+
Transform::ToCoreModel.transform(document)
|
|
164
|
+
end
|
|
165
|
+
alias to_core to_core_model
|
|
166
|
+
|
|
167
|
+
# Parse and transform to CoreModel in one step
|
|
168
|
+
#
|
|
169
|
+
# @param content [String] The Markdown content
|
|
170
|
+
# @return [Coradoc::CoreModel::StructuralElement] The CoreModel document
|
|
171
|
+
def parse_to_core(content)
|
|
172
|
+
doc = parse(content)
|
|
173
|
+
to_core_model(doc)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Transform CoreModel to Markdown model
|
|
177
|
+
#
|
|
178
|
+
# @param core_document [Coradoc::CoreModel::StructuralElement] The CoreModel document
|
|
179
|
+
# @return [Coradoc::Markdown::Document] The Markdown document
|
|
180
|
+
def from_core_model(core_document)
|
|
181
|
+
Transform::FromCoreModel.transform(core_document)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Register the Markdown format with Coradoc
|
|
187
|
+
register_format(:markdown, Markdown,
|
|
188
|
+
aliases: %w[md markdown mdown mkd],
|
|
189
|
+
extensions: %w[.md .markdown .mdown .mkd])
|
|
190
|
+
end
|