coradoc-adoc 2.0.8 → 2.0.10

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 (53) 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 +7 -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 +3 -22
  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/frontmatter_parser.rb +24 -0
  17. data/lib/coradoc/asciidoc/parser/list.rb +18 -13
  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/serializer/serializers/list/definition_item.rb +21 -1
  28. data/lib/coradoc/asciidoc/transform/element_transformers/block_transformer.rb +10 -1
  29. data/lib/coradoc/asciidoc/transform/element_transformers/document_transformer.rb +15 -1
  30. data/lib/coradoc/asciidoc/transform/element_transformers/list_transformer.rb +6 -0
  31. data/lib/coradoc/asciidoc/transform/element_transformers/other_transformer.rb +3 -1
  32. data/lib/coradoc/asciidoc/transform/from_core_model.rb +46 -9
  33. data/lib/coradoc/asciidoc/transform/from_core_model_registrations.rb +5 -1
  34. data/lib/coradoc/asciidoc/transform/frontmatter_attribute_map.rb +112 -0
  35. data/lib/coradoc/asciidoc/transform/text_extract_visitor.rb +33 -1
  36. data/lib/coradoc/asciidoc/transform/to_core_model.rb +10 -2
  37. data/lib/coradoc/asciidoc/transform/to_core_model_registrations.rb +15 -10
  38. data/lib/coradoc/asciidoc/transform.rb +1 -0
  39. data/lib/coradoc/asciidoc/transformer/attribute_list_normalizer.rb +69 -0
  40. data/lib/coradoc/asciidoc/transformer/block_rules.rb +4 -42
  41. data/lib/coradoc/asciidoc/transformer/block_type_classifier.rb +56 -0
  42. data/lib/coradoc/asciidoc/transformer/header_rules.rb +15 -53
  43. data/lib/coradoc/asciidoc/transformer/inline_rules.rb +39 -57
  44. data/lib/coradoc/asciidoc/transformer/list_rules.rb +54 -11
  45. data/lib/coradoc/asciidoc/transformer/misc_rules.rb +1 -24
  46. data/lib/coradoc/asciidoc/transformer/structural_rules.rb +18 -81
  47. data/lib/coradoc/asciidoc/transformer/table_cell_builder.rb +161 -0
  48. data/lib/coradoc/asciidoc/transformer/table_layout.rb +135 -0
  49. data/lib/coradoc/asciidoc/transformer/text_rules.rb +1 -25
  50. data/lib/coradoc/asciidoc/transformer.rb +38 -294
  51. data/lib/coradoc/asciidoc/version.rb +1 -1
  52. data/lib/coradoc/asciidoc.rb +6 -3
  53. metadata +10 -1
@@ -29,10 +29,10 @@ module Coradoc
29
29
  attrs >> r.repeat(1).as(:unordered)
30
30
  end
31
31
 
32
- def definition_list(delimiter = '::')
32
+ def definition_list(_delimiter = nil)
33
33
  (attribute_list >> newline).maybe >>
34
- dlist_item(delimiter).repeat(1).as(:definition_list) >>
35
- dlist_item(delimiter).absent?
34
+ dlist_item.repeat(1).as(:definition_list) >>
35
+ dlist_item.absent?
36
36
  end
37
37
 
38
38
  def list_marker(nesting_level = 1)
@@ -98,27 +98,32 @@ module Coradoc
98
98
  end
99
99
 
100
100
  def dlist_delimiter
101
- (str('::') | str(':::') | str('::::') | str(';;')
101
+ (
102
+ (str(':::::') >> match(':').absent?) |
103
+ (str('::::') >> match(':').absent?) |
104
+ (str(':::') >> match(':').absent?) |
105
+ (str('::') >> match(':').absent?) |
106
+ str(';;')
102
107
  ).as(:delimiter)
103
108
  end
104
109
 
105
- def dlist_term(_delimiter)
106
- (element_id_inline.maybe >>
107
- match("[^\n:]").repeat(1)
108
- .as(:text)
109
- ).as(:dlist_term) >> dlist_delimiter
110
+ def dlist_term(_delimiter = nil)
111
+ term_chars =
112
+ (dlist_delimiter.absent? >> match("[^\n]")).repeat(1)
113
+ .as(:text)
114
+ (element_id_inline.maybe >> term_chars).as(:dlist_term) >> dlist_delimiter
110
115
  end
111
116
 
112
117
  def dlist_definition
113
- text # >> empty_line.repeat(0)
118
+ text
114
119
  .as(:definition) >> line_ending >> empty_line.repeat(0)
115
120
  end
116
121
 
117
- def dlist_item(delimiter)
118
- (((dlist_term(delimiter).as(:terms).repeat(1) >> line_ending >>
122
+ def dlist_item(_delimiter = nil)
123
+ (((dlist_term.as(:terms).repeat(1) >> line_ending >>
119
124
  empty_line.repeat(0)).repeat(1) >>
120
125
  dlist_definition) |
121
- (dlist_term(delimiter).repeat(1, 1).as(:terms) >> space >>
126
+ (dlist_term.repeat(1, 1).as(:terms) >> space >>
122
127
  dlist_definition)
123
128
  ).as(:definition_list_item)
124
129
  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)
@@ -8,7 +8,8 @@ module Coradoc
8
8
  class DefinitionItem < Base
9
9
  def to_adoc(model, options_or_context = {})
10
10
  context = normalize_context(options_or_context)
11
- delimiter = context.option(:delimiter, '')
11
+ delimiter = model.delimiter.to_s
12
+ delimiter = context.option(:delimiter, '::') if delimiter.empty?
12
13
  _anchor = model.anchor.nil? ? '' : serialize_child(model.anchor, context)
13
14
  content = +''
14
15
 
@@ -26,6 +27,25 @@ module Coradoc
26
27
 
27
28
  d = model.contents ? serialize_children(model.contents, context) : ''
28
29
  content << "#{d}\n"
30
+
31
+ nested_delimiter = "#{delimiter}:"
32
+ Array(model.nested).each do |nested_list|
33
+ next unless nested_list.is_a?(Coradoc::AsciiDoc::Model::List::Definition)
34
+
35
+ nested_list.items.each do |nested_item|
36
+ content << serialize_with_options(nested_item, delimiter: nested_delimiter)
37
+ end
38
+ end
39
+
40
+ content
41
+ end
42
+
43
+ private
44
+
45
+ def serialize_with_options(child, options = {})
46
+ serializer_class = ElementRegistry.lookup(child.class)
47
+ serializer = serializer_class.new
48
+ serializer.to_adoc(child, options)
29
49
  end
30
50
  end
31
51
  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,28 @@ 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 = prepend_frontmatter(children, doc.frontmatter)
14
+
12
15
  Coradoc::CoreModel::DocumentElement.new(
13
16
  id: doc.id,
14
17
  title: title_text,
15
18
  attributes: attributes,
16
- children: ToCoreModel.transform(doc.sections || doc.contents || [])
19
+ children: children
17
20
  )
18
21
  end
19
22
 
23
+ # If the AsciiDoc document carried raw frontmatter text, parse
24
+ # it via Codec into a typed FrontmatterBlock and prepend it so
25
+ # it participates in the standard block pipeline. Codec is the
26
+ # single source of truth for YAML parsing (DRY/MECE).
27
+ def prepend_frontmatter(children, frontmatter_text)
28
+ return children if frontmatter_text.nil? || frontmatter_text.strip.empty?
29
+
30
+ block = Coradoc::CoreModel::FrontmatterBlock::Codec.from_yaml(frontmatter_text)
31
+ [block, *children]
32
+ end
33
+
20
34
  def transform_section(section, parent_id: nil)
21
35
  title_text = ToCoreModel.extract_title_text(section.title)
22
36
  section_id = section.id || Coradoc::CoreModel::IdGenerator.generate_from_title(
@@ -48,6 +48,12 @@ module Coradoc
48
48
  definition_children: def_children
49
49
  )
50
50
  di.id = item.id if item.id
51
+
52
+ nested_adoc = Array(item.nested).find do |n|
53
+ n.is_a?(Coradoc::AsciiDoc::Model::List::Definition) && n.items.any?
54
+ end
55
+ di.nested = transform_list(nested_adoc, 'definition') if nested_adoc
56
+
51
57
  di
52
58
  end
53
59
 
@@ -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,10 +40,13 @@ 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: transform(sections),
49
+ frontmatter: frontmatter
39
50
  )
40
51
  when CoreModel::SectionElement
41
52
  Coradoc::AsciiDoc::Model::Section.new(
@@ -77,6 +88,7 @@ module Coradoc
77
88
  Coradoc::AsciiDoc::Model::Block::SourceCode.new(
78
89
  id: block.id,
79
90
  title: block.title,
91
+ lang: block.language,
80
92
  lines: content_text.split("\n"),
81
93
  attributes: build_attributes(block)
82
94
  )
@@ -298,22 +310,29 @@ module Coradoc
298
310
  )
299
311
  end
300
312
 
301
- def transform_definition_list(definition_list)
313
+ def transform_definition_list(definition_list, depth = 1)
314
+ delimiter = ':' * (depth + 1)
302
315
  items = Array(definition_list.items).map do |item|
303
- transform_definition_item(item)
316
+ transform_definition_item(item, depth)
304
317
  end
305
- Coradoc::AsciiDoc::Model::List::Definition.new(items: items)
318
+ list = Coradoc::AsciiDoc::Model::List::Definition.new(items: items)
319
+ list.delimiter = delimiter
320
+ list
306
321
  end
307
322
 
308
- def transform_definition_item(item)
323
+ def transform_definition_item(item, depth = 1)
324
+ delimiter = ':' * (depth + 1)
309
325
  term = Coradoc::AsciiDoc::Model::Term.new(term: item.term.to_s)
310
326
  contents = Array(item.definitions).map do |defn|
311
327
  Coradoc::AsciiDoc::Model::TextElement.new(content: defn.to_s)
312
328
  end
313
- Coradoc::AsciiDoc::Model::List::DefinitionItem.new(
329
+ di = Coradoc::AsciiDoc::Model::List::DefinitionItem.new(
314
330
  terms: [term],
315
- contents: contents
331
+ contents: contents,
332
+ delimiter: delimiter
316
333
  )
334
+ di.nested << transform_definition_list(item.nested, depth + 1) if item.nested&.items&.any?
335
+ di
317
336
  end
318
337
 
319
338
  def transform_toc(_toc)
@@ -328,8 +347,26 @@ module Coradoc
328
347
  )
329
348
  end
330
349
 
350
+ def transform_comment_line(comment)
351
+ Coradoc::AsciiDoc::Model::CommentLine.new(
352
+ text: comment.text.to_s
353
+ )
354
+ end
355
+
331
356
  private
332
357
 
358
+ # If the first CoreModel child is a FrontmatterBlock, serialize
359
+ # it to YAML text via Codec (single source of truth) and pop it
360
+ # from the children list. Returns [remaining_children,
361
+ # frontmatter_text].
362
+ def extract_frontmatter(children)
363
+ first = children.first
364
+ return [children, nil] unless first.is_a?(CoreModel::FrontmatterBlock)
365
+
366
+ yaml = CoreModel::FrontmatterBlock::Codec.to_yaml(first)
367
+ [children.drop(1), yaml.nil? || yaml.empty? ? nil : yaml]
368
+ end
369
+
333
370
  def resolve_semantic_type(block)
334
371
  semantic = block.resolve_semantic_type
335
372
  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!
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ module Transform
6
+ # OCP opt-in bridge mapping AsciiDoc document attributes
7
+ # (`:author:`, `:revdate:`, etc.) to/from FrontmatterBlock data.
8
+ #
9
+ # NOT auto-registered. Users opt in by invoking the bridge methods
10
+ # from their pipeline (e.g., from a custom transformer extension
11
+ # or rake task). This honors OCP: core conversion never silently
12
+ # rewrites data; opt-in extensions add behavior explicitly.
13
+ #
14
+ # MECE: lives in its own file, dispatches on `attribute_name` /
15
+ # `frontmatter_key` pairs. Does not touch FrontmatterBlock::Codec
16
+ # (YAML I/O) or SchemaResolver (validation).
17
+ #
18
+ # Mappings (bidirectional):
19
+ #
20
+ # | Frontmatter key | AsciiDoc attribute | Notes |
21
+ # |-----------------|--------------------|--------------------|
22
+ # | author | author | |
23
+ # | date | revdate | |
24
+ # | tags | tags | Array <-> space str|
25
+ # | categories | categories | Array <-> space str|
26
+ module FrontmatterAttributeMap
27
+ # Single source of truth for the attribute <-> frontmatter
28
+ # mapping. Each tuple: [attribute_name(String), front_key(String),
29
+ # kind(:scalar | :array)]
30
+ MAPPINGS = [
31
+ ['author', 'author', :scalar],
32
+ ['revdate', 'date', :scalar],
33
+ ['tags', 'tags', :array],
34
+ ['categories', 'categories', :array]
35
+ ].freeze
36
+
37
+ class << self
38
+ # Build a frontmatter data hash from an AsciiDoc document
39
+ # attributes Hash. Skips unknown keys and empty values.
40
+ #
41
+ # @param attributes [Hash{String=>Object}] AsciiDoc document
42
+ # attributes (e.g., from DocumentAttributes#to_hash)
43
+ # @return [Hash{String=>Object}] frontmatter data hash
44
+ def entries_from_attributes(attributes)
45
+ attributes = normalize_hash(attributes)
46
+ MAPPINGS.each_with_object({}) do |(attr_name, front_key, kind), h|
47
+ raw = attributes[attr_name]
48
+ next if raw.nil? || raw.to_s.strip.empty?
49
+
50
+ h[front_key] = build_value(raw, kind)
51
+ end
52
+ end
53
+
54
+ # Reverse: walk a FrontmatterBlock's data and produce a Hash
55
+ # of AsciiDoc document attributes. Unknown entry keys are
56
+ # dropped (only mapped keys are translated).
57
+ #
58
+ # @param block [Coradoc::CoreModel::FrontmatterBlock]
59
+ # @return [Hash{String=>String}]
60
+ def attributes_from_block(block)
61
+ result = {}
62
+ return result unless block&.data
63
+
64
+ MAPPINGS.each do |(_, front_key, _)|
65
+ value = block.data[front_key]
66
+ next if value.nil?
67
+
68
+ attr_name = front_key_to_attribute(front_key)
69
+ next unless attr_name
70
+
71
+ result[attr_name] = serialize_value(value)
72
+ end
73
+ result
74
+ end
75
+
76
+ private
77
+
78
+ def normalize_hash(attributes)
79
+ return {} unless attributes.is_a?(Hash)
80
+
81
+ attributes.each_with_object({}) do |(k, v), h|
82
+ h[k.to_s] = v
83
+ end
84
+ end
85
+
86
+ def build_value(raw, kind)
87
+ case kind
88
+ when :array
89
+ raw.to_s.split(/\s+/).reject(&:empty?)
90
+ else
91
+ raw.to_s
92
+ end
93
+ end
94
+
95
+ def serialize_value(value)
96
+ case value
97
+ when Array
98
+ value.map(&:to_s).join(' ')
99
+ else
100
+ value.to_s
101
+ end
102
+ end
103
+
104
+ def front_key_to_attribute(front_key)
105
+ mapping = MAPPINGS.find { |_, fk, _| fk == front_key.to_s }
106
+ mapping&.first
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end