coradoc-adoc 2.0.9 → 2.0.11

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/lib/coradoc/asciidoc/model/bibliography_entry.rb +18 -0
  3. data/lib/coradoc/asciidoc/model/document.rb +9 -0
  4. data/lib/coradoc/asciidoc/model/glossaries.rb +1 -1
  5. data/lib/coradoc/asciidoc/model/list/base.rb +41 -0
  6. data/lib/coradoc/asciidoc/model/list/core.rb +4 -24
  7. data/lib/coradoc/asciidoc/model/list/definition.rb +7 -0
  8. data/lib/coradoc/asciidoc/model/list/definition_item.rb +1 -1
  9. data/lib/coradoc/asciidoc/model/list/item.rb +1 -1
  10. data/lib/coradoc/asciidoc/model/list/nestable.rb +7 -3
  11. data/lib/coradoc/asciidoc/model/list.rb +4 -2
  12. data/lib/coradoc/asciidoc/parser/base.rb +10 -70
  13. data/lib/coradoc/asciidoc/parser/block.rb +19 -27
  14. data/lib/coradoc/asciidoc/parser/block_assembler.rb +37 -100
  15. data/lib/coradoc/asciidoc/parser/block_header.rb +55 -0
  16. data/lib/coradoc/asciidoc/parser/content.rb +10 -1
  17. data/lib/coradoc/asciidoc/parser/frontmatter_parser.rb +24 -0
  18. data/lib/coradoc/asciidoc/parser/paragraph.rb +1 -3
  19. data/lib/coradoc/asciidoc/parser/rule_dispatcher.rb +158 -0
  20. data/lib/coradoc/asciidoc/parser/section.rb +1 -3
  21. data/lib/coradoc/asciidoc/parser/table.rb +1 -4
  22. data/lib/coradoc/asciidoc/parser/text.rb +1 -3
  23. data/lib/coradoc/asciidoc/parser.rb +1 -0
  24. data/lib/coradoc/asciidoc/serializer/serializers/base.rb +1 -1
  25. data/lib/coradoc/asciidoc/serializer/serializers/document.rb +7 -0
  26. data/lib/coradoc/asciidoc/serializer/serializers/list/definition.rb +3 -1
  27. data/lib/coradoc/asciidoc/transform/callout_merger.rb +94 -0
  28. data/lib/coradoc/asciidoc/transform/element_transformers/block_transformer.rb +10 -1
  29. data/lib/coradoc/asciidoc/transform/element_transformers/document_transformer.rb +17 -1
  30. data/lib/coradoc/asciidoc/transform/element_transformers/other_transformer.rb +3 -1
  31. data/lib/coradoc/asciidoc/transform/from_core_model.rb +70 -5
  32. data/lib/coradoc/asciidoc/transform/from_core_model_registrations.rb +5 -1
  33. data/lib/coradoc/asciidoc/transform/frontmatter_attribute_map.rb +112 -0
  34. data/lib/coradoc/asciidoc/transform/text_extract_visitor.rb +33 -1
  35. data/lib/coradoc/asciidoc/transform/to_core_model.rb +10 -2
  36. data/lib/coradoc/asciidoc/transform/to_core_model_registrations.rb +15 -10
  37. data/lib/coradoc/asciidoc/transform.rb +2 -0
  38. data/lib/coradoc/asciidoc/transformer/attribute_list_normalizer.rb +69 -0
  39. data/lib/coradoc/asciidoc/transformer/block_rules.rb +4 -42
  40. data/lib/coradoc/asciidoc/transformer/block_type_classifier.rb +56 -0
  41. data/lib/coradoc/asciidoc/transformer/header_rules.rb +15 -53
  42. data/lib/coradoc/asciidoc/transformer/inline_rules.rb +39 -57
  43. data/lib/coradoc/asciidoc/transformer/misc_rules.rb +1 -24
  44. data/lib/coradoc/asciidoc/transformer/structural_rules.rb +18 -81
  45. data/lib/coradoc/asciidoc/transformer/table_cell_builder.rb +161 -0
  46. data/lib/coradoc/asciidoc/transformer/table_layout.rb +135 -0
  47. data/lib/coradoc/asciidoc/transformer/text_rules.rb +1 -25
  48. data/lib/coradoc/asciidoc/transformer.rb +38 -294
  49. data/lib/coradoc/asciidoc/version.rb +1 -1
  50. data/lib/coradoc/asciidoc.rb +6 -3
  51. metadata +11 -1
@@ -24,7 +24,7 @@ module Coradoc
24
24
  # :zero :one :many
25
25
  def text_line(many_breaks = false, unguarded: false, verbatim: false)
26
26
  tl = if verbatim
27
- text_any.as(:text)
27
+ raw_verbatim_line.as(:text)
28
28
  elsif unguarded
29
29
  literal_space? >> text_any.as(:text)
30
30
  else
@@ -38,6 +38,15 @@ module Coradoc
38
38
  end
39
39
  end
40
40
 
41
+ # A verbatim source/listing line: capture every character up to the
42
+ # next newline without applying any inline substitutions. Source and
43
+ # listing blocks must round-trip their text untouched — otherwise
44
+ # sequences like Liquid `{{ var }}` collide with AsciiDoc attribute
45
+ # references and get rewritten.
46
+ def raw_verbatim_line
47
+ (line_ending.absent? >> any).repeat(1)
48
+ end
49
+
41
50
  def asciidoc_char
42
51
  line_start? >> match['*_:+=\-']
43
52
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ module Parser
6
+ # AsciiDoc frontmatter extractor.
7
+ #
8
+ # Delegates to the shared +Coradoc::CoreModel::FrontmatterBlock::TextSplitter+
9
+ # — the single source of truth for the `---\n...\n---\n` convention
10
+ # (DRY). AsciiDoc retains a local parser module for discoverability
11
+ # and as the seam for any format-specific extensions should they arise
12
+ # (e.g., recognizing AsciiDoc-style front matter variants).
13
+ module FrontmatterParser
14
+ class << self
15
+ def call(text)
16
+ Coradoc::CoreModel::FrontmatterBlock::TextSplitter.call(text)
17
+ end
18
+ end
19
+
20
+ Result = Coradoc::CoreModel::FrontmatterBlock::TextSplitter::Result
21
+ end
22
+ end
23
+ end
24
+ end
@@ -40,9 +40,7 @@ module Coradoc
40
40
  # rubocop:enable Style/OptionalBooleanParameter, Style/NumericPredicate
41
41
 
42
42
  def paragraph
43
- (element_id.maybe >>
44
- block_title.maybe >>
45
- (attribute_list >> newline).maybe >>
43
+ (block_header >>
46
44
  ((paragraph_text_line(0).repeat(1, 1) >>
47
45
  (newline.repeat(1).as(:line_break) | eof?)) |
48
46
  (paragraph_text_line(false).repeat(1) >>
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ module Parser
6
+ # Wraps every parser rule for Parslet memoization.
7
+ #
8
+ # Parameterized rules (e.g., `block_style(n_deep, delimiter, repeater)`)
9
+ # cannot be memoized by Parslet directly because their result depends
10
+ # on the args. This module aliases each rule to a per-args dispatch
11
+ # rule so Parslet sees a memoizable, parameterless rule for every
12
+ # (rule_name, args) combination. Parameterless rules get a single
13
+ # memoized alias.
14
+ #
15
+ # Also warns when a parser method is defined in more than one parser
16
+ # module — duplicate definitions are usually a refactoring mistake
17
+ # and produce confusing "last one wins" behavior at include time.
18
+ #
19
+ # Invoked once at Parser::Base load time.
20
+ module RuleDispatcher
21
+ DISPATCH_CONFIG = {
22
+ add_dispatch: true,
23
+ with_params: true
24
+ }.freeze
25
+
26
+ class << self
27
+ # @param parser_class [Class] Parser::Base or a subclass
28
+ def apply(parser_class)
29
+ parser_methods = collect_rule_names
30
+ warn_on_duplicates(parser_methods)
31
+ wrap_rules(parser_class, parser_methods)
32
+ end
33
+
34
+ # Per-instance dispatch invoked from the parameterized rule wrappers.
35
+ # @param parser_instance [Parser::Base]
36
+ # @param alias_name [Symbol] The alias created at wrap time
37
+ # @param args [Array]
38
+ # @param kwargs [Hash]
39
+ def dispatch(parser_instance, alias_name, *args, **kwargs)
40
+ cache = dispatch_cache(parser_instance)
41
+ key = dispatch_key(alias_name, args, kwargs)
42
+ unless cache.key?(key)
43
+ rule_name = dispatch_rule_name(alias_name, key)
44
+ unless parser_instance.respond_to?(rule_name)
45
+ build_dispatch_rule(parser_instance.class, alias_name,
46
+ rule_name, args, kwargs)
47
+ end
48
+ cache[key] = rule_name
49
+ end
50
+ parser_instance.public_send(cache[key])
51
+ end
52
+
53
+ private
54
+
55
+ # Walk every parser module and collect rule-method names.
56
+ # `instance_methods(false)` returns only methods defined in the
57
+ # module itself, not inherited ones — without `false` we would
58
+ # also wrap Object#send, Kernel#__send__, Module#class, etc.
59
+ # Wrapping `__send__` in particular causes infinite recursion.
60
+ # @return [Hash{Symbol => Array<Module>}]
61
+ def collect_rule_names
62
+ parser_constants = Parser.constants - %i[Base Cache FixFiles RuleDispatcher]
63
+ parser_constants.each_with_object({}) do |const, acc|
64
+ Parser.const_get(const).instance_methods(false).each do |name|
65
+ acc[name] ||= []
66
+ acc[name] << const
67
+ end
68
+ end
69
+ end
70
+
71
+ def warn_on_duplicates(parser_methods)
72
+ parser_methods.each do |name, sites|
73
+ next unless sites.size > 1
74
+
75
+ modules = sites.map { |c| Parser.const_get(c) }
76
+ Coradoc::Logger.warn(
77
+ "Parser method '#{name}' is defined #{sites.size} times in #{modules.join(', ')}"
78
+ )
79
+ end
80
+ end
81
+
82
+ def wrap_rules(parser_class, parser_methods)
83
+ parser_methods.each_key do |rule_name|
84
+ params = parser_class.instance_method(rule_name).parameters
85
+ if dispatch? && params.empty?
86
+ wrap_nondispatch(parser_class, rule_name)
87
+ elsif dispatch? && with_params?
88
+ wrap_dispatch(parser_class, rule_name)
89
+ end
90
+ end
91
+ end
92
+
93
+ def wrap_nondispatch(parser_class, rule_name)
94
+ alias_name = :"alias_nondispatch_#{rule_name}"
95
+ guard_name = :"alias_nondispatch_rule_guard_#{rule_name}"
96
+ return if parser_class.method_defined?(guard_name)
97
+
98
+ parser_class.class_eval do
99
+ alias_method alias_name, rule_name
100
+ define_method(guard_name) {}
101
+ rule(rule_name) do
102
+ public_send(alias_name)
103
+ end
104
+ end
105
+ end
106
+
107
+ def wrap_dispatch(parser_class, rule_name)
108
+ alias_name = :"alias_dispatch_#{rule_name}"
109
+ guard_name = :"alias_dispatch_rule_guard_#{rule_name}"
110
+ return if parser_class.method_defined?(guard_name)
111
+
112
+ parser_class.class_eval do
113
+ alias_method alias_name, rule_name
114
+ define_method(guard_name) {}
115
+ define_method(rule_name) do |*args, **kwargs|
116
+ RuleDispatcher.dispatch(self, alias_name, *args, **kwargs)
117
+ end
118
+ end
119
+ end
120
+
121
+ def dispatch_cache(parser_instance)
122
+ parser_instance.instance_variable_get(:@_rule_dispatch_cache) ||
123
+ parser_instance.instance_variable_set(:@_rule_dispatch_cache, {})
124
+ end
125
+
126
+ def dispatch?
127
+ DISPATCH_CONFIG[:add_dispatch]
128
+ end
129
+
130
+ def with_params?
131
+ DISPATCH_CONFIG[:with_params]
132
+ end
133
+
134
+ def dispatch_key(alias_name, args, kwargs)
135
+ [alias_name, args, kwargs.to_a.sort].hash.abs
136
+ end
137
+
138
+ def dispatch_rule_name(alias_name, key)
139
+ :"#{alias_name}_#{key}"
140
+ end
141
+
142
+ # Build a Parslet memoizable rule that closes over the captured
143
+ # args and forwards to the original aliased rule. Using Parslet's
144
+ # class-level `rule()` (not define_method on singleton_class) is
145
+ # essential — Parslet's memoization, `as()`, and tree building
146
+ # depend on the rule going through the standard Parslet machinery.
147
+ def build_dispatch_rule(parser_class, original_alias, rule_name, args, kwargs)
148
+ parser_class.class_eval do
149
+ rule(rule_name) do
150
+ public_send(original_alias, *args, **kwargs)
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -27,9 +27,7 @@ module Coradoc
27
27
  def section_block(level = 2)
28
28
  return nil if level > 8
29
29
 
30
- (attribute_list >> newline).maybe >>
31
- element_id.maybe >>
32
- (attribute_list >> newline).maybe >>
30
+ block_header >>
33
31
  section_title(level).as(:title) >>
34
32
  contents.as(:contents).maybe
35
33
  end
@@ -41,10 +41,7 @@ module Coradoc
41
41
  # - "^e|" centered cell with emphasis style
42
42
 
43
43
  def table
44
- element_id.maybe >>
45
- (attribute_list >> newline).maybe >>
46
- block_title.maybe >>
47
- (attribute_list >> newline).maybe >>
44
+ block_header >>
48
45
  table_start.capture(:table_delim) >>
49
46
  line_ending >>
50
47
  table_rows.as(:rows) >>
@@ -113,9 +113,7 @@ module Coradoc
113
113
  # inline_image is defined in inline.rb for inline images (image:)
114
114
 
115
115
  def block_image
116
- (element_id.maybe >>
117
- block_title.maybe >>
118
- (attribute_list >> newline).maybe >>
116
+ (block_header >>
119
117
  match('^i') >> str('mage::') >>
120
118
  file_path.as(:path) >>
121
119
  attribute_list(:attribute_list_macro) >>
@@ -5,6 +5,7 @@ module Coradoc
5
5
  module Parser
6
6
  autoload :Base, "#{__dir__}/parser/base"
7
7
  autoload :Cache, "#{__dir__}/parser/cache"
8
+ autoload :FrontmatterParser, "#{__dir__}/parser/frontmatter_parser"
8
9
  end
9
10
  end
10
11
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'coradoc/asciidoc/serializer/serialization_context'
3
+ require_relative '../serialization_context'
4
4
 
5
5
  module Coradoc
6
6
  module AsciiDoc
@@ -21,6 +21,13 @@ module Coradoc
21
21
 
22
22
  def serialize_to_adoc
23
23
  parts = []
24
+ # Frontmatter prefix, if present. The raw YAML text is
25
+ # already canonical (single source of truth is Codec), so we
26
+ # only wrap it with `---` delimiters.
27
+ if @model.frontmatter && !@model.frontmatter.strip.empty?
28
+ parts << "---\n#{@model.frontmatter.chomp}\n---\n\n"
29
+ end
30
+
24
31
  # Only add leading newline if we have sections but no header
25
32
  parts << "\n" if @model.sections && !@model.sections.empty? && !@model.header
26
33
 
@@ -8,7 +8,9 @@ module Coradoc
8
8
  class Definition < Base
9
9
  def to_adoc(model, _options = {})
10
10
  @model = model
11
- content = +"\n"
11
+ _attrs = @model.attrs.to_adoc(show_empty: false).to_s
12
+ prefix = _attrs.empty? ? '' : "#{_attrs}\n"
13
+ content = "\n#{prefix}"
12
14
  @model.items.each do |item|
13
15
  # Pass delimiter to item serialization
14
16
  serialized = serialize_child_with_options(item, delimiter: @model.delimiter)
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ module Transform
6
+ # Post-processing pass that merges AsciiDoc callout annotation
7
+ # paragraphs into the verbatim block they annotate.
8
+ #
9
+ # AsciiDoc callouts look like:
10
+ #
11
+ # [source,ruby]
12
+ # ----
13
+ # get '/hi' do <1>
14
+ # ----
15
+ # <1> Returns hello world
16
+ #
17
+ # The parser emits the source block and the annotation as two
18
+ # independent children. The CoreModel representation should attach
19
+ # the annotation to the block as a typed Callout, so downstream
20
+ # serializers can render them appropriately for each format.
21
+ #
22
+ # Single responsibility: take a flat list of transformed CoreModel
23
+ # children, return a flat list with `<N>` paragraphs adjacent to a
24
+ # SourceBlock / ListingBlock folded into that block's `callouts`.
25
+ # Anything else is passed through untouched.
26
+ class CalloutMerger
27
+ ANNOTATION_LINE = /<(\d+)>\s*(.*?)\s*\z/
28
+ ANNOTATION_SPLIT = /(?=<\d+>)/
29
+
30
+ class << self
31
+ def call(children)
32
+ new.merge(Array(children))
33
+ end
34
+ end
35
+
36
+ # Walks the input children left-to-right. When a paragraph whose
37
+ # content is composed entirely of `<N> text` lines follows a
38
+ # verbatim block (SourceBlock or ListingBlock), each `<N>` line
39
+ # becomes a Callout attached to that block instead of a separate
40
+ # paragraph.
41
+ #
42
+ # Annotations that do not follow a verbatim block are preserved
43
+ # verbatim — they may be legitimate prose.
44
+ def merge(children)
45
+ children.each.with_object([]) do |child, result|
46
+ annotations = extract_annotations(child)
47
+ target = annotations && preceding_verbatim_block(result)
48
+ if target
49
+ target.callouts.concat(annotations)
50
+ else
51
+ result << child
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ # Returns an Array of Callout if the paragraph is entirely
59
+ # callout annotations, otherwise nil.
60
+ def extract_annotations(child)
61
+ return nil unless child.is_a?(Coradoc::CoreModel::ParagraphBlock)
62
+
63
+ lines = split_annotation_lines(child.flat_text)
64
+ return nil if lines.empty?
65
+
66
+ callouts = lines.map do |line|
67
+ match = line.match(ANNOTATION_LINE)
68
+ match ? Coradoc::CoreModel::Callout.new(index: match[1].to_i, content: match[2]) : nil
69
+ end
70
+ return nil if callouts.any?(&:nil?)
71
+
72
+ callouts
73
+ end
74
+
75
+ # Splits a paragraph into candidate annotation lines. Returns []
76
+ # if any non-blank segment fails to look like an annotation.
77
+ def split_annotation_lines(text)
78
+ return [] if text.nil? || text.strip.empty?
79
+
80
+ chunks = text.strip.split(ANNOTATION_SPLIT)
81
+ chunks.map(&:strip).reject(&:empty?)
82
+ end
83
+
84
+ def preceding_verbatim_block(result)
85
+ last = result.last
86
+ return nil unless last.is_a?(Coradoc::CoreModel::SourceBlock) ||
87
+ last.is_a?(Coradoc::CoreModel::ListingBlock)
88
+
89
+ last
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -62,7 +62,16 @@ module Coradoc
62
62
  has_nested_blocks = lines.any?(Coradoc::AsciiDoc::Model::Block::Core)
63
63
 
64
64
  if has_nested_blocks
65
- children = lines.map { |line| ToCoreModel.transform(line) }
65
+ children = lines.filter_map do |line|
66
+ result = ToCoreModel.transform(line)
67
+ next nil if result.nil?
68
+ next result if result.is_a?(Coradoc::CoreModel::Base)
69
+
70
+ text = ToCoreModel.extract_text_content(result)
71
+ next nil if text.nil? || text.strip.empty?
72
+
73
+ Coradoc::CoreModel::TextContent.new(text: text)
74
+ end
66
75
  klass.new(
67
76
  id: block.id,
68
77
  title: ToCoreModel.extract_title_text(block.title),
@@ -9,14 +9,29 @@ module Coradoc
9
9
  def transform_document(doc)
10
10
  title_text = ToCoreModel.extract_title_text(doc.header&.title)
11
11
  attributes = ToCoreModel.extract_document_attributes(doc)
12
+ children = ToCoreModel.transform(doc.sections || doc.contents || [])
13
+ children = CalloutMerger.call(children)
14
+ children = prepend_frontmatter(children, doc.frontmatter)
15
+
12
16
  Coradoc::CoreModel::DocumentElement.new(
13
17
  id: doc.id,
14
18
  title: title_text,
15
19
  attributes: attributes,
16
- children: ToCoreModel.transform(doc.sections || doc.contents || [])
20
+ children: children
17
21
  )
18
22
  end
19
23
 
24
+ # If the AsciiDoc document carried raw frontmatter text, parse
25
+ # it via Codec into a typed FrontmatterBlock and prepend it so
26
+ # it participates in the standard block pipeline. Codec is the
27
+ # single source of truth for YAML parsing (DRY/MECE).
28
+ def prepend_frontmatter(children, frontmatter_text)
29
+ return children if frontmatter_text.nil? || frontmatter_text.strip.empty?
30
+
31
+ block = Coradoc::CoreModel::FrontmatterBlock::Codec.from_yaml(frontmatter_text)
32
+ [block, *children]
33
+ end
34
+
20
35
  def transform_section(section, parent_id: nil)
21
36
  title_text = ToCoreModel.extract_title_text(section.title)
22
37
  section_id = section.id || Coradoc::CoreModel::IdGenerator.generate_from_title(
@@ -24,6 +39,7 @@ module Coradoc
24
39
  )
25
40
 
26
41
  content_children = ToCoreModel.transform(section.contents || [])
42
+ content_children = CalloutMerger.call(content_children)
27
43
  nested_sections = (section.sections || []).map do |child|
28
44
  transform_section(child, parent_id: section_id)
29
45
  end
@@ -25,8 +25,10 @@ module Coradoc
25
25
  end
26
26
 
27
27
  def transform_image(image)
28
+ src = image.src.to_s
29
+ src = src[1..] if src.start_with?(':')
28
30
  Coradoc::CoreModel::Image.new(
29
- src: image.src,
31
+ src: src,
30
32
  alt: image.title&.to_s,
31
33
  width: image.attributes&.[]('width'),
32
34
  height: image.attributes&.[]('height')
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'from_core_model_registrations'
4
-
5
3
  module Coradoc
6
4
  module AsciiDoc
7
5
  module Transform
@@ -9,8 +7,18 @@ module Coradoc
9
7
  class FromCoreModel
10
8
  include Coradoc::Transform::Base
11
9
 
10
+ @registered = false
11
+
12
12
  class << self
13
+ def register!
14
+ return if @registered
15
+
16
+ Transform::FromCoreModelRegistrations.register_all!
17
+ @registered = true
18
+ end
19
+
13
20
  def transform(model)
21
+ register!
14
22
  return model.map { |item| transform(item) } if model.is_a?(Array)
15
23
  return model unless model.is_a?(Coradoc::CoreModel::Base)
16
24
 
@@ -32,27 +40,42 @@ module Coradoc
32
40
  Coradoc::AsciiDoc::Model::Header.new(title: '')
33
41
  end
34
42
 
43
+ sections, frontmatter = extract_frontmatter(Array(element.children))
44
+
35
45
  Coradoc::AsciiDoc::Model::Document.new(
36
46
  id: element.id,
37
47
  header: header,
38
- sections: transform(element.children)
48
+ sections: flatten_children(sections),
49
+ frontmatter: frontmatter
39
50
  )
40
51
  when CoreModel::SectionElement
41
52
  Coradoc::AsciiDoc::Model::Section.new(
42
53
  id: element.id,
43
54
  level: element.level,
44
55
  title: create_title(element.title, element.level),
45
- contents: transform(element.children)
56
+ contents: flatten_children(element.children)
46
57
  )
47
58
  else
48
59
  Coradoc::AsciiDoc::Model::Section.new(
49
60
  id: element.id,
50
61
  title: create_title(element.title, 1),
51
- contents: transform(element.children)
62
+ contents: flatten_children(element.children)
52
63
  )
53
64
  end
54
65
  end
55
66
 
67
+ # Transforms each CoreModel child and flattens one level so a
68
+ # transform that returns multiple siblings (e.g. a source block
69
+ # followed by its re-expanded callout paragraphs) stays in
70
+ # document order.
71
+ def flatten_children(children)
72
+ Array(children).flat_map { |child| flatten_one(transform(child)) }
73
+ end
74
+
75
+ def flatten_one(result)
76
+ result.is_a?(Array) ? result : [result]
77
+ end
78
+
56
79
  def transform_block(block)
57
80
  content = block.renderable_content
58
81
 
@@ -71,12 +94,19 @@ module Coradoc
71
94
  end
72
95
 
73
96
  content_text = safe_content_to_string(content)
97
+ result = build_verbatim_block(semantic, block, content_text)
98
+ return result unless verbatim_with_callouts?(semantic, block)
74
99
 
100
+ [result, *build_callout_paragraphs(block.callouts)]
101
+ end
102
+
103
+ def build_verbatim_block(semantic, block, content_text)
75
104
  case semantic
76
105
  when :source_code
77
106
  Coradoc::AsciiDoc::Model::Block::SourceCode.new(
78
107
  id: block.id,
79
108
  title: block.title,
109
+ lang: block.language,
80
110
  lines: content_text.split("\n"),
81
111
  attributes: build_attributes(block)
82
112
  )
@@ -151,6 +181,23 @@ module Coradoc
151
181
  end
152
182
  end
153
183
 
184
+ def verbatim_with_callouts?(semantic, block)
185
+ return false unless %i[source_code listing].include?(semantic)
186
+ return false if block.callouts.nil? || block.callouts.empty?
187
+
188
+ true
189
+ end
190
+
191
+ # Re-expands typed Callouts back into the AsciiDoc `<N> text`
192
+ # paragraph form so the round-trip is faithful.
193
+ def build_callout_paragraphs(callouts)
194
+ callouts.sort_by { |c| c.index || Float::INFINITY }.map do |callout|
195
+ Coradoc::AsciiDoc::Model::Paragraph.new(
196
+ content: create_text_elements("<#{callout.index}> #{callout.content}")
197
+ )
198
+ end
199
+ end
200
+
154
201
  def transform_table(table)
155
202
  rows = Array(table.rows).map do |row|
156
203
  columns = Array(row.cells).map do |cell|
@@ -335,8 +382,26 @@ module Coradoc
335
382
  )
336
383
  end
337
384
 
385
+ def transform_comment_line(comment)
386
+ Coradoc::AsciiDoc::Model::CommentLine.new(
387
+ text: comment.text.to_s
388
+ )
389
+ end
390
+
338
391
  private
339
392
 
393
+ # If the first CoreModel child is a FrontmatterBlock, serialize
394
+ # it to YAML text via Codec (single source of truth) and pop it
395
+ # from the children list. Returns [remaining_children,
396
+ # frontmatter_text].
397
+ def extract_frontmatter(children)
398
+ first = children.first
399
+ return [children, nil] unless first.is_a?(CoreModel::FrontmatterBlock)
400
+
401
+ yaml = CoreModel::FrontmatterBlock::Codec.to_yaml(first)
402
+ [children.drop(1), yaml.nil? || yaml.empty? ? nil : yaml]
403
+ end
404
+
340
405
  def resolve_semantic_type(block)
341
406
  semantic = block.resolve_semantic_type
342
407
  return semantic if semantic
@@ -115,6 +115,11 @@ module Coradoc
115
115
  Coradoc::CoreModel::BibliographyEntry,
116
116
  ->(model) { FromCoreModel.transform_bibliography_entry(model) }
117
117
  )
118
+
119
+ Registry.register(
120
+ Coradoc::CoreModel::CommentLine,
121
+ ->(model) { FromCoreModel.transform_comment_line(model) }
122
+ )
118
123
  end
119
124
  end
120
125
  end
@@ -123,4 +128,3 @@ module Coradoc
123
128
  end
124
129
 
125
130
  # Auto-register when this file is loaded
126
- Coradoc::AsciiDoc::Transform::FromCoreModelRegistrations.register_all!