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
@@ -74,8 +74,33 @@ module Coradoc
74
74
  model.alt || model.src || ''
75
75
  end
76
76
 
77
+ def visit_inline_span(model)
78
+ model.text.to_s
79
+ end
80
+
81
+ def visit_list(model)
82
+ visit_array(model.items)
83
+ end
84
+
85
+ def visit_block(model)
86
+ visit_array(model.lines)
87
+ end
88
+
89
+ def visit_core_model_list(model)
90
+ model.items.map { |i| visit(i.content) }.join(' ')
91
+ end
92
+
93
+ def visit_definition_item(model)
94
+ terms_text = visit(model.terms)
95
+ contents_text = visit(model.contents)
96
+ [terms_text, contents_text].reject(&:empty?).join(': ')
97
+ end
98
+
77
99
  def visit_base_model(model)
78
- model.content ? visit(model.content) : ''
100
+ content = model.content
101
+ content ? visit(content) : ''
102
+ rescue NoMethodError
103
+ ''
79
104
  end
80
105
 
81
106
  def visit_core_model_inline(model)
@@ -115,6 +140,13 @@ module Coradoc
115
140
  when Model::Inline::Footnote then visit_footnote(model)
116
141
  when Model::Inline::AttributeReference then visit_attribute_reference(model)
117
142
  when Model::Image::Core then visit_adoc_image(model)
143
+ when CoreModel::ListBlock then visit_core_model_list(model)
144
+ when Model::Inline::Span then visit_inline_span(model)
145
+ when Model::List::Core then visit_list(model)
146
+ when Model::List::Definition then visit_list(model)
147
+ when Model::List::DefinitionItem then visit_definition_item(model)
148
+ when Model::Block::Core then visit_block(model)
149
+ when Model::LineBreak, Model::CommentLine, Model::CommentBlock then ''
118
150
  when Model::Base then visit_base_model(model)
119
151
  else
120
152
  model.class.name.start_with?('Parslet::') ? model.to_s : ''
@@ -1,15 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'to_core_model_registrations'
4
-
5
3
  module Coradoc
6
4
  module AsciiDoc
7
5
  module Transform
8
6
  class ToCoreModel
9
7
  include Coradoc::Transform::Base
10
8
 
9
+ @registered = false
10
+
11
11
  class << self
12
+ def register!
13
+ return if @registered
14
+
15
+ Transform::ToCoreModelRegistrations.register_all!
16
+ @registered = true
17
+ end
18
+
12
19
  def transform(model)
20
+ register!
13
21
  return model.filter_map { |item| transform(item) } if model.is_a?(Array)
14
22
  return model unless model.is_a?(Coradoc::AsciiDoc::Model::Base)
15
23
 
@@ -75,6 +75,15 @@ module Coradoc
75
75
  )
76
76
  }
77
77
  )
78
+
79
+ Registry.register(
80
+ Coradoc::AsciiDoc::Model::CommentLine,
81
+ lambda { |model|
82
+ Coradoc::CoreModel::CommentLine.new(
83
+ text: model.text.to_s
84
+ )
85
+ }
86
+ )
78
87
  end
79
88
 
80
89
  def register_list_transformers!
@@ -179,6 +188,11 @@ module Coradoc
179
188
  ->(model) { Oth.transform_image(model) }
180
189
  )
181
190
 
191
+ Registry.register(
192
+ Coradoc::AsciiDoc::Model::Image::InlineImage,
193
+ ->(model) { Oth.transform_image(model) }
194
+ )
195
+
182
196
  Registry.register(
183
197
  Coradoc::AsciiDoc::Model::Bibliography,
184
198
  ->(model) { Oth.transform_bibliography(model) }
@@ -190,17 +204,11 @@ module Coradoc
190
204
  )
191
205
 
192
206
  [
193
- Coradoc::AsciiDoc::Model::TextElement,
194
207
  Coradoc::AsciiDoc::Model::Include,
195
208
  Coradoc::AsciiDoc::Model::Audio,
196
209
  Coradoc::AsciiDoc::Model::Video,
197
210
  Coradoc::AsciiDoc::Model::ContentList,
198
- Coradoc::AsciiDoc::Model::Tag
199
- ].each do |klass|
200
- Registry.register(klass, ->(model) { model })
201
- end
202
-
203
- [
211
+ Coradoc::AsciiDoc::Model::Tag,
204
212
  Coradoc::AsciiDoc::Model::LineBreak,
205
213
  Coradoc::AsciiDoc::Model::Break::PageBreak
206
214
  ].each do |klass|
@@ -212,6 +220,3 @@ module Coradoc
212
220
  end
213
221
  end
214
222
  end
215
-
216
- # Auto-register when this file is loaded
217
- Coradoc::AsciiDoc::Transform::ToCoreModelRegistrations.register_all!
@@ -12,6 +12,7 @@ module Coradoc
12
12
  autoload :TextExtractVisitor, "#{__dir__}/transform/text_extract_visitor"
13
13
  autoload :InlineTransformVisitor, "#{__dir__}/transform/inline_transform_visitor"
14
14
  autoload :ElementTransformers, "#{__dir__}/transform/element_transformers"
15
+ autoload :FrontmatterAttributeMap, "#{__dir__}/transform/frontmatter_attribute_map"
15
16
  end
16
17
  end
17
18
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ class Transformer < Parslet::Transform
6
+ # Pure-function module for normalizing raw parser `:attribute_list`
7
+ # values into a single canonical Model::AttributeList.
8
+ #
9
+ # The parser's `block_header` rule captures every consecutive `[...]`
10
+ # block before a structural element as a Parslet sequence under
11
+ # `:attribute_list`. Real-world AsciiDoc often stacks multiple lists
12
+ # before a single delimiter:
13
+ #
14
+ # [role=quote]
15
+ # [source, ruby]
16
+ # ----
17
+ # code
18
+ # ----
19
+ #
20
+ # This module is the single source of truth for converting any of those
21
+ # shapes (nil, single list, array of lists, array of hashes) into one
22
+ # canonical AttributeList that downstream model constructors can use.
23
+ module AttributeListNormalizer
24
+ module_function
25
+
26
+ # @param value [Object, nil] Raw parser value bound to :attribute_list
27
+ # @return [Model::AttributeList, nil]
28
+ def coerce(value)
29
+ case value
30
+ when nil then nil
31
+ when Model::AttributeList then value
32
+ when Array
33
+ lists = value.map { |entry| unwrap(entry) }.compact
34
+ return nil if lists.empty?
35
+ return lists.first if lists.size == 1
36
+
37
+ merge(lists)
38
+ else
39
+ value
40
+ end
41
+ end
42
+
43
+ # Merge several AttributeLists into one, preserving positional order
44
+ # and concatenating named keys in input order.
45
+ # @param lists [Array<Model::AttributeList>]
46
+ # @return [Model::AttributeList]
47
+ def merge(lists)
48
+ merged = Model::AttributeList.new
49
+ lists.each do |list|
50
+ next unless list.is_a?(Model::AttributeList)
51
+
52
+ list.positional.each { |p| merged.add_positional(p.value) }
53
+ list.named.each { |n| merged.add_named(n.name, n.value) }
54
+ end
55
+ merged
56
+ end
57
+
58
+ # Unwrap a single entry of the parser's :attribute_list sequence.
59
+ # @param entry [Object]
60
+ # @return [Model::AttributeList, nil]
61
+ def unwrap(entry)
62
+ return entry if entry.is_a?(Model::AttributeList)
63
+
64
+ entry[:attribute_list] if entry.is_a?(Hash) && entry.key?(:attribute_list)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -11,12 +11,11 @@ module Coradoc
11
11
  rule(block: subtree(:block)) do
12
12
  id = block[:id]
13
13
  title = block[:title]
14
- attribute_list = block[:attribute_list]
14
+ attribute_list = AttributeListNormalizer.coerce(block[:attribute_list])
15
15
  delimiter = block[:delimiter].to_s
16
- delimiter_c = delimiter[0]
17
16
  lines = block[:lines]
18
17
  ordering = block.keys.select do |k|
19
- %i[id title attribute_list attribute_list2].include?(k)
18
+ %i[id title attribute_list].include?(k)
20
19
  end
21
20
 
22
21
  opts = {
@@ -26,44 +25,7 @@ module Coradoc
26
25
  lines: lines,
27
26
  ordering: ordering
28
27
  }
29
- opts[:attributes] = attribute_list if attribute_list
30
- delimiter_len = opts[:delimiter_len]
31
-
32
- if delimiter_c == '*'
33
- if attribute_list
34
- if attribute_list.positional == [] &&
35
- attribute_list.named.first&.name == 'reviewer'
36
- Model::Block::ReviewerComment.new(
37
- id:,
38
- title:,
39
- lines:,
40
- delimiter_len:,
41
- attributes: attribute_list
42
- )
43
- else
44
- Model::Block::Side.new(id:, title:, lines:, delimiter_len:,
45
- attributes: attribute_list)
46
- end
47
- else
48
- Model::Block::Side.new(id:, title:, lines:, delimiter_len:,
49
- attributes: attribute_list)
50
- end
51
- elsif delimiter_c == '='
52
- Model::Block::Example.new(id:, title:, lines:, delimiter_len:,
53
- attributes: attribute_list)
54
- elsif delimiter_c == '+'
55
- Model::Block::Pass.new(id:, title:, lines:, delimiter_len:,
56
- attributes: attribute_list)
57
- elsif delimiter_c == '-' && delimiter.size == 2
58
- Model::Block::Open.new(id:, title:, lines:, delimiter_len:,
59
- attributes: attribute_list)
60
- elsif delimiter_c == '-' && delimiter.size >= 4
61
- Model::Block::SourceCode.new(id:, title:, lines:, delimiter_len:,
62
- attributes: attribute_list)
63
- elsif delimiter_c == '_'
64
- Model::Block::Quote.new(id:, title:, lines:, delimiter_len:,
65
- attributes: attribute_list)
66
- end
28
+ BlockTypeClassifier.classify(delimiter, opts, attribute_list)
67
29
  end
68
30
 
69
31
  # Example
@@ -84,7 +46,7 @@ module Coradoc
84
46
  id = block_image[:id]
85
47
  title = block_image[:title]
86
48
  path = block_image[:path]
87
- attrs = block_image[:attribute_list]
49
+ attrs = AttributeListNormalizer.coerce(block_image[:attribute_list])
88
50
  Model::Image::BlockImage.new(
89
51
  title: title,
90
52
  id: id,
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ class Transformer < Parslet::Transform
6
+ # Single source of truth for "which delimiter maps to which block model".
7
+ #
8
+ # The block rule in BlockRules delegates here to convert a parser
9
+ # delimiter string (e.g., `----`, `****`, `--`) into the appropriate
10
+ # Model::Block::* subclass instance. Adding a new block type means
11
+ # appending one entry to DELIMITER_CLASSIFICATIONS — no edits to the
12
+ # block rule itself. (Open/Closed Principle.)
13
+ module BlockTypeClassifier
14
+ # Each entry is [char, min_length, max_length, factory].
15
+ # The factory is a callable taking (opts, attribute_list) and
16
+ # returning a Model::Block::* instance. `max_length` nil means
17
+ # unbounded.
18
+ DELIMITER_CLASSIFICATIONS = [
19
+ ['*', 4, nil, ->(opts, attrs) {
20
+ if attrs && attrs.positional == [] && attrs.named.first&.name == 'reviewer'
21
+ Model::Block::ReviewerComment.new(**opts.merge(attributes: attrs))
22
+ else
23
+ Model::Block::Side.new(**opts.merge(attributes: attrs))
24
+ end
25
+ }],
26
+ ['=', 4, nil, ->(opts, attrs) { Model::Block::Example.new(**opts.merge(attributes: attrs)) }],
27
+ ['+', 4, nil, ->(opts, attrs) { Model::Block::Pass.new(**opts.merge(attributes: attrs)) }],
28
+ ['_', 4, nil, ->(opts, attrs) { Model::Block::Quote.new(**opts.merge(attributes: attrs)) }],
29
+ ['-', 4, nil, ->(opts, attrs) { Model::Block::SourceCode.new(**opts.merge(attributes: attrs)) }],
30
+ ['-', 2, 2, ->(opts, attrs) { Model::Block::Open.new(**opts.merge(attributes: attrs)) }]
31
+ ].freeze
32
+
33
+ module_function
34
+
35
+ # @param delimiter [String] e.g., "----", "**", "--"
36
+ # @param opts [Hash] Constructor options (id, title, lines, delimiter_len, ordering)
37
+ # @param attrs [Model::AttributeList, nil]
38
+ # @return [Model::Block::Base, nil]
39
+ def classify(delimiter, opts, attrs)
40
+ char = delimiter[0]
41
+ len = delimiter.size
42
+ entry = DELIMITER_CLASSIFICATIONS.find do |c, min_len, max_len, _|
43
+ next false unless c == char
44
+ next false unless len >= min_len
45
+ next false if max_len && len > max_len
46
+
47
+ true
48
+ end
49
+ return nil unless entry
50
+
51
+ entry.last.call(opts, attrs)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -7,40 +7,25 @@ module Coradoc
7
7
  module HeaderRules
8
8
  def self.apply(transformer_class)
9
9
  transformer_class.class_eval do
10
- # Header with author and revision
11
- rule(
12
- title: simple(:title),
13
- author: simple(:author),
14
- revision: simple(:revision)
15
- ) do
16
- id = title.is_a?(Model::Title) ? title.id : nil
17
- Model::Header.new(id:, title:, author:, revision:)
18
- end
10
+ # Header single canonical rule covering all combinations of
11
+ # optional :author and :revision slots. The previous design had
12
+ # four explicit rules (one per combination); this version reads
13
+ # the same data with one `subtree` match.
14
+ rule(header: subtree(:header)) do
15
+ title = header[:title]
16
+ author = header[:author]
17
+ revision = header[:revision]
19
18
 
20
- # Header with author only
21
- rule(
22
- title: simple(:title),
23
- author: simple(:author)
24
- ) do
25
- id = title.is_a?(Model::Title) ? title.id : nil
26
- Model::Header.new(id:, title:, author:, revision: nil)
27
- end
19
+ id = header[:id]
20
+ id = title.id if title.is_a?(Model::Title) && title.id && !id
21
+ id = id.to_s unless id.nil?
22
+ id = nil if id && id.empty?
28
23
 
29
- # Header with revision only
30
- rule(
31
- title: simple(:title),
32
- revision: simple(:revision)
33
- ) do
34
- id = title.is_a?(Model::Title) ? title.id : nil
35
- Model::Header.new(id:, title:, author: nil, revision:)
24
+ Model::Header.new(id:, title:, author:, revision:)
36
25
  end
37
26
 
38
- # Header with title only
39
- rule(
40
- title: simple(:title)
41
- ) do
42
- id = title.is_a?(Model::Title) ? title.id : nil
43
- Model::Header.new(id:, title:, author: nil, revision: nil)
27
+ rule(header: simple(:header)) do
28
+ header
44
29
  end
45
30
 
46
31
  # Author
@@ -60,29 +45,6 @@ module Coradoc
60
45
  ) do
61
46
  Model::Revision.new(number:, date:, remark:)
62
47
  end
63
-
64
- # Unwrap header hash - handles cases where header wasn't transformed yet
65
- rule(header: subtree(:header)) do
66
- if header.is_a?(Hash) && header.key?(:title)
67
- id = header[:id]
68
- id = id.to_s unless id.nil?
69
- id = nil if id && id.empty?
70
-
71
- title = header[:title]
72
- author = header[:author]
73
- revision = header[:revision]
74
-
75
- id = title.id if title.is_a?(Model::Title) && title.id && !id
76
-
77
- Model::Header.new(id:, title:, author:, revision:)
78
- else
79
- header
80
- end
81
- end
82
-
83
- rule(header: simple(:header)) do
84
- header
85
- end
86
48
  end
87
49
  end
88
50
  end
@@ -5,6 +5,17 @@ module Coradoc
5
5
  class Transformer < Parslet::Transform
6
6
  # Module containing inline element transformation rules
7
7
  module InlineRules
8
+ # Inline formatting variants that share the same rule shape:
9
+ # constrained and unconstrained forms of the same model class.
10
+ # `span` is excluded because it carries `text:` + `attributes:`
11
+ # rather than `content:`, so it gets its own pair of rules.
12
+ FORMATTING_VARIANTS = [
13
+ %i[bold Bold],
14
+ %i[italic Italic],
15
+ %i[highlight Highlight],
16
+ %i[monospace Monospace]
17
+ ].freeze
18
+
8
19
  def self.apply(transformer_class)
9
20
  transformer_class.class_eval do
10
21
  # Link
@@ -82,64 +93,23 @@ module Coradoc
82
93
  href_arg.to_s
83
94
  end
84
95
 
85
- # Bold (constrained)
86
- rule(bold_constrained: subtree(:bold)) do
87
- content = Transformer.extract_inline_content(bold)
88
- Model::Inline::Bold.new(content: content, unconstrained: false)
89
- end
90
-
91
- # Bold (unconstrained)
92
- rule(bold_unconstrained: subtree(:bold)) do
93
- content = Transformer.extract_inline_content(bold)
94
- Model::Inline::Bold.new(content: content, unconstrained: true)
95
- end
96
-
97
- # Italic (constrained)
98
- rule(italic_constrained: subtree(:italic)) do
99
- content = Transformer.extract_inline_content(italic)
100
- Model::Inline::Italic.new(content: content, unconstrained: false)
101
- end
102
-
103
- # Italic (unconstrained)
104
- rule(italic_unconstrained: subtree(:italic)) do
105
- content = Transformer.extract_inline_content(italic)
106
- Model::Inline::Italic.new(content: content, unconstrained: true)
107
- end
108
-
109
- # Highlight (constrained)
110
- rule(highlight_constrained: subtree(:highlight)) do
111
- content = Transformer.extract_inline_content(highlight)
112
- Model::Inline::Highlight.new(content: content, unconstrained: false)
113
- end
114
-
115
- # Highlight (unconstrained)
116
- rule(highlight_unconstrained: subtree(:highlight)) do
117
- content = Transformer.extract_inline_content(highlight)
118
- Model::Inline::Highlight.new(content: content, unconstrained: true)
119
- end
120
-
121
- # Monospace (constrained)
122
- rule(monospace_constrained: subtree(:monospace)) do
123
- content = Transformer.extract_inline_content(monospace)
124
- Model::Inline::Monospace.new(content: content, unconstrained: false)
125
- end
126
-
127
- # Monospace (unconstrained)
128
- rule(monospace_unconstrained: subtree(:monospace)) do
129
- content = Transformer.extract_inline_content(monospace)
130
- Model::Inline::Monospace.new(content: content, unconstrained: true)
131
- end
132
-
133
- # Superscript
134
- rule(superscript: subtree(:superscript)) do
135
- content = Transformer.extract_simple_inline_content(superscript)
136
- Model::Inline::Superscript.new(content:)
137
- end
96
+ # Inline formatting rules generated from a single registry.
97
+ # See InlineRules::FORMATTING_VARIANTS. `span` is special
98
+ # because it carries `text:` + `attributes:` rather than
99
+ # `content:`, so it stays inline below.
100
+ InlineRules::FORMATTING_VARIANTS.each do |prefix, class_name|
101
+ klass = Model::Inline.const_get(class_name)
102
+ constrained_key = :"#{prefix}_constrained"
103
+ unconstrained_key = :"#{prefix}_unconstrained"
138
104
 
139
- # Subscript
140
- rule(subscript: subtree(:subscript)) do
141
- content = Transformer.extract_simple_inline_content(subscript)
142
- Model::Inline::Subscript.new(content:)
105
+ rule(constrained_key => subtree(:subtree)) do
106
+ content = Transformer.extract_inline_content(subtree)
107
+ klass.new(content: content, unconstrained: false)
108
+ end
109
+ rule(unconstrained_key => subtree(:subtree)) do
110
+ content = Transformer.extract_inline_content(subtree)
111
+ klass.new(content: content, unconstrained: true)
112
+ end
143
113
  end
144
114
 
145
115
  # Span (constrained)
@@ -160,6 +130,18 @@ module Coradoc
160
130
  )
161
131
  end
162
132
 
133
+ # Superscript
134
+ rule(superscript: subtree(:superscript)) do
135
+ content = Transformer.extract_simple_inline_content(superscript)
136
+ Model::Inline::Superscript.new(content:)
137
+ end
138
+
139
+ # Subscript
140
+ rule(subscript: subtree(:subscript)) do
141
+ content = Transformer.extract_simple_inline_content(subscript)
142
+ Model::Inline::Subscript.new(content:)
143
+ end
144
+
163
145
  # Highlight (simple)
164
146
  rule(highlight: simple(:text)) do
165
147
  Model::Highlight.new(content: text)
@@ -5,6 +5,42 @@ module Coradoc
5
5
  class Transformer < Parslet::Transform
6
6
  # Module containing list transformation rules
7
7
  module ListRules
8
+ class << self
9
+ def build_dlist_tree(items)
10
+ root = Model::List::Definition.new(items: [])
11
+ stack = [[root, 0]]
12
+
13
+ items.each do |item|
14
+ depth = dlist_depth(item.delimiter)
15
+ stack.pop while stack.last[1] >= depth
16
+
17
+ stack.last[0].items << item
18
+ nested_list = Model::List::Definition.new(items: [])
19
+ item.nested << nested_list
20
+ stack.push([nested_list, depth])
21
+ end
22
+
23
+ prune_empty_nested(root)
24
+ root
25
+ end
26
+
27
+ def dlist_depth(delimiter)
28
+ delim = delimiter.to_s
29
+ return 1 if delim == ';;' || delim.empty?
30
+
31
+ [delim.count(':') - 1, 1].max
32
+ end
33
+
34
+ def prune_empty_nested(list)
35
+ list.items.each do |item|
36
+ item.nested.select! do |n|
37
+ n.is_a?(Model::List::Definition) && n.items.any?
38
+ end
39
+ item.nested.each { |n| prune_empty_nested(n) }
40
+ end
41
+ end
42
+ end
43
+
8
44
  def self.apply(transformer_class)
9
45
  transformer_class.class_eval do
10
46
  # List item
@@ -68,7 +104,7 @@ module Coradoc
68
104
  end
69
105
 
70
106
  # Definition list term (with optional anchor)
71
- rule(dlist_term: subtree(:term_data), delimiter: simple(:_delim)) do
107
+ rule(dlist_term: subtree(:term_data), delimiter: simple(:delim)) do
72
108
  case term_data
73
109
  when Hash
74
110
  text = term_data[:text]
@@ -76,11 +112,11 @@ module Coradoc
76
112
  text = text.content.to_s if text.is_a?(Model::TextElement)
77
113
  id = term_data[:id]
78
114
  id = id.to_s if id.is_a?(Parslet::Slice)
79
- { text: text.to_s, id: id }
115
+ { text: text.to_s, id: id, delimiter: delim.to_s }
80
116
  when Model::TextElement
81
- { text: term_data.content.to_s, id: term_data.id }
117
+ { text: term_data.content.to_s, id: term_data.id, delimiter: delim.to_s }
82
118
  else
83
- { text: term_data.to_s, id: nil }
119
+ { text: term_data.to_s, id: nil, delimiter: delim.to_s }
84
120
  end
85
121
  end
86
122
 
@@ -95,13 +131,15 @@ module Coradoc
95
131
  t.is_a?(Hash) ? t[:text].to_s : t.to_s
96
132
  end
97
133
  item_id = nil
134
+ item_delim = '::'
98
135
  terms.each do |t|
99
- next unless t.is_a?(Hash) && t[:id]
136
+ next unless t.is_a?(Hash)
100
137
 
101
- item_id = t[:id].to_s
102
- break
138
+ item_id = t[:id].to_s if t[:id]
139
+ item_delim = t[:delimiter].to_s if t[:delimiter]
103
140
  end
104
- Model::List::DefinitionItem.new(terms: term_strings, contents: contents, id: item_id)
141
+ Model::List::DefinitionItem.new(terms: term_strings, contents: contents,
142
+ id: item_id, delimiter: item_delim)
105
143
  end
106
144
 
107
145
  # Definition list item with hash terms (single term case)
@@ -111,6 +149,7 @@ module Coradoc
111
149
  data = item_data.is_a?(Hash) ? item_data : { terms: Array(item_data), definition: '' }
112
150
 
113
151
  item_id = nil
152
+ item_delim = '::'
114
153
  terms_data = data[:terms]
115
154
  definition = data[:definition].to_s
116
155
 
@@ -118,17 +157,19 @@ module Coradoc
118
157
  case t
119
158
  when Hash
120
159
  item_id ||= t[:id].to_s if t[:id]
160
+ item_delim = t[:delimiter].to_s if t[:delimiter]
121
161
  t[:text].to_s
122
162
  else
123
163
  t.to_s
124
164
  end
125
165
  end
126
166
 
127
- Model::List::DefinitionItem.new(terms: terms, contents: definition, id: item_id)
167
+ Model::List::DefinitionItem.new(terms: terms, contents: definition,
168
+ id: item_id, delimiter: item_delim)
128
169
  end
129
170
 
130
171
  rule(definition_list: sequence(:list_items)) do
131
- Model::List::Definition.new(items: list_items)
172
+ ListRules.build_dlist_tree(list_items)
132
173
  end
133
174
 
134
175
  # Definition list with attribute_list (e.g., [%key])
@@ -136,7 +177,9 @@ module Coradoc
136
177
  attribute_list: simple(:attribute_list),
137
178
  definition_list: sequence(:list_items)
138
179
  ) do
139
- Model::List::Definition.new(items: list_items, attrs: attribute_list)
180
+ tree = ListRules.build_dlist_tree(list_items)
181
+ tree.attrs = attribute_list if attribute_list
182
+ tree
140
183
  end
141
184
  end
142
185
  end