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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed044cb4af9fd1e18a9cb2c34ddc5c322d801fd24962058a5b6ec55e7a3a9c43
4
- data.tar.gz: 751978667c8663723085bf0a79d5908cf4a3368e827366a1d703c2602671fcb3
3
+ metadata.gz: 791dca3b794e199e6d856458c3399946545f54a26a98cc4e8aee07de8b7aa21f
4
+ data.tar.gz: 6fd4f6457098075c4ad3f98238ab56c4eb5359271f2024e3b75b0caee3791372
5
5
  SHA512:
6
- metadata.gz: caf485068682d2b714078ff0ec934601a35498fb546d5a97677cbae3b08aa237b598768ca7bafb3f1075868ae5524a01ab20a9fede25d2e4a374d8e67390baca
7
- data.tar.gz: cf951b3c317c1a40ac4b2d170dba913b4a83d375aa60a8a7a8a16e47865d3ebe7609b61630436d90726fe682e3d78412beadac3ced3589bf642b865ad7250628
6
+ metadata.gz: d8c7eb7672609651c3152ce468727f638cd5e9b5bd73b25b1334697710b8083391db13e9d6d030f97a288a7fe28e33567a2dd934b6a455c0e5279fe4fe183455
7
+ data.tar.gz: 003e62344acf12212216306ad211330464866862dc5876d2caf7a4812c3c120a041b1da11d7ae83668b15e4a0c96a80e08984b9b7a3686de451fa548c444b0e0
@@ -32,6 +32,24 @@ module Coradoc
32
32
  attribute :document_id, :string
33
33
  attribute :ref_text, :string
34
34
  attribute :line_break, :string, default: -> { '' }
35
+
36
+ # Coerce a raw parser AST value into the canonical ref_text string.
37
+ # Accepts the shapes produced by Parser::Bibliography for `:ref_text`:
38
+ # nil, Parslet::Slice, plain String, single Model::Base, or an Array
39
+ # of any of these. Keeping this coercion on the model that owns
40
+ # ref_text (rather than in a transformer rule) keeps the transformer
41
+ # declarative and lets callers build entries from any source shape.
42
+ # @param raw [Object, nil]
43
+ # @return [String]
44
+ def self.coerce_ref_text(raw)
45
+ return '' if raw.nil?
46
+
47
+ case raw
48
+ when Array then raw.map { |e| coerce_ref_text(e) }.join
49
+ when String then raw
50
+ else raw.to_s
51
+ end
52
+ end
35
53
  end
36
54
  end
37
55
  end
@@ -69,6 +69,15 @@ module Coradoc
69
69
  Coradoc::AsciiDoc::Model::Video
70
70
  ]
71
71
 
72
+ # Raw YAML frontmatter text, or nil if absent.
73
+ #
74
+ # AsciiDoc treats frontmatter as opaque text — the YAML is only
75
+ # parsed/emitted at the CoreModel boundary by
76
+ # CoreModel::FrontmatterBlock::Codec (single source of truth).
77
+ # Storing raw text keeps the AsciiDoc parser/serializer symmetric
78
+ # without dragging YAML semantics into the AsciiDoc model (MECE).
79
+ attribute :frontmatter, :string
80
+
72
81
  # @param [Integer] index The index of the section to retrieve
73
82
  # @return [Coradoc::AsciiDoc::Model::Base] The section at the specified index
74
83
  def [](index)
@@ -4,7 +4,7 @@ module Coradoc
4
4
  module AsciiDoc
5
5
  module Model
6
6
  class Glossaries < Base
7
- attribute :items, :string, collection: true
7
+ attribute :items, :string, collection: true, initialize_empty: true
8
8
  end
9
9
  end
10
10
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ module Model
6
+ module List
7
+ # Shared base class for all list container types.
8
+ #
9
+ # Every list flavor (Core, Definition, and any future ones) inherits
10
+ # the universal list-level attributes from this class:
11
+ #
12
+ # - +id+ optional anchor identifier (inherited from Model::Base)
13
+ # - +attrs+ block attribute list, e.g. +[%hardbreaks]+ or +[#my-id]+
14
+ #
15
+ # Subclasses declare their own +items+ with the appropriate item type
16
+ # (List::Item for ordered/unordered, List::DefinitionItem for definition)
17
+ # plus any flavor-specific attributes (marker, prefix, delimiter, ...).
18
+ #
19
+ # @!attribute [r] attrs
20
+ # @return [Coradoc::AsciiDoc::Model::AttributeList] Additional list
21
+ # attributes parsed from the +[...]+ block header preceding the list
22
+ class Base < Coradoc::AsciiDoc::Model::Base
23
+ include Coradoc::AsciiDoc::Model::Anchorable
24
+
25
+ attribute :attrs,
26
+ Coradoc::AsciiDoc::Model::AttributeList,
27
+ default: -> { Coradoc::AsciiDoc::Model::AttributeList.new }
28
+
29
+ asciidoc do
30
+ map_attribute 'id', to: :id
31
+ map_attribute 'attrs', to: :attrs
32
+ end
33
+
34
+ def block_level?
35
+ true
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -4,21 +4,17 @@ module Coradoc
4
4
  module AsciiDoc
5
5
  module Model
6
6
  module List
7
- # Base class for list elements in AsciiDoc documents.
7
+ # Base class for ordered/unordered lists.
8
8
  #
9
- # Lists are container elements that hold list items and provide
10
- # functionality for different list types (ordered, unordered, definition).
9
+ # Inherits universal list attributes (id, attrs) from List::Base and
10
+ # adds the marker-related attributes specific to bulleted/numbered lists.
11
11
  #
12
- # @!attribute [r] id
13
- # @return [String, nil] Optional identifier for the list
14
12
  # @!attribute [r] prefix
15
- # @return [String, nil] List marker prefix (e.g., "*", "*", "**", etc.)
13
+ # @return [String, nil] List marker prefix (e.g., "*", "**", etc.)
16
14
  # @!attribute [r] items
17
15
  # @return [Array<ListItem>] List items in this list
18
16
  # @!attribute [r] ol_count
19
17
  # @return [Integer] Ordered list nesting level
20
- # @!attribute [r] attrs
21
- # @return [AttributeList] Additional list attributes
22
18
  # @!attribute [r] marker
23
19
  # @return [String, nil] The marker character used for this list
24
20
  #
@@ -27,31 +23,15 @@ module Coradoc
27
23
  # list.items << Coradoc::AsciiDoc::Model::List::Item.new("Item 1")
28
24
  #
29
25
  class Core < Nestable
30
- include Coradoc::AsciiDoc::Model::Anchorable
31
-
32
- def block_level?
33
- true
34
- end
35
-
36
- attribute :id, :string
37
26
  attribute :prefix, :string
38
- # attribute :anchor, Inline::Anchor, default: -> {
39
- # id.nil? ? nil : Inline::Anchor.new(id)
40
- # }
41
27
  attribute :items, Coradoc::AsciiDoc::Model::List::Item, collection: true, initialize_empty: true
42
28
  attribute :ol_count, :integer, default: -> { 1 }
43
- attribute :attrs, Coradoc::AsciiDoc::Model::AttributeList, default: lambda {
44
- Coradoc::AsciiDoc::Model::AttributeList.new
45
- }
46
29
  attribute :marker, :string
47
30
 
48
31
  asciidoc do
49
- map_attribute 'id', to: :id
50
- map_attribute 'anchor', to: :anchor
51
32
  map_attribute 'prefix', to: :prefix
52
33
  map_attribute 'items', to: :items
53
34
  map_attribute 'ol_count', to: :ol_count
54
- map_attribute 'attrs', to: :attrs
55
35
  map_attribute 'marker', to: :marker
56
36
  end
57
37
  end
@@ -4,6 +4,13 @@ module Coradoc
4
4
  module AsciiDoc
5
5
  module Model
6
6
  module List
7
+ # Definition list container. Inherits universal list attributes
8
+ # (id, attrs) from List::Base.
9
+ #
10
+ # @!attribute [r] items
11
+ # @return [Array<DefinitionItem>] Definition items in this list
12
+ # @!attribute [r] delimiter
13
+ # @return [String] Delimiter indicating nesting depth ('::', ':::', ...)
7
14
  class Definition < Base
8
15
  attribute :items,
9
16
  Coradoc::AsciiDoc::Model::Base,
@@ -26,7 +26,7 @@ module Coradoc
26
26
  # item.terms << Coradoc::AsciiDoc::Model::Term.new(term: "API")
27
27
  # item.contents << Coradoc::AsciiDoc::Model::TextElement.new("Application Programming Interface")
28
28
  #
29
- class DefinitionItem < Base
29
+ class DefinitionItem < Coradoc::AsciiDoc::Model::Base
30
30
  include Coradoc::AsciiDoc::Model::Anchorable
31
31
 
32
32
  attribute :id, :string
@@ -40,7 +40,7 @@ module Coradoc
40
40
  # item.nested = Coradoc::AsciiDoc::Model::List::Unordered.new
41
41
  # item.nested.items << Coradoc::AsciiDoc::Model::List::Item.new
42
42
  #
43
- class Item < Base
43
+ class Item < Coradoc::AsciiDoc::Model::Base
44
44
  include Coradoc::AsciiDoc::Model::Anchorable
45
45
 
46
46
  attribute :id, :string
@@ -4,9 +4,13 @@ module Coradoc
4
4
  module AsciiDoc
5
5
  module Model
6
6
  module List
7
- # Mixin module for nestable list functionality.
8
- # Provides common functionality for lists that can contain nested lists.
9
- class Nestable < Coradoc::AsciiDoc::Model::Base
7
+ # Marker class for lists that can be nested inside a List::Item
8
+ # via its +nested+ attribute. Inherits universal list attributes
9
+ # (id, attrs) from List::Base.
10
+ #
11
+ # List::Definition does not extend Nestable because it has its own
12
+ # nesting model via List::DefinitionItem#nested.
13
+ class Nestable < Base
10
14
  end
11
15
  end
12
16
  end
@@ -6,16 +6,18 @@ module Coradoc
6
6
  # Namespace for all AsciiDoc list types and their items.
7
7
  #
8
8
  # List Architecture:
9
- # - List::Core - Common list functionality (base class)
9
+ # - List::Base - Universal list attributes (id, attrs)
10
+ # - List::Nestable - Marker class for lists nestable inside Item
11
+ # - List::Core - Ordered/unordered list base (marker, prefix, ol_count)
10
12
  # - List::Ordered - Numbered lists (1., 2., 3., etc.)
11
13
  # - List::Unordered - Bulleted lists (*, **, etc.)
12
14
  # - List::Definition - Labeled/definition lists (term:: definition)
13
15
  # - List::Item - Item for ordered/unordered lists
14
16
  # - List::DefinitionItem - Item for definition lists
15
- # - List::Nestable - Mixin for nesting support
16
17
  #
17
18
  module List
18
19
  # Autoload list types lazily
20
+ autoload :Base, 'coradoc/asciidoc/model/list/base'
19
21
  autoload :Core, 'coradoc/asciidoc/model/list/core'
20
22
  autoload :Nestable, 'coradoc/asciidoc/model/list/nestable'
21
23
  autoload :Ordered, 'coradoc/asciidoc/model/list/ordered'
@@ -11,6 +11,7 @@ module Coradoc
11
11
  autoload :AttributeList, 'coradoc/asciidoc/parser/attribute_list'
12
12
  autoload :Bibliography, 'coradoc/asciidoc/parser/bibliography'
13
13
  autoload :Block, 'coradoc/asciidoc/parser/block'
14
+ autoload :BlockHeader, 'coradoc/asciidoc/parser/block_header'
14
15
  autoload :Citation, 'coradoc/asciidoc/parser/citation'
15
16
  autoload :Content, 'coradoc/asciidoc/parser/content'
16
17
  autoload :DocumentAttributes, 'coradoc/asciidoc/parser/document_attributes'
@@ -18,6 +19,7 @@ module Coradoc
18
19
  autoload :Inline, 'coradoc/asciidoc/parser/inline'
19
20
  autoload :List, 'coradoc/asciidoc/parser/list'
20
21
  autoload :Paragraph, 'coradoc/asciidoc/parser/paragraph'
22
+ autoload :RuleDispatcher, 'coradoc/asciidoc/parser/rule_dispatcher'
21
23
  autoload :Section, 'coradoc/asciidoc/parser/section'
22
24
  autoload :Table, 'coradoc/asciidoc/parser/table'
23
25
  autoload :Term, 'coradoc/asciidoc/parser/term'
@@ -29,6 +31,7 @@ module Coradoc
29
31
  include AttributeList
30
32
  include Bibliography
31
33
  include Block
34
+ include BlockHeader
32
35
  include Citation
33
36
  include Content
34
37
  include DocumentAttributes
@@ -82,78 +85,15 @@ module Coradoc
82
85
  warn e.parse_failure_cause.ascii_tree
83
86
  end
84
87
 
85
- def rule_dispatch(rule_name, *args, **kwargs)
86
- @dispatch_data ||= {}
87
- dispatch_key = [rule_name, args, kwargs.to_a.sort]
88
- dispatch_hash = dispatch_key.hash.abs
89
- unless @dispatch_data.key?(dispatch_hash)
90
- alias_name = :"#{rule_name}_#{dispatch_hash}"
91
- Coradoc::AsciiDoc::Parser::Base.class_exec do
92
- rule(alias_name) do
93
- public_send(rule_name, *args, **kwargs)
94
- end
95
- end
96
- @dispatch_data[dispatch_hash] = alias_name
97
- end
98
- dispatch_method = @dispatch_data[dispatch_hash]
99
- public_send(dispatch_method)
100
- end
101
-
102
- def self.config(key)
103
- # NOTE: These are internal dispatch configuration options for the parser:
104
- # - add_dispatch: Enables automatic method dispatching
105
- # - with_params: Supports parameterized rule invocation
106
- c = {
107
- add_dispatch: true,
108
- with_params: true
109
- }
110
-
111
- raise ArgumentError, "Unknown config key: #{key}. Available keys: #{c.keys.join(', ')}" unless c.key?(key)
112
-
113
- c[key]
114
- end
115
-
116
- # Collect parser methods from all parser modules (excluding Base, Cache, and FixFiles)
117
- # Base is the parser class, Cache is a utility class, FixFiles is a utility module
118
- parser_constants = Coradoc::AsciiDoc::Parser.constants - %i[Base Cache FixFiles]
119
- parser_methods = parser_constants.each_with_object({}) do |const, acc|
120
- rule_names = Coradoc::AsciiDoc::Parser.const_get(const).instance_methods
121
- rule_names.each do |rule_name|
122
- acc[rule_name] ||= []
123
- acc[rule_name] << const
124
- end
125
- end
126
-
127
- # Warn about duplicated parser methods:
128
- parser_methods.each do |rule_name, defn_sites|
129
- count = defn_sites.length
130
- if count > 1
131
- defn_site_constants = defn_sites.map { |const| Coradoc::AsciiDoc::Parser.const_get(const) }
132
- Coradoc::Logger.warn "Parser method '#{rule_name}' is defined #{count} times in #{defn_site_constants.join(', ')}"
133
- end
134
- end
135
-
136
- parser_methods.each_key do |rule_name|
137
- params = Coradoc::AsciiDoc::Parser::Base.instance_method(rule_name).parameters
138
- if config(:add_dispatch) && params == []
139
- alias_name = :"alias_nondispatch_#{rule_name}"
140
- Coradoc::AsciiDoc::Parser::Base.class_exec do
141
- alias_method alias_name, rule_name
142
- rule(rule_name) do
143
- public_send(alias_name)
144
- end
145
- end
146
- elsif config(:add_dispatch) && config(:with_params)
147
- alias_name = :"alias_dispatch_#{rule_name}"
148
- Coradoc::AsciiDoc::Parser::Base.class_exec do
149
- alias_method alias_name, rule_name
150
- define_method(rule_name) do |*args, **kwargs|
151
- rule_dispatch(alias_name, *args, **kwargs)
152
- end
153
- end
154
- end
88
+ def rule_dispatch(rule_name, *, **)
89
+ RuleDispatcher.dispatch(self, rule_name, *, **)
155
90
  end
156
91
  end
92
+
93
+ # Wrap every parser rule for Parslet memoization. Must run after all
94
+ # parser modules are included in Base so that instance_method(rule_name)
95
+ # finds the methods defined by every module.
96
+ RuleDispatcher.apply(Base)
157
97
  end
158
98
  end
159
99
  end
@@ -30,14 +30,6 @@ module Coradoc
30
30
  open_block(n_deep)).as(:block)
31
31
  end
32
32
 
33
- def reviewer_note_block(_n_deep = 3)
34
- # Match blocks with reviewer attribute
35
- # This should only match when attribute_list contains reviewer=
36
- # For now, we'll make it not match anything specific
37
- # The block() method will handle these cases
38
- str('').absent? # Never matches - placeholder for future implementation
39
- end
40
-
41
33
  def example_block(n_deep)
42
34
  block_style(n_deep, '=', 4)
43
35
  end
@@ -83,6 +75,7 @@ module Coradoc
83
75
  end
84
76
 
85
77
  # Block delimiter: 4+ identical characters (or 2 for open block)
78
+ # Used by paragraph.rb to reject lines that look like block delimiters.
86
79
  # NOTE: repeat(4,) means 4 or more (not exactly 4)
87
80
  def block_delimiter
88
81
  line_start? >>
@@ -95,16 +88,6 @@ module Coradoc
95
88
  newline
96
89
  end
97
90
 
98
- def element_attributes
99
- block_title.maybe >>
100
- element_id.maybe >>
101
- (attribute_list >> newline).maybe >>
102
- block_title.maybe >>
103
- newline.maybe >>
104
- (attribute_list >> newline).maybe >>
105
- element_id.maybe
106
- end
107
-
108
91
  # Block style parser with variable delimiter length
109
92
  # @param n_deep [Integer] Nesting depth for nested blocks
110
93
  # @param delimiter [String] The delimiter character ("=", "-", "_", "*", "+")
@@ -121,17 +104,27 @@ module Coradoc
121
104
  delim_str = c.captures[capture_key].to_s.strip
122
105
  closing_pattern = str(delim_str) >> newline
123
106
 
124
- content = block_image
125
- content |= block(n_deep - 1) if n_deep.positive?
126
- content |= list
127
- content |= text_line(false, unguarded: true, verbatim: verbatim)
128
- content |= empty_line.as(:line_break)
107
+ # Verbatim blocks (source/listing) treat their body as literal
108
+ # text per the AsciiDoc spec — no substitutions, no nested
109
+ # blocks. Allowing nested block parsing here would consume
110
+ # shorter inner delimiters (e.g. `----` inside `------`) and
111
+ # strip the original structure when serializing back.
112
+ content = if verbatim
113
+ text_line(false, unguarded: true, verbatim: true) |
114
+ empty_line.as(:line_break)
115
+ else
116
+ c = block_image
117
+ c |= block(n_deep - 1) if n_deep.positive?
118
+ c |= list
119
+ c |= text_line(false, unguarded: true)
120
+ c |= empty_line.as(:line_break)
121
+ c
122
+ end
129
123
 
130
124
  (closing_pattern.absent? >> content).repeat(1)
131
125
  end
132
126
 
133
- element_attributes >>
134
- (line_start? >> attribute_list >> newline).maybe >>
127
+ block_header >>
135
128
  line_start? >>
136
129
  current_delimiter.as(:delimiter) >> newline >>
137
130
  if type == :pass
@@ -165,8 +158,7 @@ module Coradoc
165
158
  (closing_pattern.absent? >> content).repeat(1)
166
159
  end
167
160
 
168
- element_attributes >>
169
- (line_start? >> attribute_list >> newline).maybe >>
161
+ block_header >>
170
162
  line_start? >>
171
163
  current_delimiter.as(:delimiter) >> newline >>
172
164
  if type == :pass
@@ -6,112 +6,31 @@ module Coradoc
6
6
  # Single Responsibility: Assemble block AST from metadata hints
7
7
  # Takes metadata analysis and input text, returns proper AST structure
8
8
  class BlockAssembler
9
- # Main entry point: assemble block AST from input and metadata
9
+ # Main entry point: assemble block AST from input and metadata.
10
+ #
11
+ # The result hash always includes :delimiter and :lines. :title and
12
+ # :attribute_list are included only when present in the metadata.
13
+ # The previous 4-arm `case pattern` switch collapsed into this single
14
+ # method once it became clear the only difference between arms was
15
+ # which optional keys appeared in the result hash.
16
+ #
10
17
  # @param input [String] The input text to parse
11
- # @param metadata_analysis [Hash] Analysis from MetadataDetector
12
- # @return [Hash] AST hash {:block => {...}}
13
- def self.assemble(input, metadata_analysis)
14
- return nil unless metadata_analysis
15
-
16
- pattern = metadata_analysis[:pattern]
17
-
18
- # Delegate to pattern-specific methods (Open/Closed Principle)
19
- case pattern
20
- when :title_attr_delim
21
- assemble_title_attr_delim(input, metadata_analysis)
22
- when :title_delim
23
- assemble_title_delim(input, metadata_analysis)
24
- when :attr_delim
25
- assemble_attr_delim(input, metadata_analysis)
26
- when :plain_delim
27
- assemble_plain_delim(input, metadata_analysis)
28
- end
29
- end
30
-
31
- # Handle: Title + Attribute + Delimiter pattern
32
- # @param input [String] The input text
33
- # @param metadata [Hash] Metadata analysis
34
- # @return [Hash] Complete block hash
35
- def self.assemble_title_attr_delim(input, metadata)
36
- lines = input.lines
37
- delimiter_line = metadata[:delimiter_line]
38
- delimiter = metadata[:delimiter][:delimiter]
39
-
40
- # Extract components
41
- title_text = metadata[:title][:text]
42
- attr_list = parse_attribute_list(metadata[:attributes])
43
-
44
- # Extract block content
45
- block_lines = extract_block_lines(lines, delimiter_line, delimiter)
46
-
47
- {
48
- title: title_text,
49
- attribute_list: attr_list,
50
- delimiter: delimiter,
51
- lines: block_lines
52
- }
53
- end
18
+ # @param metadata [Hash] Analysis from MetadataDetector
19
+ # @return [Hash, nil] AST hash, or nil if metadata is nil
20
+ def self.assemble(input, metadata)
21
+ return nil unless metadata
54
22
 
55
- # Handle: Title + Delimiter pattern (no attributes)
56
- # @param input [String] The input text
57
- # @param metadata [Hash] Metadata analysis
58
- # @return [Hash] Block hash without attributes
59
- def self.assemble_title_delim(input, metadata)
60
23
  lines = input.lines
61
24
  delimiter_line = metadata[:delimiter_line]
62
25
  delimiter = metadata[:delimiter][:delimiter]
63
26
 
64
- # Extract components
65
- title_text = metadata[:title][:text]
66
-
67
- # Extract block content
68
- block_lines = extract_block_lines(lines, delimiter_line, delimiter)
69
-
70
- {
71
- title: title_text,
27
+ result = {
72
28
  delimiter: delimiter,
73
- lines: block_lines
74
- }
75
- end
76
-
77
- # Handle: Attribute + Delimiter pattern (no title)
78
- # @param input [String] The input text
79
- # @param metadata [Hash] Metadata analysis
80
- # @return [Hash] Block hash without title
81
- def self.assemble_attr_delim(input, metadata)
82
- lines = input.lines
83
- delimiter_line = metadata[:delimiter_line]
84
- delimiter = metadata[:delimiter][:delimiter]
85
-
86
- # Extract components
87
- attr_list = parse_attribute_list(metadata[:attributes])
88
-
89
- # Extract block content
90
- block_lines = extract_block_lines(lines, delimiter_line, delimiter)
91
-
92
- {
93
- attribute_list: attr_list,
94
- delimiter: delimiter,
95
- lines: block_lines
96
- }
97
- end
98
-
99
- # Handle: Just Delimiter pattern
100
- # @param input [String] The input text
101
- # @param metadata [Hash] Metadata analysis
102
- # @return [Hash] Minimal block hash
103
- def self.assemble_plain_delim(input, metadata)
104
- lines = input.lines
105
- delimiter_line = metadata[:delimiter_line]
106
- delimiter = metadata[:delimiter][:delimiter]
107
-
108
- # Extract block content
109
- block_lines = extract_block_lines(lines, delimiter_line, delimiter)
110
-
111
- {
112
- delimiter: delimiter,
113
- lines: block_lines
29
+ lines: extract_block_lines(lines, delimiter_line, delimiter)
114
30
  }
31
+ result[:title] = metadata[:title][:text] if metadata[:title]
32
+ result[:attribute_list] = parse_attribute_list(metadata[:attributes]) if metadata[:attributes]
33
+ result
115
34
  end
116
35
 
117
36
  # Helper: Extract content between delimiters
@@ -132,8 +51,16 @@ module Coradoc
132
51
  # Check if this is the closing delimiter
133
52
  break if line.strip == delimiter
134
53
 
135
- # Handle empty lines vs content lines
136
- if line.strip.empty?
54
+ stripped = line.strip
55
+ if nested_delimiter?(stripped) && stripped != delimiter
56
+ nested_delimiter = stripped
57
+ nested_lines = extract_block_lines(lines, i, nested_delimiter)
58
+
59
+ block_lines << { block: { delimiter: nested_delimiter, lines: nested_lines } }
60
+
61
+ i += nested_lines.length + 1
62
+ elsif line.strip.empty?
63
+ # Handle empty lines vs content lines
137
64
  block_lines << { line_break: "\n" }
138
65
  else
139
66
  # Remove trailing newline for processing
@@ -147,6 +74,16 @@ module Coradoc
147
74
  block_lines
148
75
  end
149
76
 
77
+ def self.nested_delimiter?(str)
78
+ return false if str.length < 2
79
+
80
+ char = str[0]
81
+ return false unless ['-', '*', '=', '_', '+'].include?(char)
82
+ return false unless str.chars.all? { |c| c == char }
83
+
84
+ (char == '-' && str.length == 2) || str.length >= 4
85
+ end
86
+
150
87
  # Parse attribute list to match expected AST structure
151
88
  # @param attr_meta [Hash] Attribute metadata from detector
152
89
  # @return [Hash] Properly formatted attribute_list hash
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ module Parser
6
+ # Single Responsibility: parse the optional header that precedes a block.
7
+ #
8
+ # Encapsulates the canonical AsciiDoc block-header grammar in one place
9
+ # (DRY, MECE, single source of truth). Before this module existed, every
10
+ # block-like rule (block, table, section, paragraph, block_image) inlined
11
+ # its own header rule with subtly different slot orderings — and several
12
+ # of them captured the same Parslet key more than once in a single
13
+ # sequence, which triggered Parslet's "Duplicate subtrees while merging
14
+ # result" warning and silently discarded one of the captured values.
15
+ #
16
+ # Canonical header shape, each component at most once:
17
+ #
18
+ # block_title? >> element_id? >> attribute_blocks?
19
+ #
20
+ # `attribute_blocks` accepts one or more consecutive `[...]` attribute
21
+ # lists, captured as a Parslet sequence under the :attribute_list key.
22
+ # Real-world AsciiDoc often stacks attribute lists before a block:
23
+ #
24
+ # [role=quote]
25
+ # [source, ruby]
26
+ # ----
27
+ # code
28
+ # ----
29
+ #
30
+ # Capturing the sequence (rather than one slot per `[...]`) preserves
31
+ # every attribute and lets the transformer merge them into a single
32
+ # Coradoc::AsciiDoc::Model::AttributeList downstream.
33
+ module BlockHeader
34
+ # Canonical block header rule. Single canonical order; each of title,
35
+ # id, and attribute_blocks is optional and matched at most once.
36
+ # @return [Parslet::Atoms::Base]
37
+ def block_header
38
+ block_title.maybe >>
39
+ element_id.maybe >>
40
+ attribute_blocks.maybe
41
+ end
42
+
43
+ # One or more consecutive attribute_list + newline sequences, captured
44
+ # as a Parslet sequence under :attribute_list. When multiple `[...]`
45
+ # blocks precede a delimiter, all of them reach the transformer; when
46
+ # only one appears, the sequence has a single element and the existing
47
+ # transformer rule handles it the same way as before.
48
+ # @return [Parslet::Atoms::Base]
49
+ def attribute_blocks
50
+ (attribute_list >> newline).repeat(1).as(:attribute_list)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end