coradoc-mirror 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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/lib/coradoc/mirror/core_model_to_mirror.rb +181 -0
  3. data/lib/coradoc/mirror/handler_registry.rb +105 -0
  4. data/lib/coradoc/mirror/handlers/admonition.rb +29 -0
  5. data/lib/coradoc/mirror/handlers/bibliography.rb +43 -0
  6. data/lib/coradoc/mirror/handlers/blockquote.rb +19 -0
  7. data/lib/coradoc/mirror/handlers/code_block.rb +69 -0
  8. data/lib/coradoc/mirror/handlers/comment.rb +14 -0
  9. data/lib/coradoc/mirror/handlers/definition_list.rb +69 -0
  10. data/lib/coradoc/mirror/handlers/example.rb +19 -0
  11. data/lib/coradoc/mirror/handlers/footnote.rb +18 -0
  12. data/lib/coradoc/mirror/handlers/frontmatter.rb +71 -0
  13. data/lib/coradoc/mirror/handlers/generic_block.rb +24 -0
  14. data/lib/coradoc/mirror/handlers/horizontal_rule.rb +14 -0
  15. data/lib/coradoc/mirror/handlers/image.rb +58 -0
  16. data/lib/coradoc/mirror/handlers/inline.rb +213 -0
  17. data/lib/coradoc/mirror/handlers/list.rb +80 -0
  18. data/lib/coradoc/mirror/handlers/open_block.rb +16 -0
  19. data/lib/coradoc/mirror/handlers/paragraph.rb +16 -0
  20. data/lib/coradoc/mirror/handlers/reviewer.rb +14 -0
  21. data/lib/coradoc/mirror/handlers/sidebar.rb +19 -0
  22. data/lib/coradoc/mirror/handlers/structural.rb +84 -0
  23. data/lib/coradoc/mirror/handlers/table.rb +82 -0
  24. data/lib/coradoc/mirror/handlers/toc.rb +48 -0
  25. data/lib/coradoc/mirror/handlers/verse.rb +22 -0
  26. data/lib/coradoc/mirror/handlers.rb +38 -0
  27. data/lib/coradoc/mirror/mark.rb +181 -0
  28. data/lib/coradoc/mirror/mark_reverse_builder.rb +142 -0
  29. data/lib/coradoc/mirror/mirror_json_format.rb +42 -0
  30. data/lib/coradoc/mirror/mirror_to_core_model.rb +73 -0
  31. data/lib/coradoc/mirror/mirror_yaml_format.rb +41 -0
  32. data/lib/coradoc/mirror/node.rb +856 -0
  33. data/lib/coradoc/mirror/output.rb +62 -0
  34. data/lib/coradoc/mirror/partitioner.rb +62 -0
  35. data/lib/coradoc/mirror/reverse_builder.rb +600 -0
  36. data/lib/coradoc/mirror/transformer.rb +41 -0
  37. data/lib/coradoc/mirror/version.rb +7 -0
  38. data/lib/coradoc/mirror.rb +161 -0
  39. data/lib/coradoc-mirror.rb +14 -0
  40. metadata +140 -0
@@ -0,0 +1,600 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Mirror
5
+ # OCP-compliant registry for Mirror node -> CoreModel transformation.
6
+ #
7
+ # Adding support for a new Mirror node type is purely additive:
8
+ #
9
+ # module ReverseBuilder
10
+ # class Figure < Base
11
+ # registers 'figure'
12
+ #
13
+ # def build(node)
14
+ # CoreModel::Image.new(src: node.src, ...)
15
+ # end
16
+ # end
17
+ # end
18
+ #
19
+ # No edits to MirrorToCoreModel or any other existing class. The
20
+ # registry is the single source of truth for "which type string maps
21
+ # to which builder" (MECE).
22
+ #
23
+ # This file is the autoload target for the ReverseBuilder constant
24
+ # (see coradoc/mirror.rb). All built-in Builder subclasses live here
25
+ # so their `registers` calls run at load time and the REGISTRY is
26
+ # full before any caller references it. Mirror-level mark dispatch
27
+ # lives in MarkReverseBuilder (mark_reverse_builder.rb).
28
+ module ReverseBuilder
29
+ REGISTRY = {}
30
+
31
+ module_function
32
+
33
+ def register(type, builder_class)
34
+ REGISTRY[type] = builder_class
35
+ end
36
+
37
+ def lookup(type)
38
+ REGISTRY[type]
39
+ end
40
+
41
+ def registered_types
42
+ REGISTRY.keys
43
+ end
44
+
45
+ # Base class for all reverse builders. Subclasses register one or
46
+ # more Mirror type strings via `registers` and implement `#build`.
47
+ # Shared helpers (build_content, extract_text, apply_mark, ...) are
48
+ # delegated to the context (a MirrorToCoreModel instance), keeping
49
+ # each builder focused on the per-type mapping only (DRY).
50
+ class Base
51
+ attr_reader :context
52
+
53
+ def initialize(context)
54
+ @context = context
55
+ end
56
+
57
+ def build(_node)
58
+ raise NotImplementedError,
59
+ "#{self.class} must implement #build(node)"
60
+ end
61
+
62
+ # Shared helpers — all delegate to the context (DRY).
63
+ def build_content(node) = context.build_content(node)
64
+ def build_inline_children(node) = context.build_inline_children(node)
65
+ def build_node(node) = context.build_node(node)
66
+ def extract_text(node) = context.extract_text(node)
67
+ def apply_mark(inner, mark) = context.apply_mark(inner, mark)
68
+ def inline_content(element) = context.inline_content(element)
69
+
70
+ class << self
71
+ # DSL: declare which Mirror type strings this builder handles.
72
+ # Multiple strings per builder are allowed (e.g. all JS
73
+ # SECTION_TYPES route to the same SectionElement builder).
74
+ def registers(*types)
75
+ types.each { |t| ReverseBuilder.register(t, self) }
76
+ end
77
+ end
78
+ end
79
+
80
+ # ── Structural ──
81
+
82
+ class Document < Base
83
+ registers 'doc'
84
+
85
+ def build(node)
86
+ attrs = node.attrs
87
+ CoreModel::DocumentElement.new(
88
+ title: attrs&.title,
89
+ id: attrs&.id,
90
+ children: build_content(node)
91
+ )
92
+ end
93
+ end
94
+
95
+ # All JS SECTION_TYPES reverse to a generic SectionElement. Style
96
+ # information lives in the original AsciiDoc and is preserved only
97
+ # on the forward side; the reverse side collapses them.
98
+ class Section < Base
99
+ registers 'section', 'clause', 'annex', 'content_section',
100
+ 'abstract', 'foreword', 'introduction',
101
+ 'acknowledgements', 'terms', 'definitions', 'references'
102
+
103
+ def build(node)
104
+ attrs = node.attrs
105
+ CoreModel::SectionElement.new(
106
+ title: attrs&.title,
107
+ level: attrs&.level,
108
+ id: attrs&.id,
109
+ children: build_content(node)
110
+ )
111
+ end
112
+ end
113
+
114
+ class Header < Base
115
+ registers 'floating_title', 'heading'
116
+
117
+ def build(node)
118
+ attrs = node.attrs
119
+ CoreModel::HeaderElement.new(
120
+ title: attrs&.title,
121
+ level: attrs&.level,
122
+ children: build_content(node)
123
+ )
124
+ end
125
+ end
126
+
127
+ class Preamble < Base
128
+ registers 'preface'
129
+
130
+ def build(node)
131
+ CoreModel::PreambleElement.new(children: build_content(node))
132
+ end
133
+ end
134
+
135
+ # `sections` is a structural container only — unwrap directly into
136
+ # an array. MirrorToCoreModel#build_content flattens arrays.
137
+ class Sections < Base
138
+ registers 'sections'
139
+
140
+ def build(node)
141
+ build_content(node)
142
+ end
143
+ end
144
+
145
+ # ── Blocks ──
146
+
147
+ class Paragraph < Base
148
+ registers 'paragraph'
149
+
150
+ def build(node)
151
+ CoreModel::ParagraphBlock.new(children: build_inline_children(node))
152
+ end
153
+ end
154
+
155
+ class CodeBlock < Base
156
+ registers 'sourcecode'
157
+
158
+ def build(node)
159
+ attrs = node.attrs
160
+ CoreModel::SourceBlock.new(
161
+ content: attrs&.text || extract_text(node),
162
+ language: attrs&.language,
163
+ title: attrs&.title
164
+ )
165
+ end
166
+ end
167
+
168
+ class Blockquote < Base
169
+ registers 'quote'
170
+
171
+ def build(node)
172
+ CoreModel::QuoteBlock.new(
173
+ attribution: node.attrs&.attribution,
174
+ children: build_content(node)
175
+ )
176
+ end
177
+ end
178
+
179
+ class Example < Base
180
+ registers 'example'
181
+
182
+ def build(node)
183
+ CoreModel::ExampleBlock.new(
184
+ title: node.attrs&.title,
185
+ children: build_content(node)
186
+ )
187
+ end
188
+ end
189
+
190
+ class Sidebar < Base
191
+ registers 'sidebar'
192
+
193
+ def build(node)
194
+ CoreModel::SidebarBlock.new(
195
+ title: node.attrs&.title,
196
+ children: build_content(node)
197
+ )
198
+ end
199
+ end
200
+
201
+ class OpenBlock < Base
202
+ registers 'open_block'
203
+
204
+ def build(node)
205
+ CoreModel::OpenBlock.new(children: build_content(node))
206
+ end
207
+ end
208
+
209
+ class Verse < Base
210
+ registers 'verse'
211
+
212
+ def build(node)
213
+ CoreModel::VerseBlock.new(
214
+ content: extract_text(node),
215
+ attribution: node.attrs&.attribution
216
+ )
217
+ end
218
+ end
219
+
220
+ class HorizontalRule < Base
221
+ registers 'horizontal_rule', 'thematic_break'
222
+
223
+ def build(_node)
224
+ CoreModel::HorizontalRuleBlock.new
225
+ end
226
+ end
227
+
228
+ class Frontmatter < Base
229
+ registers 'frontmatter'
230
+
231
+ def build(node)
232
+ attrs = node.attrs
233
+ CoreModel::FrontmatterBlock.new(
234
+ schema: attrs&.schema,
235
+ data: FrontmatterTreeToHash.to_hash(attrs&.entries || [])
236
+ )
237
+ end
238
+ end
239
+
240
+ class Admonition < Base
241
+ registers 'admonition'
242
+
243
+ def build(node)
244
+ CoreModel::AnnotationBlock.new(
245
+ annotation_type: node.attrs&.admonition_type,
246
+ content: extract_text(node)
247
+ )
248
+ end
249
+ end
250
+
251
+ # ── Lists ──
252
+
253
+ class BulletList < Base
254
+ registers 'bullet_list'
255
+
256
+ def build(node)
257
+ items = build_content(node).select { |c| c.is_a?(CoreModel::ListItem) }
258
+ CoreModel::ListBlock.new(marker_type: 'unordered', items: items)
259
+ end
260
+ end
261
+
262
+ class OrderedList < Base
263
+ registers 'ordered_list'
264
+
265
+ def build(node)
266
+ items = build_content(node).select { |c| c.is_a?(CoreModel::ListItem) }
267
+ CoreModel::ListBlock.new(marker_type: 'ordered', items: items)
268
+ end
269
+ end
270
+
271
+ class ListItem < Base
272
+ registers 'list_item'
273
+
274
+ def build(node)
275
+ children = build_inline_children(node)
276
+ text = children.map { |c| c.is_a?(CoreModel::TextContent) ? c.text : '' }.join
277
+
278
+ CoreModel::ListItem.new(
279
+ content: text,
280
+ children: children,
281
+ nested_list: find_nested_list(node)
282
+ )
283
+ end
284
+
285
+ private
286
+
287
+ def find_nested_list(node)
288
+ node.content&.each do |child|
289
+ next unless child.is_a?(Node)
290
+ return build_node(child) if LIST_TYPES.include?(child.type)
291
+ end
292
+ nil
293
+ end
294
+ end
295
+
296
+ class DefinitionList < Base
297
+ registers 'dl'
298
+
299
+ def build(node)
300
+ terms = []
301
+ descriptions = []
302
+ node.content&.each do |child|
303
+ next unless child.is_a?(Node)
304
+
305
+ case child.type
306
+ when 'dt' then terms << build_node(child)
307
+ when 'dd' then descriptions << build_node(child)
308
+ end
309
+ end
310
+
311
+ items = terms.zip(descriptions).map do |term, desc|
312
+ CoreModel::DefinitionItem.new(
313
+ term: inline_content(term),
314
+ definitions: [inline_content(desc)]
315
+ )
316
+ end
317
+
318
+ CoreModel::DefinitionList.new(items: items)
319
+ end
320
+ end
321
+
322
+ class InlineText < Base
323
+ registers 'dt', 'dd'
324
+
325
+ def build(node)
326
+ children = build_inline_children(node)
327
+ text = children.map { |c| c.is_a?(CoreModel::TextContent) ? c.text : '' }.join
328
+ CoreModel::InlineElement.new(content: text)
329
+ end
330
+ end
331
+
332
+ # ── Media ──
333
+
334
+ class Image < Base
335
+ registers 'image'
336
+
337
+ def build(node)
338
+ attrs = node.attrs
339
+ CoreModel::Image.new(
340
+ src: attrs&.src,
341
+ alt: attrs&.alt,
342
+ title: attrs&.title,
343
+ caption: attrs&.caption,
344
+ width: attrs&.width,
345
+ height: attrs&.height
346
+ )
347
+ end
348
+ end
349
+
350
+ # JS @metanorma/mirror `figure` wraps an image plus an optional
351
+ # caption. Reverse: collapse back to a single CoreModel::Image,
352
+ # promoting the caption child to `caption:` if present.
353
+ class Figure < Base
354
+ registers 'figure'
355
+
356
+ def build(node)
357
+ image_child = node.content&.find { |c| c.is_a?(Node) && c.type == 'image' }
358
+ return nil unless image_child
359
+
360
+ image = build_node(image_child)
361
+ caption = extract_caption(node)
362
+ image.caption = caption if caption && !image.caption
363
+ image
364
+ end
365
+
366
+ private
367
+
368
+ def extract_caption(node)
369
+ caption_node = node.content&.find { |c| c.is_a?(Node) && c.type == 'caption' }
370
+ return nil unless caption_node
371
+
372
+ extract_text(caption_node)
373
+ end
374
+ end
375
+
376
+ # Caption only appears as a Figure child. If encountered standalone,
377
+ # extract its text as an inline element so it isn't lost.
378
+ class Caption < Base
379
+ registers 'caption'
380
+
381
+ def build(node)
382
+ CoreModel::InlineElement.new(content: extract_text(node))
383
+ end
384
+ end
385
+
386
+ # ── Tables ──
387
+
388
+ class Table < Base
389
+ registers 'table'
390
+
391
+ def build(node)
392
+ rows = []
393
+ node.content&.each do |child|
394
+ next unless child.is_a?(Node)
395
+ next unless %w[table_head table_body].include?(child.type)
396
+
397
+ child.content&.each do |row_node|
398
+ rows << build_node(row_node) if row_node.is_a?(Node)
399
+ end
400
+ end
401
+
402
+ CoreModel::Table.new(title: node.attrs&.title, rows: rows)
403
+ end
404
+ end
405
+
406
+ class TableHead < Base
407
+ registers 'table_head'
408
+
409
+ def build(node)
410
+ build_content(node).first || CoreModel::TableRow.new
411
+ end
412
+ end
413
+
414
+ class TableBody < Base
415
+ registers 'table_body'
416
+
417
+ def build(node)
418
+ build_content(node).first || CoreModel::TableRow.new
419
+ end
420
+ end
421
+
422
+ class TableRow < Base
423
+ registers 'table_row'
424
+
425
+ def build(node)
426
+ cells = build_content(node).select { |c| c.is_a?(CoreModel::TableCell) }
427
+ CoreModel::TableRow.new(cells: cells)
428
+ end
429
+ end
430
+
431
+ class TableCell < Base
432
+ registers 'table_cell'
433
+
434
+ def build(node)
435
+ attrs = node.attrs
436
+ CoreModel::TableCell.new(
437
+ content: extract_text(node),
438
+ header: attrs&.header || false,
439
+ colspan: attrs&.colspan,
440
+ rowspan: attrs&.rowspan,
441
+ alignment: attrs&.alignment
442
+ )
443
+ end
444
+ end
445
+
446
+ # ── Bibliography ──
447
+
448
+ class Bibliography < Base
449
+ registers 'bibliography'
450
+
451
+ def build(node)
452
+ entries = build_content(node).select { |c| c.is_a?(CoreModel::BibliographyEntry) }
453
+ CoreModel::Bibliography.new(title: node.attrs&.title, entries: entries)
454
+ end
455
+ end
456
+
457
+ class BiblioEntry < Base
458
+ registers 'biblio_entry'
459
+
460
+ def build(node)
461
+ attrs = node.attrs
462
+ CoreModel::BibliographyEntry.new(
463
+ anchor_name: attrs&.anchor_name,
464
+ document_id: attrs&.document_id,
465
+ ref_text: extract_text(node)
466
+ )
467
+ end
468
+ end
469
+
470
+ # ── Footnotes ──
471
+
472
+ # The `footnotes` block is a structural trailing container; it has
473
+ # no CoreModel equivalent (each entry is built separately). Returns
474
+ # nil so build_content filters it out.
475
+ class Footnotes < Base
476
+ registers 'footnotes'
477
+
478
+ def build(_node)
479
+ nil
480
+ end
481
+ end
482
+
483
+ class FootnoteEntry < Base
484
+ registers 'footnote_entry'
485
+
486
+ def build(node)
487
+ attrs = node.attrs
488
+ CoreModel::Footnote.new(id: attrs&.id, content: extract_text(node))
489
+ end
490
+ end
491
+
492
+ # Inline footnote marker (JS `footnote_marker`). The CoreModel
493
+ # FootnoteReference holds the same id/ref/number triple.
494
+ class FootnoteMarker < Base
495
+ registers 'footnote_marker'
496
+
497
+ def build(node)
498
+ attrs = node.attrs
499
+ CoreModel::FootnoteReference.new(
500
+ id: attrs&.id,
501
+ reference: attrs&.ref_id,
502
+ number: attrs&.number
503
+ )
504
+ end
505
+ end
506
+
507
+ # ── TOC ──
508
+
509
+ class Toc < Base
510
+ registers 'toc'
511
+
512
+ def build(_node)
513
+ CoreModel::Toc.new
514
+ end
515
+ end
516
+
517
+ class TocEntry < Base
518
+ registers 'toc_entry'
519
+
520
+ def build(node)
521
+ attrs = node.attrs
522
+ CoreModel::TocEntry.new(id: attrs&.id, title: attrs&.title)
523
+ end
524
+ end
525
+
526
+ # ── Inline ──
527
+
528
+ class Text < Base
529
+ registers 'text'
530
+
531
+ def build(node)
532
+ text = node.text || ''
533
+ marks = node.marks || []
534
+
535
+ return CoreModel::TextContent.new(text: text) if marks.empty?
536
+
537
+ marks.reduce(CoreModel::TextContent.new(text: text)) do |current, mark|
538
+ apply_mark(current, mark)
539
+ end
540
+ end
541
+ end
542
+
543
+ class SoftBreak < Base
544
+ registers 'soft_break'
545
+
546
+ def build(_node)
547
+ CoreModel::LineBreakElement.new
548
+ end
549
+ end
550
+
551
+ # Catch-all for unrecognized block types emitted by the forward
552
+ # direction (`Node::GenericBlock`). Preserves the semantic_type so
553
+ # downstream consumers can dispatch on it.
554
+ class GenericBlock < Base
555
+ registers 'generic_block'
556
+
557
+ def build(node)
558
+ attrs = node.attrs
559
+ CoreModel::Block.new(
560
+ block_semantic_type: attrs&.semantic_type || 'generic',
561
+ title: attrs&.title,
562
+ id: attrs&.id,
563
+ content: extract_text(node)
564
+ )
565
+ end
566
+ end
567
+
568
+ LIST_TYPES = %w[bullet_list ordered_list].freeze
569
+ private_constant :LIST_TYPES
570
+
571
+ # Walks a typed FrontmatterEntry / FrontmatterValue tree and
572
+ # rebuilds the CoreModel `data` hash. Inverse of
573
+ # Handlers::Frontmatter.build_value.
574
+ module FrontmatterTreeToHash
575
+ module_function
576
+
577
+ def to_hash(entries)
578
+ entries.each_with_object({}) do |entry, result|
579
+ result[entry.key] = unwrap_value(entry.value)
580
+ end
581
+ end
582
+
583
+ def unwrap_value(value)
584
+ case value.value_type
585
+ when 'map' then to_hash(value.entries || [])
586
+ when 'array' then (value.items || []).map { |v| unwrap_value(v) }
587
+ when 'integer' then value.integer_value
588
+ when 'float' then value.float_value
589
+ when 'boolean' then value.boolean_value
590
+ when 'date' then value.date_value
591
+ when 'datetime' then value.datetime_value
592
+ when 'symbol' then value.symbol_value&.to_sym
593
+ when 'nil' then nil
594
+ else value.string_value
595
+ end
596
+ end
597
+ end
598
+ end
599
+ end
600
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Mirror
5
+ # Bidirectional facade for CoreModel ↔ Mirror transformation.
6
+ #
7
+ # Delegates to CoreModelToMirror (forward) and MirrorToCoreModel (reverse).
8
+ #
9
+ # @example Forward transformation
10
+ # transformer = Coradoc::Mirror::Transformer.new
11
+ # mirror_doc = transformer.from_core_model(document)
12
+ #
13
+ # @example Reverse transformation
14
+ # core_doc = transformer.to_core_model(mirror_doc)
15
+ #
16
+ class Transformer
17
+ def initialize(registry: Coradoc::Mirror.default_registry)
18
+ @registry = registry
19
+ end
20
+
21
+ # Convert CoreModel document to Mirror node tree.
22
+ #
23
+ # @param document [CoreModel::Base] CoreModel document
24
+ # @param partition_structural [Boolean] wrap doc.content in
25
+ # preface/sections/bibliography containers per the @metanorma/mirror
26
+ # JS structural contract (default: false).
27
+ # @return [Node::Document] mirror document root
28
+ def from_core_model(document, partition_structural: false)
29
+ CoreModelToMirror.new(registry: @registry).call(document, partition_structural: partition_structural)
30
+ end
31
+
32
+ # Convert Mirror node tree to CoreModel document.
33
+ #
34
+ # @param mirror_node [Node] mirror document root
35
+ # @return [CoreModel::Base] CoreModel document
36
+ def to_core_model(mirror_node)
37
+ MirrorToCoreModel.new.call(mirror_node)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Mirror
5
+ VERSION = '0.1.1'
6
+ end
7
+ end