coradoc-adoc 2.0.21 → 2.0.23

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c4b5749f4fd1a691eb76fe9f7848b1d4bc2cd2e43cd2ee0a5f1afdcb697459c
4
- data.tar.gz: 90e6bf66da9a7c4dba7c7eafbdabf378259e4b9792a93d4c88209d52bd991876
3
+ metadata.gz: 69bc04902f8020decc17928a5397de2876f060ed8ee36de95f0920ba6312e8b4
4
+ data.tar.gz: ca56574d1b78863a574551732397ff65944f2b012781def3bb4ea4bcf8b0aaf1
5
5
  SHA512:
6
- metadata.gz: e421d05a3a2c43387471224818a5261229478c1539ddade33a30957be8d7fb0775c9b0bb95b59afa32cb4e84f0adab07b62164113a521635fd5e70cdbe66ecec
7
- data.tar.gz: dd6c2ea15fcb4c3e521a929006ba2e7ebbff5f6548902a2d7947cdac04a9005a792a9ae4fb07d68f53e30c844de9cbd00bd62a0fa034fa9f9c298209fe50ec7e
6
+ metadata.gz: 708cf53d7a9c6de58e15aac99a1e77343e004b7d014697335cd91f96143584b0701235e84030948a5ab6a09eba8612a361b215b1be9707ca07943d3f7179ad09
7
+ data.tar.gz: ff60d697d4fff0ec229ca5b597f1fe2f37d34129bff79f10276b72824ecd418f770d9229800dee6f143f95345af0c1432aceb100385b6c578fd08abb01a3767a
@@ -87,6 +87,20 @@ module Coradoc
87
87
  )
88
88
  end
89
89
 
90
+ # Remove the first named attribute matching `name` and return its
91
+ # scalar value (or nil if absent). Used by promoters that lift a
92
+ # named attr out of the residual bag into a typed field.
93
+ # @param name [String, Symbol]
94
+ # @return [String, nil]
95
+ def delete_named(name)
96
+ name_str = name.to_s
97
+ idx = named.index { |n| n.name.to_s == name_str }
98
+ return nil unless idx
99
+
100
+ removed = @named.delete_at(idx)
101
+ removed.value.first&.to_s
102
+ end
103
+
90
104
  # Validate named attributes against validators
91
105
  #
92
106
  # @param validators [Hash] Hash of name => matcher pairs
@@ -209,12 +223,24 @@ module Coradoc
209
223
  positional.empty? && named.empty?
210
224
  end
211
225
 
212
- # Get a named attribute value by name
226
+ # Get the scalar value of the first named attribute matching `name`.
227
+ # Returns the first element of the underlying multi-value array, or
228
+ # nil if the name is absent. Use {#fetch_all} to retrieve the full
229
+ # multi-value array.
213
230
  # @param name [String, Symbol] The attribute name
214
- # @return [Object, nil] The attribute value or nil if not found
231
+ # @return [String, nil]
215
232
  def [](name)
216
233
  name_str = name.to_s
217
- named.find { |n| n.name.to_s == name_str }&.value
234
+ named.find { |n| n.name.to_s == name_str }&.value&.first&.to_s
235
+ end
236
+
237
+ # Get the full multi-value array for a named attribute.
238
+ # @param name [String, Symbol]
239
+ # @return [Array<String>] (empty when absent)
240
+ def fetch_all(name)
241
+ name_str = name.to_s
242
+ found = named.find { |n| n.name.to_s == name_str }
243
+ found ? found.value : []
218
244
  end
219
245
 
220
246
  # Get a named attribute value with default
@@ -222,7 +248,8 @@ module Coradoc
222
248
  # @param default [Object] The default value if not found
223
249
  # @return [Object] The attribute value or default
224
250
  def fetch(name, default = nil)
225
- self[name] || default
251
+ value = self[name]
252
+ value.nil? ? default : value
226
253
  end
227
254
  end
228
255
  end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ module Model
6
+ module Image
7
+ # Pure-function promoter: lifts semantically meaningful slots out of
8
+ # a generic {Model::AttributeList} into the typed fields declared on
9
+ # an image class, and the inverse — composes a serialisable
10
+ # AttributeList from typed fields + residual.
11
+ #
12
+ # The promotion map is owned by the target class itself, via
13
+ # +promoted_positional+ and +promoted_named+. This module is the
14
+ # single place that consumes those declarations, so adding a new
15
+ # promoted field is a one-line class-level change (OCP).
16
+ #
17
+ # Positional slots are consumed by index; named slots are removed
18
+ # from the residual list so they don't appear twice. The residual
19
+ # list preserves order and identity for everything that wasn't
20
+ # promoted (e.g. +scaledwidth+, +pdfwidth+, +opts+).
21
+ #
22
+ # @example Extract for an inline image
23
+ # list = Model::AttributeList.new
24
+ # list.add_positional('Alt', 'Thumb')
25
+ # list.add_named('width', '640')
26
+ # extracted, residual = AttributeExtractor.call(list, InlineImage)
27
+ # extracted # => { alt: 'Alt', role: 'Thumb', width: '640' }
28
+ # residual.positional # => []
29
+ # residual.named # => []
30
+ #
31
+ # @example Compose for serialisation
32
+ # AttributeExtractor.compose(image)
33
+ # # => Model::AttributeList with positional [alt, role] + named
34
+ # # [width, height, link] + residual attrs, in declaration order
35
+ #
36
+ module AttributeExtractor
37
+ module_function
38
+
39
+ # @param attribute_list [Model::AttributeList, nil]
40
+ # @param target_class [Class] an Image::Core subclass
41
+ # @return [Array<(Hash{Symbol=>String}, Model::AttributeList)>]
42
+ # a tuple of promoted typed values and the residual list
43
+ def call(attribute_list, target_class)
44
+ source = attribute_list || Model::AttributeList.new
45
+ residual = Model::AttributeList.new
46
+ extracted = {}
47
+
48
+ promote_positional(extracted, residual, source, target_class)
49
+ promote_named(extracted, residual, source, target_class)
50
+
51
+ [extracted, residual]
52
+ end
53
+
54
+ # Inverse of {call}: rebuild a serialisable AttributeList from a
55
+ # model's typed fields plus its residual list. Used by the AsciiDoc
56
+ # image serializer so round-trips reproduce the original syntax.
57
+ #
58
+ # A field that's declared in both +promoted_positional+ and
59
+ # +promoted_named+ (e.g. inline image +role+) is emitted only
60
+ # once — via its positional slot when filled, otherwise via named.
61
+ # @param model [Coradoc::AsciiDoc::Model::Image::Core]
62
+ # @return [Model::AttributeList]
63
+ def compose(model)
64
+ composed = Model::AttributeList.new
65
+ filled = compose_positional(composed, model)
66
+ compose_named(composed, model, filled)
67
+ append_residual(composed, model.attributes)
68
+ composed
69
+ end
70
+
71
+ def compose_positional(composed, model)
72
+ filled = []
73
+ model.class.promoted_positional.each do |attr_name|
74
+ value = model.public_send(attr_name)
75
+ next if value.nil? || value.to_s.empty?
76
+
77
+ composed.add_positional(value.to_s)
78
+ filled << attr_name
79
+ end
80
+ filled
81
+ end
82
+
83
+ def compose_named(composed, model, filled)
84
+ model.class.promoted_named.each do |attr_name|
85
+ next if filled.include?(attr_name)
86
+
87
+ value = model.public_send(attr_name)
88
+ next if value.nil? || value.to_s.empty?
89
+
90
+ composed.add_named(attr_name.to_s, value.to_s)
91
+ end
92
+ end
93
+
94
+ def promote_positional(extracted, residual, source, target_class)
95
+ promoted = target_class.promoted_positional
96
+ source.positional.each_with_index do |positional_attr, index|
97
+ attr_name = promoted[index]
98
+ value = positional_attr.value
99
+ if attr_name && !value.to_s.empty?
100
+ extracted[attr_name] = value.to_s
101
+ else
102
+ residual.add_positional(value)
103
+ end
104
+ end
105
+ end
106
+
107
+ def promote_named(extracted, residual, source, target_class)
108
+ promoted_names = target_class.promoted_named.to_set(&:to_s)
109
+ source.named.each do |named_attr|
110
+ promote_one_named(extracted, residual, named_attr, promoted_names)
111
+ end
112
+ end
113
+
114
+ def promote_one_named(extracted, residual, named_attr, promoted_names)
115
+ name_str = named_attr.name.to_s
116
+ unless promoted_names.include?(name_str)
117
+ residual.add_named(named_attr.name, named_attr.value)
118
+ return
119
+ end
120
+ # Positional promotion wins: a slot already filled positionally
121
+ # is not overwritten by a same-named entry (rare, but possible
122
+ # when both `[alt, role, role=X]` are supplied).
123
+ key = name_str.to_sym
124
+ return if extracted.key?(key)
125
+
126
+ value = named_attr.value.first&.to_s
127
+ return if value.nil? || value.empty?
128
+
129
+ extracted[key] = value
130
+ end
131
+
132
+ def append_residual(composed, residual)
133
+ return unless residual.is_a?(Model::AttributeList)
134
+
135
+ residual.positional.each { |p| composed.add_positional(p.value) }
136
+ residual.named.each { |n| composed.add_named(n.name, n.value) }
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -18,6 +18,14 @@ module Coradoc
18
18
  default: lambda {
19
19
  ::Coradoc::AsciiDoc::Model::AttributeList.new
20
20
  }
21
+
22
+ # Block images support the legacy positional form
23
+ # `image::target[alt, caption, role, ...]`, so the 2nd positional
24
+ # is promoted to `caption` (Asciidoctor image block macro shorthand).
25
+ # @return [Array<Symbol>]
26
+ def self.promoted_positional
27
+ %i[alt caption]
28
+ end
21
29
  end
22
30
  end
23
31
  end
@@ -9,30 +9,21 @@ module Coradoc
9
9
  # Images can be block-level (standalone paragraphs) or inline (within text).
10
10
  # This base class provides common functionality for both types.
11
11
  #
12
- # @!attribute [r] id
13
- # @return [String, nil] Optional identifier for the image
12
+ # Typed promotion of attribute-list slots
13
+ # ---------------------------------------
14
14
  #
15
- # @!attribute [r] title
16
- # @return [String, nil] Optional image title/alt text
17
- #
18
- # @!attribute [r] src
19
- # @return [String] The image source URL or path
20
- #
21
- # @!attribute [r] attributes
22
- # @return [Coradoc::AsciiDoc::Model::Image::Core::AttributeList] Image-specific attributes
23
- #
24
- # @!attribute [r] annotate_missing
25
- # @return [String, nil] Annotation text for missing images
26
- #
27
- # @!attribute [r] line_break
28
- # @return [String] Line break character (default: "")
29
- #
30
- # @!attribute [r] colons
31
- # @return [String, nil] Colon positioning for attributes
32
- #
33
- # @see Coradoc::AsciiDoc::Model::Image::BlockImage Block-level images
34
- # @see Coradoc::AsciiDoc::Model::Image::InlineImage Inline images
15
+ # Semantically meaningful image attributes (`alt`, `role`, `width`,
16
+ # `height`, `link`) are declared as typed lutaml-model fields on
17
+ # `Core` itself — not as validators on a generic bag. The class-level
18
+ # {promoted_positional} and {promoted_named} methods are the single
19
+ # source of truth for which slots get lifted into typed fields and in
20
+ # what order; subclasses override them to reflect syntax differences
21
+ # (e.g. inline images treat the 2nd positional as `role`, block images
22
+ # do not).
35
23
  #
24
+ # The lift itself is performed by {AttributeExtractor}, a pure function
25
+ # over (AttributeList, target_class) → (extracted_hash, residual_list).
26
+ # Anything not promoted stays in `attributes` for round-trip fidelity.
36
27
  class Core < Coradoc::AsciiDoc::Model::Base
37
28
  # Autoload nested AttributeList class
38
29
  autoload :AttributeList, 'coradoc/asciidoc/model/image/core/attribute_list'
@@ -42,6 +33,12 @@ module Coradoc
42
33
  attribute :id, :string
43
34
  attribute :title, :string
44
35
  attribute :src, :string
36
+ attribute :alt, :string
37
+ attribute :caption, :string
38
+ attribute :role, :string
39
+ attribute :width, :string
40
+ attribute :height, :string
41
+ attribute :link, :string
45
42
  attribute :attributes,
46
43
  Coradoc::AsciiDoc::Model::Image::Core::AttributeList,
47
44
  default: lambda {
@@ -51,17 +48,29 @@ module Coradoc
51
48
  attribute :line_break, :string, default: -> { '' }
52
49
  attribute :colons, :string
53
50
 
54
- # Aliases for common attribute accessors
55
51
  alias path src
56
- alias alt title
52
+
53
+ # Positional attribute-list slots that this image class promotes to
54
+ # typed fields, in order. Subclasses override to reflect their
55
+ # syntax. Index 0 → alt for all image kinds; index 1 → role for
56
+ # inline images only.
57
+ # @return [Array<Symbol>]
58
+ def self.promoted_positional
59
+ %i[alt]
60
+ end
61
+
62
+ # Named attribute-list keys that this image class promotes to typed
63
+ # fields. The same set applies to both inline and block images.
64
+ # @return [Array<Symbol>]
65
+ def self.promoted_named
66
+ %i[width height link role]
67
+ end
57
68
 
58
69
  # Custom to_adoc implementation that uses ElementRegistry directly
59
70
  # to avoid recursion issues with image serialization.
60
71
  #
61
72
  # @return [String] AsciiDoc representation of this image
62
73
  def to_adoc
63
- # Use the registered serializer rather than Coradoc::AsciiDoc::Serializer.serialize
64
- # to avoid recursion
65
74
  serializer_class = Coradoc::AsciiDoc::Serializer::ElementRegistry.lookup(self.class)
66
75
  serializer_class.new.to_adoc(self)
67
76
  end
@@ -10,6 +10,13 @@ module Coradoc
10
10
  end
11
11
 
12
12
  attribute :colons, :string, default: -> { ':' }
13
+
14
+ # Inline images use the 2nd positional slot for the role, per
15
+ # Asciidoctor: `image:target[alt, role, width=N, ...]`.
16
+ # @return [Array<Symbol>]
17
+ def self.promoted_positional
18
+ %i[alt role]
19
+ end
13
20
  end
14
21
  end
15
22
  end
@@ -8,6 +8,7 @@ module Coradoc
8
8
  autoload :Core, 'coradoc/asciidoc/model/image/core'
9
9
  autoload :InlineImage, 'coradoc/asciidoc/model/image/inline_image'
10
10
  autoload :BlockImage, 'coradoc/asciidoc/model/image/block_image'
11
+ autoload :AttributeExtractor, 'coradoc/asciidoc/model/image/attribute_extractor'
11
12
  end
12
13
  end
13
14
  end
@@ -4,13 +4,22 @@ module Coradoc
4
4
  module AsciiDoc
5
5
  module Parser
6
6
  module Admonition
7
+ # Match a single style name case-insensitively. Each character
8
+ # class `[Xx]` lets the same alternation accept +note+, +Note+,
9
+ # and +NOTE+ without three separate str() branches per style.
10
+ def case_insensitive_str(s)
11
+ s.chars.reduce(nil) do |acc, ch|
12
+ node = match("[#{ch.upcase}#{ch.downcase}]")
13
+ acc.nil? ? node : (acc >> node)
14
+ end
15
+ end
16
+
7
17
  def admonition_type
8
- str('NOTE') | str('TIP') | str('EDITOR') |
9
- str('IMPORTANT') | str('WARNING') | str('CAUTION') |
10
- str('TODO')
11
- # requires atypical syntax for access?
12
- # | str('DANGER')
13
- # | str('SAFETY PRECAUTION')
18
+ styles = Coradoc::AsciiDoc::Transform::ElementTransformers::AdmonitionStyles.all_styles
19
+ styles.reduce(nil) do |acc, style|
20
+ matcher = case_insensitive_str(style)
21
+ acc.nil? ? matcher : (acc | matcher)
22
+ end
14
23
  end
15
24
 
16
25
  def admonition_line
@@ -44,7 +44,10 @@ module Coradoc
44
44
  end
45
45
 
46
46
  def positional_value_unquoted
47
- match('[^\],]').repeat(1)
47
+ # Exclude `=` so that `key=value` is matched by `named_attribute`
48
+ # rather than swallowed as a single positional token. A positional
49
+ # value that legitimately contains `=` must be quoted.
50
+ match('[^\],=]').repeat(1)
48
51
  end
49
52
 
50
53
  def positional_value_single_quote
@@ -120,20 +120,38 @@ module Coradoc
120
120
  (str('image:').present? >> str('image:') >>
121
121
  str(':').absent? >>
122
122
  match('[A-Za-z0-9_.\\-:/&?=+,%#~;]+').repeat(1).as(:path) >>
123
- (str('[') >> match('[^\\]]').repeat(1).as(:text) >> str(']')).maybe
123
+ attribute_list(:attribute_list).maybe
124
124
  ).as(:inline_image)
125
125
  end
126
126
 
127
127
  # Triple-plus inline passthrough: `+++raw content+++`. The content
128
128
  # passes through all substitutions verbatim. Common use is to embed
129
129
  # raw HTML in AsciiDoc documents.
130
- def inline_passthrough
130
+ def inline_passthrough_triple_plus
131
131
  (str('+++') >>
132
132
  (str('+++').absent? >> match('[^\n]')).repeat(1).as(:raw) >>
133
133
  str('+++')
134
134
  ).as(:inline_passthrough)
135
135
  end
136
136
 
137
+ # `pass:[raw]` macro form. Equivalent semantic to triple-plus:
138
+ # the bracket payload survives all substitutions verbatim. Common
139
+ # use is inside monospace spans to keep characters like `<` from
140
+ # being re-interpreted as xref markers. The optional `subs` segment
141
+ # (`pass:quotes[...]`) is consumed but currently ignored — the
142
+ # payload is always passed through raw.
143
+ def inline_passthrough_macro
144
+ (str('pass:').present? >> str('pass:') >>
145
+ match('[a-zA-Z,+]').repeat(0) >>
146
+ str('[') >> (str(']]').absent? >> match('[^\]\n]')).repeat(1).as(:raw) >>
147
+ str(']')
148
+ ).as(:inline_passthrough)
149
+ end
150
+
151
+ def inline_passthrough
152
+ inline_passthrough_triple_plus | inline_passthrough_macro
153
+ end
154
+
137
155
  def underline
138
156
  (attribute_list >> match('\\[.underline\\]').as(:role) >>
139
157
  str('#') >>
@@ -157,10 +175,26 @@ module Coradoc
157
175
  str('link:').present? |
158
176
  str('image:').present? |
159
177
  str('+++').present? |
178
+ str('pass:').present? |
160
179
  term_type.present? |
161
180
  str('footnote').present? |
162
181
  stem_type.present? |
163
- str('\\<<').present?
182
+ str('\\<<').present? |
183
+ hard_line_break_marker?
184
+ end
185
+
186
+ # AsciiDoc hard line break: a space followed by `+` at end of line,
187
+ # or a backslash at end of line. Both forms render as `<br>` inside
188
+ # the enclosing paragraph/verse. Recognised ahead of `text_unformatted`
189
+ # so the marker isn't swallowed as plain text.
190
+ def hard_line_break_marker?
191
+ (str(' +') >> str("\n")).present? |
192
+ (str('\\') >> str("\n")).present?
193
+ end
194
+
195
+ def hard_line_break
196
+ ((str(' +') >> str("\n")) |
197
+ (str('\\') >> str("\n"))).as(:hard_line_break)
164
198
  end
165
199
 
166
200
  def inline
@@ -187,7 +221,8 @@ module Coradoc
187
221
  inline_image |
188
222
  inline_passthrough |
189
223
  underline |
190
- small
224
+ small |
225
+ hard_line_break
191
226
  end
192
227
 
193
228
  def text_unformatted
@@ -149,8 +149,13 @@ module Coradoc
149
149
  end
150
150
 
151
151
  def dlist_definition
152
- text
153
- .as(:definition) >> line_ending >> empty_line.repeat(0)
152
+ # AsciiDoc convention: the definition body is indented relative
153
+ # to the term. That leading whitespace is structural (marks
154
+ # the line as a continuation of the dlist item), not content.
155
+ # Consume it without capturing so downstream CoreModel text
156
+ # doesn't carry the source indentation into HTML/Markdown.
157
+ (match('[ \t]').repeat(0) >> text.as(:definition)) >>
158
+ line_ending >> empty_line.repeat(0)
154
159
  end
155
160
 
156
161
  def dlist_item(_delimiter = nil)
@@ -14,7 +14,8 @@ module Coradoc
14
14
  end
15
15
  _anchor = model.anchor.nil? ? '' : "#{serialize_child(model.anchor)}\n"
16
16
  _title = model.title.to_s.empty? ? '' : ".#{model.title}\n"
17
- attrs = serialize_child(model.attributes, options)
17
+ composed = Model::Image::AttributeExtractor.compose(model)
18
+ attrs = serialize_child(composed, options)
18
19
  [missing, _anchor, _title, 'image', model.colons, model.src, attrs,
19
20
  model.line_break].join
20
21
  end
@@ -24,6 +25,7 @@ module Coradoc
24
25
  # Self-register this serializer
25
26
  ElementRegistry.register(Coradoc::AsciiDoc::Model::Image::Core, Image::Core)
26
27
  ElementRegistry.register(Coradoc::AsciiDoc::Model::Image::BlockImage, Image::Core)
28
+ ElementRegistry.register(Coradoc::AsciiDoc::Model::Image::InlineImage, Image::Core)
27
29
  end
28
30
  end
29
31
  end
@@ -26,6 +26,21 @@ module Coradoc
26
26
  BUILTIN.include?(name) || custom.include?(name)
27
27
  end
28
28
 
29
+ # Canonical uppercase form for +style+, or nil if unknown.
30
+ # Single source of truth for the canonical casing used in
31
+ # CoreModel::AnnotationBlock#annotation_type and AsciiDoc
32
+ # round-trip output.
33
+ def canonicalize(style)
34
+ return nil unless admonition?(style)
35
+
36
+ style.to_s.upcase
37
+ end
38
+
39
+ # All registered style names (BUILTIN + custom), lowercased.
40
+ def all_styles
41
+ (BUILTIN + custom).freeze
42
+ end
43
+
29
44
  # Register an additional admonition style. Open for extension.
30
45
  def register(style)
31
46
  name = style.to_s.downcase
@@ -71,16 +71,26 @@ module Coradoc
71
71
 
72
72
  # Dispatch entry point for any block whose AsciiDoc model carries
73
73
  # an attribute list (delimited blocks + open blocks). Per the
74
- # AsciiDoc spec, every delimited block accepts admonition styles
75
- # in its attribute line. Admonition style wins for example,
76
- # sidebar, quote, and open blocks; verbatim blocks (source,
77
- # listing) intentionally ignore it because their verbatim
78
- # semantics are stronger than the annotation label.
74
+ # AsciiDoc spec, every delimited block accepts both admonition
75
+ # labels and verbatim/stem casts in its attribute line. The cast
76
+ # is resolved by +block_cast_semantic+ in MECE order:
77
+ # admonition > stem > verbatim > fallback.
79
78
  def transform_with_admonition_check(block, native_class)
80
- style = first_positional_attr(block)
81
- return transform_admonition_block(block, style) if AdmonitionStyles.admonition?(style)
82
-
83
- transform_typed_block(block, native_class)
79
+ semantic = block_cast_semantic(block)
80
+ case semantic
81
+ when :admonition
82
+ transform_admonition_block(block, first_positional_attr(block))
83
+ when :stem
84
+ transform_stem_block(block, first_positional_attr(block))
85
+ when :source
86
+ transform_source_block(block)
87
+ when :listing
88
+ transform_listing_from_open(block)
89
+ when :literal
90
+ transform_literal_from_open(block)
91
+ else
92
+ transform_typed_block(block, native_class)
93
+ end
84
94
  end
85
95
 
86
96
  def transform_example_block(block)
@@ -98,36 +108,32 @@ module Coradoc
98
108
  # Open blocks (`--`) are generic containers. AsciiDoc allows
99
109
  # casting them to a different block type via positional
100
110
  # attributes: verbatim types (`[source]`, `[listing]`,
101
- # `[literal]`) and admonition labels (`[NOTE]`, `[TIP]`,
111
+ # `[literal]`), STEM types (`[stem]`, `[latexmath]`,
112
+ # `[asciimath]`), and admonition labels (`[NOTE]`, `[TIP]`,
102
113
  # `[WARNING]`, `[CAUTION]`, `[IMPORTANT]`). When such a cast
103
114
  # is present, the block behaves like the corresponding
104
115
  # delimited block. Anything else stays an OpenBlock.
105
116
  def transform_open_block(block)
106
- semantic = open_block_semantic(block)
107
- case semantic
108
- when :source
109
- transform_source_block(block)
110
- when :listing
111
- transform_listing_from_open(block)
112
- when :literal
113
- transform_literal_from_open(block)
114
- when :admonition
115
- transform_admonition_block(block, first_positional_attr(block))
116
- else
117
- transform_typed_block(block, Coradoc::CoreModel::OpenBlock)
118
- end
117
+ transform_with_admonition_check(block, Coradoc::CoreModel::OpenBlock)
119
118
  end
120
119
 
121
120
  VERBATILE_CAST_STYLES = %w[source listing literal].freeze
122
121
 
123
- def open_block_semantic(block)
122
+ # Resolve a delimited block's cast from its first positional
123
+ # attribute. Single source of truth for the cast ladder used
124
+ # by every block-form transformer (example/sidebar/quote/open).
125
+ # Returns one of: :admonition, :stem, :source, :listing,
126
+ # :literal, or nil (no cast — use the block's native class).
127
+ def block_cast_semantic(block)
124
128
  style = first_positional_attr(block)
125
129
  return nil unless style
126
130
 
127
- case style.to_s.downcase
128
- when *VERBATILE_CAST_STYLES then :"#{style.downcase}"
129
- else :admonition if AdmonitionStyles.admonition?(style)
130
- end
131
+ style_str = style.to_s.downcase
132
+ return :admonition if AdmonitionStyles.admonition?(style)
133
+ return :stem if STEM_STYLES.key?(style_str)
134
+ return style_str.to_sym if VERBATILE_CAST_STYLES.include?(style_str)
135
+
136
+ nil
131
137
  end
132
138
 
133
139
  # Returns the first positional attribute on the block's
@@ -148,10 +154,14 @@ module Coradoc
148
154
  # (example / sidebar / quote / pass / open). Builds an
149
155
  # AnnotationBlock with annotation_type set from the style and
150
156
  # the block's body lines joined into a single content string.
157
+ # Type is canonicalised via AdmonitionStyles so every code path
158
+ # (line admonition, block admonition, attribute-cast admonition)
159
+ # produces the same uppercase key.
151
160
  def transform_admonition_block(block, type)
161
+ canonical = AdmonitionStyles.canonicalize(type) || type.to_s
152
162
  content_lines = Array(block.lines).map { |line| ToCoreModel.extract_text_content(line) }.join("\n")
153
163
  Coradoc::CoreModel::AnnotationBlock.new(
154
- annotation_type: type.to_s.downcase,
164
+ annotation_type: canonical,
155
165
  content: content_lines,
156
166
  title: ToCoreModel.extract_title_text(block.title)
157
167
  )
@@ -13,6 +13,7 @@ module Coradoc
13
13
  children = CalloutMerger.call(children)
14
14
  children = prepend_frontmatter(children, doc.frontmatter)
15
15
  children = insert_title_heading_after_frontmatter(children, title_text)
16
+ children = resolve_attribute_references(children, attributes)
16
17
 
17
18
  Coradoc::CoreModel::DocumentElement.new(
18
19
  id: doc.id,
@@ -58,6 +59,16 @@ module Coradoc
58
59
  children.insert(frontmatter_count, title_heading)
59
60
  end
60
61
 
62
+ # Resolve `{name}` attribute references in the body against the
63
+ # document's own declared attributes. Single source of truth:
64
+ # the resolver lives in core so other format gems can reuse it
65
+ # when they have equivalent reference macros.
66
+ def resolve_attribute_references(children, attributes)
67
+ return children if attributes.nil? || attributes.keys.empty?
68
+
69
+ Coradoc::CoreModel::AttributeReferenceResolver.call(children, attributes)
70
+ end
71
+
61
72
  def transform_section(section, parent_id: nil)
62
73
  title_text = ToCoreModel.extract_title_text(section.title)
63
74
  section_id = section.id || Coradoc::CoreModel::IdGenerator.generate_from_title(
@@ -67,20 +67,28 @@ module Coradoc
67
67
  )
68
68
  li.children = children
69
69
 
70
- if item.nested.is_a?(Coradoc::AsciiDoc::Model::List::Core)
71
- nested_core = transform_list(item.nested, list_marker_type(item.nested))
72
- li.children << nested_core
73
- elsif item.nested.is_a?(Array)
74
- item.nested.each do |n|
75
- next unless n.is_a?(Coradoc::AsciiDoc::Model::List::Core)
76
-
77
- li.children << transform_list(n, list_marker_type(n))
78
- end
79
- end
80
-
70
+ nested_lists = extract_nested_lists(item)
71
+ li.nested_list = nested_lists.first if nested_lists.size == 1
81
72
  li
82
73
  end
83
74
 
75
+ # Pull every nested List::Core off the AsciiDoc model item and
76
+ # transform each into a CoreModel::ListBlock. Returns [] when
77
+ # the item has no nested lists. Single source of truth for the
78
+ # nested-list shape so transform_list_item and any future caller
79
+ # share the same extraction logic.
80
+ def extract_nested_lists(item)
81
+ nested = item.nested
82
+ return [] if nested.nil?
83
+
84
+ candidates = nested.is_a?(Array) ? nested : [nested]
85
+ candidates.filter_map do |n|
86
+ next unless n.is_a?(Coradoc::AsciiDoc::Model::List::Core)
87
+
88
+ transform_list(n, list_marker_type(n))
89
+ end
90
+ end
91
+
84
92
  def list_marker_type(list)
85
93
  case list
86
94
  when Coradoc::AsciiDoc::Model::List::Ordered then 'ordered'
@@ -15,9 +15,10 @@ module Coradoc
15
15
  end
16
16
 
17
17
  def transform_admonition(admonition)
18
+ canonical = Coradoc::AsciiDoc::Transform::ElementTransformers::AdmonitionStyles.canonicalize(admonition.type) || admonition.type.to_s
18
19
  children = ToCoreModel.transform_inline_content(admonition.content)
19
20
  block = Coradoc::CoreModel::AnnotationBlock.new(
20
- annotation_type: admonition.type,
21
+ annotation_type: canonical,
21
22
  content: ToCoreModel.extract_text_content(admonition.content)
22
23
  )
23
24
  block.children = children
@@ -25,24 +26,22 @@ module Coradoc
25
26
  end
26
27
 
27
28
  def transform_image(image)
28
- src = image.src.to_s
29
- src = src[1..] if src.start_with?(':')
30
- positional = image.attributes&.positional || []
31
- positional_alt = positional.first&.value&.to_s || ''
32
- caption = positional[1]&.value&.to_s
33
- title = image.title&.to_s
34
- # AsciiDoc block-title (`.Caption`) is semantically a caption,
35
- # but for compatibility with the existing pipeline that treated
36
- # it as alt when no positional alt was supplied, fall back to it.
37
- alt = positional_alt.empty? ? title : positional_alt
38
- Coradoc::CoreModel::Image.new(
39
- src: src,
40
- alt: alt,
41
- caption: caption,
42
- title: title,
43
- width: image.attributes&.[]('width'),
44
- height: image.attributes&.[]('height')
45
- )
29
+ Coradoc::CoreModel::Image.new(**image_attributes(image))
30
+ end
31
+
32
+ def image_attributes(image)
33
+ {
34
+ src: normalize_image_src(image.src),
35
+ alt: image.alt, title: image.title, caption: image.caption,
36
+ width: image.width, height: image.height,
37
+ link: image.link, role: image.role,
38
+ inline: image.is_a?(Coradoc::AsciiDoc::Model::Image::InlineImage)
39
+ }
40
+ end
41
+
42
+ def normalize_image_src(src)
43
+ s = src.to_s
44
+ s.start_with?(':') ? s[1..] : s
46
45
  end
47
46
 
48
47
  def transform_bibliography(bib)
@@ -319,17 +319,25 @@ module Coradoc
319
319
  )
320
320
  when 'raw_inline'
321
321
  Coradoc::AsciiDoc::Model::Inline::Passthrough.new(content: inline.content)
322
+ when 'hard_line_break'
323
+ Coradoc::AsciiDoc::Model::Inline::HardLineBreak.new
322
324
  else
323
325
  Coradoc::AsciiDoc::Model::TextElement.new(content: inline.content)
324
326
  end
325
327
  end
326
328
 
327
329
  def transform_image(image)
328
- Coradoc::AsciiDoc::Model::Image::BlockImage.new(
329
- src: image.src,
330
- title: image.alt,
331
- attributes: build_image_attributes(image)
332
- )
330
+ target_class = image.inline ? Coradoc::AsciiDoc::Model::Image::InlineImage
331
+ : Coradoc::AsciiDoc::Model::Image::BlockImage
332
+ target_class.new(**image_attributes(image))
333
+ end
334
+
335
+ def image_attributes(image)
336
+ {
337
+ src: image.src, alt: image.alt, title: image.title,
338
+ caption: image.caption, width: image.width, height: image.height,
339
+ link: image.link, role: image.role
340
+ }
333
341
  end
334
342
 
335
343
  def transform_bibliography(bib)
@@ -528,6 +536,10 @@ module Coradoc
528
536
  content.map { |item| create_text_elements(item) }
529
537
  when Coradoc::CoreModel::InlineElement
530
538
  transform_inline(content)
539
+ when Coradoc::CoreModel::TextContent
540
+ Coradoc::AsciiDoc::Model::TextElement.new(content: content.text.to_s)
541
+ when Coradoc::CoreModel::Base
542
+ transform(content)
531
543
  when Coradoc::AsciiDoc::Model::Base
532
544
  content
533
545
  when Lutaml::Model::Serializable
@@ -553,13 +565,6 @@ module Coradoc
553
565
  attrs
554
566
  end
555
567
 
556
- def build_image_attributes(image)
557
- attrs = {}
558
- attrs['width'] = image.width if image.width
559
- attrs['height'] = image.height if image.height
560
- attrs
561
- end
562
-
563
568
  def default_marker(marker_type)
564
569
  case marker_type
565
570
  when 'ordered' then '.'
@@ -172,9 +172,11 @@ module Coradoc
172
172
  Registry.register(
173
173
  Coradoc::AsciiDoc::Model::Inline::AttributeReference,
174
174
  lambda { |model|
175
+ name = model.name.to_s
175
176
  Coradoc::CoreModel::InlineElement.new(
176
177
  format_type: 'attribute_reference',
177
- content: "{#{model.name}}"
178
+ target: name,
179
+ content: "{#{name}}"
178
180
  )
179
181
  }
180
182
  )
@@ -246,6 +248,16 @@ module Coradoc
246
248
  ->(model) { Inc.transform_include(model) }
247
249
  )
248
250
 
251
+ # Inline hard line break — distinct from the structural
252
+ # Model::LineBreak (which represents paragraph-separator blank
253
+ # lines and is dropped at the CoreModel boundary). Hard breaks
254
+ # are semantic and round-trip as CoreModel::HardLineBreakElement
255
+ # so HTML/Markdown renderers can map them to <br>.
256
+ Registry.register(
257
+ Coradoc::AsciiDoc::Model::Inline::HardLineBreak,
258
+ ->(_model) { Coradoc::CoreModel::HardLineBreakElement.new(content: '') }
259
+ )
260
+
249
261
  [
250
262
  Coradoc::AsciiDoc::Model::Audio,
251
263
  Coradoc::AsciiDoc::Model::Video,
@@ -37,12 +37,15 @@ module Coradoc
37
37
  Model::Block::Example.new(title: '', lines: example)
38
38
  end
39
39
 
40
- # Admonition
40
+ # Admonition. Canonicalise the type to uppercase so round-trips
41
+ # through HTML/Markdown/DocBook all see the same key used by
42
+ # icon and CSS-class lookups.
41
43
  rule(
42
44
  admonition_type: simple(:admonition_type),
43
45
  content: sequence(:content)
44
46
  ) do
45
- Model::Admonition.new(content: content, type: admonition_type.to_s)
47
+ canonical = Coradoc::AsciiDoc::Transform::ElementTransformers::AdmonitionStyles.canonicalize(admonition_type.to_s)
48
+ Model::Admonition.new(content: content, type: canonical)
46
49
  end
47
50
 
48
51
  # Block image
@@ -51,12 +54,16 @@ module Coradoc
51
54
  title = block_image[:title]
52
55
  path = block_image[:path]
53
56
  attrs = AttributeListNormalizer.coerce(block_image[:attribute_list_macro])
57
+ promoted, residual = Model::Image::AttributeExtractor.call(
58
+ attrs, Model::Image::BlockImage
59
+ )
54
60
  Model::Image::BlockImage.new(
55
61
  title: title,
56
62
  id: id,
57
63
  src: path,
58
- attributes: attrs,
59
- line_break: "\n"
64
+ attributes: residual,
65
+ line_break: "\n",
66
+ **promoted
60
67
  )
61
68
  end
62
69
  end
@@ -36,14 +36,21 @@ module Coradoc
36
36
 
37
37
  # Inline image
38
38
  rule(inline_image: subtree(:inline_image)) do
39
+ attrs = AttributeListNormalizer.coerce(inline_image[:attribute_list])
40
+ promoted, residual = Model::Image::AttributeExtractor.call(
41
+ attrs, Model::Image::InlineImage
42
+ )
39
43
  Model::Image::InlineImage.new(
40
- title: inline_image[:text],
41
44
  src: inline_image[:path],
42
- attributes: inline_image[:attribute_list]
45
+ attributes: residual,
46
+ **promoted
43
47
  )
44
48
  end
45
49
 
46
- # Inline passthrough (`+++raw content+++`)
50
+ # Inline passthrough (`+++raw content+++` or `pass:[raw]`).
51
+ # Both forms carry an opaque payload that survives all
52
+ # substitutions verbatim; `form` records which syntax was
53
+ # used so the AsciiDoc serializer can round-trip faithfully.
47
54
  rule(inline_passthrough: subtree(:passthrough)) do
48
55
  Model::Inline::Passthrough.new(
49
56
  content: passthrough[:raw].to_s,
@@ -56,6 +63,15 @@ module Coradoc
56
63
  Model::Inline::AttributeReference.new(name:)
57
64
  end
58
65
 
66
+ # Hard line break (` +\n` or `\\n`). Emitted as a dedicated
67
+ # AsciiDoc model (Inline::HardLineBreak) distinct from
68
+ # Model::LineBreak, which only represents paragraph-separator
69
+ # blank lines. Hard breaks carry semantic meaning: HTML/Markdown
70
+ # renderers map them to <br>.
71
+ rule(hard_line_break: simple(:_)) do
72
+ Model::Inline::HardLineBreak.new
73
+ end
74
+
59
75
  # Term
60
76
  rule(
61
77
  term_type: simple(:term_type),
@@ -20,12 +20,24 @@ module Coradoc
20
20
  cell_opts = {}
21
21
  style = parse_format(format, cell_opts)
22
22
 
23
- unescaped_content = content.to_s.gsub(/\\([|!,:;])/, '\1')
23
+ unescaped_content = cell_content_to_string(content).gsub(/\\([|!,:;])/, '\1')
24
24
  cell_opts[:content] = parse_inline_content(unescaped_content, style)
25
25
 
26
26
  Model::TableCell.new(**cell_opts)
27
27
  end
28
28
 
29
+ # Coerce the parser-supplied cell content into a plain String.
30
+ # The cell parser emits `text:` as either a single Parslet::Slice
31
+ # or an Array of slices (from `.repeat(0)`). An empty Array must
32
+ # map to "" — Ruby's `[].to_s` returns "[]", which previously
33
+ # leaked into TableCell content as a phantom "[]" cell.
34
+ def cell_content_to_string(content)
35
+ return '' if content.nil?
36
+ return content.map(&:to_s).join if content.is_a?(Array)
37
+
38
+ content.to_s
39
+ end
40
+
29
41
  # Coerce a raw parser cell value into a TableCell.
30
42
  # Used by TableLayout.group_cells_into_rows when it encounters
31
43
  # cells that the parser emitted as Hashes or plain strings.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Coradoc
4
4
  module AsciiDoc
5
- VERSION = '2.0.21'
5
+ VERSION = '2.0.23'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coradoc-adoc
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.21
4
+ version: 2.0.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -146,6 +146,7 @@ files:
146
146
  - lib/coradoc/asciidoc/model/header.rb
147
147
  - lib/coradoc/asciidoc/model/highlight.rb
148
148
  - lib/coradoc/asciidoc/model/image.rb
149
+ - lib/coradoc/asciidoc/model/image/attribute_extractor.rb
149
150
  - lib/coradoc/asciidoc/model/image/block_image.rb
150
151
  - lib/coradoc/asciidoc/model/image/block_image/attribute_list.rb
151
152
  - lib/coradoc/asciidoc/model/image/core.rb