coradoc-adoc 2.0.29 → 2.0.31

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.
@@ -169,19 +169,22 @@ module Coradoc
169
169
 
170
170
  # Single admonition dispatch used by every block-form path
171
171
  # (example / sidebar / quote / pass / open). Builds an
172
- # AnnotationBlock with annotation_type set from the style and
173
- # the block's body lines joined into a single content string.
174
- # Type is canonicalised via AdmonitionStyles so every code path
175
- # (line admonition, block admonition, attribute-cast admonition)
176
- # produces the same uppercase key.
172
+ # AnnotationBlock by delegating to +transform_typed_block+
173
+ # with +annotation_type+ injected via +extra_attrs+. This
174
+ # preserves inline formatting (links, monospace, bold), list
175
+ # structure, and paragraph breaks — the typed-block pipeline
176
+ # already groups lines into ParagraphBlock children and
177
+ # dispatches block-level lines (lists, tables) through
178
+ # ToCoreModel.transform. Type is canonicalised via
179
+ # AdmonitionStyles so every code path (line admonition, block
180
+ # admonition, attribute-cast admonition) produces the same
181
+ # uppercase key.
177
182
  def transform_admonition_block(block, type)
178
183
  canonical = AdmonitionStyles.canonicalize(type) || type.to_s
179
- content_lines = Array(block.lines).map { |line| ToCoreModel.extract_text_content(line) }.join("\n")
180
- Coradoc::CoreModel::AnnotationBlock.new(
181
- annotation_type: canonical,
182
- content: content_lines,
183
- title: ToCoreModel.extract_title_text(block.title),
184
- source_line: block.source_line
184
+ transform_typed_block(
185
+ block,
186
+ Coradoc::CoreModel::AnnotationBlock,
187
+ annotation_type: canonical
185
188
  )
186
189
  end
187
190
 
@@ -214,76 +217,85 @@ module Coradoc
214
217
  end
215
218
 
216
219
  def transform_typed_block(block, klass, extra_attrs = {})
217
- raw_lines = Array(block.lines)
218
- has_nested_blocks = raw_lines.any? { |line| block_level_child?(line) }
219
-
220
- if has_nested_blocks
221
- children = raw_lines.reject do |line|
222
- line.is_a?(Coradoc::AsciiDoc::Model::LineBreak) ||
223
- line.is_a?(Coradoc::AsciiDoc::Model::Break::PageBreak)
224
- end.filter_map do |line|
225
- result = ToCoreModel.transform(line)
226
- next nil if result.nil?
227
- next result if result.is_a?(Coradoc::CoreModel::Base)
228
-
229
- text = ToCoreModel.extract_text_content(result)
230
- next nil if text.nil? || text.strip.empty?
231
-
232
- Coradoc::CoreModel::TextContent.new(text: text)
233
- end
234
- klass.new(
235
- id: block.id,
236
- title: ToCoreModel.extract_title_text(block.title),
237
- children: children,
238
- language: ToCoreModel.extract_block_language(block),
239
- source_line: block.source_line,
240
- **extra_attrs
241
- )
242
- else
243
- paragraph_groups = group_block_lines_into_paragraphs(raw_lines)
244
-
245
- content_lines = paragraph_groups.map do |group|
246
- ToCoreModel.extract_text_content(group)
247
- end.join("\n\n")
248
-
249
- children = paragraph_groups.map do |group|
250
- inline = ToCoreModel.transform_inline_content(group)
251
- inline = [Coradoc::CoreModel::TextContent.new(text: '')] if inline.empty?
252
- Coradoc::CoreModel::ParagraphBlock.new(
253
- content: ToCoreModel.extract_text_content(group),
254
- children: Array(inline),
255
- source_line: ToCoreModel.extract_source_line(group)
256
- )
257
- end
220
+ children, content = build_typed_block_children(block.lines)
258
221
 
259
- klass.new(
260
- id: block.id,
261
- title: ToCoreModel.extract_title_text(block.title),
262
- content: content_lines,
263
- children: children,
264
- language: ToCoreModel.extract_block_language(block),
265
- source_line: block.source_line,
266
- **extra_attrs
267
- )
268
- end
222
+ klass.new(
223
+ id: block.id,
224
+ title: ToCoreModel.extract_title_text(block.title),
225
+ content: content,
226
+ children: children,
227
+ language: ToCoreModel.extract_block_language(block),
228
+ source_line: block.source_line,
229
+ **extra_attrs
230
+ )
269
231
  end
270
232
 
271
- # AsciiDoc joins consecutive non-blank lines into one paragraph;
272
- # blank lines (parsed as Model::LineBreak) separate paragraphs.
273
- def group_block_lines_into_paragraphs(lines)
274
- groups = []
275
- current = []
276
- lines.each do |line|
233
+ # Walks block.lines in document order and emits the typed
234
+ # block's children alongside the prose-string view of its
235
+ # paragraphs.
236
+ #
237
+ # Consecutive inline lines coalesce into one ParagraphBlock
238
+ # (preserving AsciiDoc paragraph semantics: soft-wrapped
239
+ # source lines are one paragraph, blank lines split them).
240
+ # Block-level lines (lists, tables, nested blocks — anything
241
+ # whose AsciiDoc model returns true from +block_level?+)
242
+ # dispatch through +ToCoreModel.transform+ and slot in at
243
+ # their original document-order position. LineBreak /
244
+ # PageBreak lines flush the pending inline paragraph.
245
+ #
246
+ # Returns [+children+, +content+] where +content+ is the
247
+ # paragraph text joined with blank-line separators (nil when
248
+ # the block has no paragraphs, e.g. contains only nested
249
+ # blocks).
250
+ #
251
+ # Single source of truth for typed-block body construction.
252
+ # Used by every typed-block transformer (example, sidebar,
253
+ # quote, open, typed-cast, admonition) so they all preserve
254
+ # inline formatting, list structure, and paragraph breaks
255
+ # identically.
256
+ def build_typed_block_children(lines)
257
+ children = []
258
+ paragraph_texts = []
259
+ pending_inline = []
260
+
261
+ Array(lines).each do |line|
277
262
  if line.is_a?(Coradoc::AsciiDoc::Model::LineBreak) ||
278
263
  line.is_a?(Coradoc::AsciiDoc::Model::Break::PageBreak)
279
- groups << current if current.any?
280
- current = []
264
+ flush_inline_paragraph(pending_inline, children, paragraph_texts)
265
+ pending_inline.clear
266
+ elsif block_level_child?(line)
267
+ flush_inline_paragraph(pending_inline, children, paragraph_texts)
268
+ pending_inline.clear
269
+ dispatched = ToCoreModel.transform(line)
270
+ children << dispatched if dispatched
281
271
  else
282
- current << line
272
+ pending_inline << line
283
273
  end
284
274
  end
285
- groups << current if current.any?
286
- groups
275
+ flush_inline_paragraph(pending_inline, children, paragraph_texts)
276
+
277
+ content = paragraph_texts.any? ? paragraph_texts.join("\n\n") : nil
278
+ [children, content]
279
+ end
280
+
281
+ # Materialises +pending_inline+ into a ParagraphBlock and
282
+ # appends it to +children+, recording its flat text in
283
+ # +paragraph_texts+ for later content-string joining. No-op
284
+ # when +pending_inline+ is empty so callers can flush
285
+ # unconditionally at every boundary (blank line, block-level
286
+ # child, end-of-input) without producing empty paragraphs.
287
+ def flush_inline_paragraph(pending_inline, children, paragraph_texts)
288
+ return if pending_inline.empty?
289
+
290
+ inline = ToCoreModel.transform_inline_content(pending_inline)
291
+ inline = [Coradoc::CoreModel::TextContent.new(text: '')] if inline.empty?
292
+ text = ToCoreModel.extract_text_content(pending_inline)
293
+ paragraph_texts << text
294
+ children << Coradoc::CoreModel::ParagraphBlock.new(
295
+ content: text,
296
+ children: Array(inline),
297
+ source_line: ToCoreModel.extract_source_line(pending_inline)
298
+ )
287
299
  end
288
300
 
289
301
  # A line that should be emitted as a direct child of the
@@ -51,7 +51,7 @@ module Coradoc
51
51
  return children if children.any? { |c| title_heading?(c) }
52
52
 
53
53
  title_id = doc_id || Coradoc::CoreModel::IdGenerator
54
- .generate_from_title(title_text)
54
+ .generate_from_title(title_text)
55
55
  title_heading = Coradoc::CoreModel::HeaderElement.new(
56
56
  level: 0,
57
57
  title: title_text,
@@ -4,6 +4,24 @@ module Coradoc
4
4
  module AsciiDoc
5
5
  module Transform
6
6
  module ElementTransformers
7
+ # Owns the recursive re-parse that recognises nested inline marks
8
+ # (Bug 16B). The re-parse re-enters the full inline pipeline:
9
+ #
10
+ # transform_inline → parse_nested_inline_children
11
+ # → ToCoreModel.parse_and_transform_inline
12
+ # → InlineTransformVisitor.visit
13
+ # → ToCoreModel.transform
14
+ # → transform_inline (cycle)
15
+ #
16
+ # The cycle terminates when the mark's content has no further
17
+ # inline-mark characters — the parser produces only flat text,
18
+ # `parse_nested_inline_children` returns `[]`, and the mark
19
+ # keeps its flat content shape.
20
+ #
21
+ # Realistic nesting is 2–3 levels. Pathological input
22
+ # (`*****…*****` with hundreds of levels) raises
23
+ # `SystemStackError` naturally — that's Ruby's contract, not a
24
+ # bespoke guard.
7
25
  class InlineTransformer
8
26
  class << self
9
27
  def transform_inline(inline, format_type)
@@ -11,36 +29,23 @@ module Coradoc
11
29
  raw_content = ToCoreModel.extract_text_content(inline.content)
12
30
 
13
31
  # Recursively parse the mark's content to recognise nested
14
- # inline marks (Bug 16B). For `**Per-repo \`file.yml\`**`,
15
- # the outer Bold's content "Per-repo `file.yml`" is fed
16
- # back through the inline parser so the inner constrained
17
- # monospace is recognised. The parsed children are stored
18
- # on the BoldElement; the Mirror handler walks them to
19
- # produce ProseMirror's flat text-node-with-marks shape.
32
+ # inline marks (Bug 16B). Only populate `children` when the
33
+ # parser found real nested marks flat marks keep children
34
+ # empty, and the Mirror handler's `build_simple_mark` falls
35
+ # back to the `content` string. Saves one TextContent
36
+ # allocation per flat mark (the common case).
20
37
  children = parse_nested_inline_children(raw_content)
21
38
 
22
- if children.any?
23
- klass.new(
24
- content: raw_content,
25
- children: children,
26
- source_line: inline.source_line
27
- )
28
- else
29
- klass.new(
30
- content: raw_content,
31
- source_line: inline.source_line
32
- )
33
- end
39
+ kwargs = { content: raw_content, source_line: inline.source_line }
40
+ kwargs[:children] = children if children.any?
41
+ klass.new(**kwargs)
34
42
  end
35
43
 
36
44
  # Re-parse a mark's raw content string through the inline
37
45
  # parser. Returns the list of CoreModel children when the
38
46
  # content contains nested inline marks; returns [] when
39
47
  # the content is plain text (no nested marks to preserve).
40
- # The empty return is the recursion terminator — once a
41
- # mark's content has no further mark characters, the
42
- # parser produces only TextContent nodes, which we drop
43
- # in favour of the mark's flat content string.
48
+ # The empty return is the recursion terminator.
44
49
  def parse_nested_inline_children(text)
45
50
  return [] if text.nil? || text.to_s.empty?
46
51
 
@@ -32,24 +32,74 @@ module Coradoc
32
32
  private
33
33
 
34
34
  def transform_definition_item(item)
35
- term_content = item.terms
35
+ term_parts = Array(item.terms)
36
36
  def_content = item.contents
37
37
 
38
- term_parts = term_content.is_a?(Array) ? term_content : [term_content]
39
- parsed_terms = term_parts.flat_map do |part|
40
- ToCoreModel.parse_inline_text(part)
38
+ # Process each term independently so multi-term `<dt>`'s
39
+ # (e.g. AsciiDoc `term1::\nterm2::\ndef`) preserve their
40
+ # distinct identities in CoreModel#terms. Each entry in
41
+ # `term_strings` is one term's plain text; `term_children`
42
+ # is the parallel array of typed inline children. The
43
+ # singular `term` accessor is set to the first term for
44
+ # backward compatibility with consumers that haven't
45
+ # migrated to the `terms` collection.
46
+ term_strings = term_parts.map do |part|
47
+ parsed = ToCoreModel.parse_inline_text(part)
48
+ ToCoreModel.extract_text_content(ToCoreModel.transform_inline_content(parsed))
41
49
  end
42
50
 
43
- parsed_defs = ToCoreModel.parse_inline_text(def_content)
44
-
45
- term_children = ToCoreModel.transform_inline_content(parsed_terms)
46
- def_children = ToCoreModel.transform_inline_content(parsed_defs)
51
+ # Inline children for the FIRST term only. The CoreModel
52
+ # `term_children` accessor is singular; carrying per-term
53
+ # children for multi-term `<dt>`'s would require a
54
+ # collection of arrays. The common case (single-term dt
55
+ # with inline markup) is fully supported; multi-term dt
56
+ # with inline markup on later terms degrades to text-only
57
+ # for those later terms (acceptable per asciidoctor's
58
+ # observed behaviour — multi-term inline markup is rare).
59
+ primary_term_children = if term_parts.any?
60
+ parsed = ToCoreModel.parse_inline_text(term_parts.first)
61
+ ToCoreModel.transform_inline_content(parsed)
62
+ else
63
+ []
64
+ end
65
+
66
+ # contents is typed on DefinitionItem as Array<TextElement>
67
+ # (see model/list/definition_item.rb). Each TextElement's
68
+ # `to_s` handles the polymorphic content shape (String,
69
+ # Array, or nested Serializable). Join the per-line text
70
+ # elements into a single paragraph string so the inline
71
+ # parser sees the full multi-line dd content as one
72
+ # soft-wrapped paragraph.
73
+ #
74
+ # When dd has no inline definition (term-only item where
75
+ # the dd is populated entirely by `+`-continuation blocks),
76
+ # def_content is empty and we skip populating definitions
77
+ # entirely — avoids emitting an empty <text></text> node
78
+ # ahead of the attached blocks.
79
+ def_text = def_content.map(&:to_s).reject(&:empty?).join(' ')
80
+ has_definition = !def_text.empty?
81
+
82
+ parsed_defs = has_definition ? ToCoreModel.parse_inline_text(def_text) : []
83
+ def_children = has_definition ? ToCoreModel.transform_inline_content(parsed_defs) : []
84
+
85
+ # Transform `+`-continuation blocks (paragraphs, admonitions,
86
+ # delimited blocks) into their CoreModel equivalents. Each
87
+ # becomes a child of the dd, rendered after the definition
88
+ # paragraph. Passes through ToCoreModel.transform so any
89
+ # registered block transformer applies (paragraph, source,
90
+ # admonition, etc.).
91
+ attached_children = Array(item.attached).filter_map do |block|
92
+ ToCoreModel.transform(block)
93
+ end
47
94
 
95
+ primary_term = term_strings.first.to_s
48
96
  di = Coradoc::CoreModel::DefinitionItem.new(
49
- term: ToCoreModel.extract_text_content(term_children),
50
- definitions: [ToCoreModel.extract_text_content(def_children)],
51
- term_children: term_children,
97
+ term: primary_term,
98
+ terms: term_strings,
99
+ definitions: has_definition ? [ToCoreModel.extract_text_content(def_children)] : [],
100
+ term_children: primary_term_children,
52
101
  definition_children: def_children,
102
+ attached_children: attached_children,
53
103
  source_line: item.source_line
54
104
  )
55
105
  di.id = item.id if item.id
@@ -31,15 +31,15 @@ module Coradoc
31
31
  without_title_heading, title_text = extract_title_heading(without_frontmatter, element.title)
32
32
 
33
33
  header = if title_text
34
- Coradoc::AsciiDoc::Model::Header.new(
35
- title: Coradoc::AsciiDoc::Model::Title.new(
36
- content: title_text,
37
- level_int: 0
38
- )
39
- )
40
- else
41
- Coradoc::AsciiDoc::Model::Header.new(title: '')
42
- end
34
+ Coradoc::AsciiDoc::Model::Header.new(
35
+ title: Coradoc::AsciiDoc::Model::Title.new(
36
+ content: title_text,
37
+ level_int: 0
38
+ )
39
+ )
40
+ else
41
+ Coradoc::AsciiDoc::Model::Header.new(title: '')
42
+ end
43
43
 
44
44
  Coradoc::AsciiDoc::Model::Document.new(
45
45
  id: element.id,
@@ -369,12 +369,18 @@ module Coradoc
369
369
 
370
370
  def transform_definition_item(item, depth = 1)
371
371
  delimiter = ':' * (depth + 1)
372
- term = Coradoc::AsciiDoc::Model::Term.new(term: item.term.to_s)
372
+ # Multi-term `<dt>`: one Term per entry in `item.terms`.
373
+ # Falls back to `[item.term]` for items populated via
374
+ # legacy paths that only set the singular accessor.
375
+ term_strings = terms_for(item)
376
+ terms = term_strings.map do |text|
377
+ Coradoc::AsciiDoc::Model::Term.new(term: text.to_s)
378
+ end
373
379
  contents = Array(item.definitions).map do |defn|
374
380
  Coradoc::AsciiDoc::Model::TextElement.new(content: defn.to_s)
375
381
  end
376
382
  di = Coradoc::AsciiDoc::Model::List::DefinitionItem.new(
377
- terms: [term],
383
+ terms: terms,
378
384
  contents: contents,
379
385
  delimiter: delimiter
380
386
  )
@@ -382,6 +388,16 @@ module Coradoc
382
388
  di
383
389
  end
384
390
 
391
+ # Source of truth for "the terms on this <dt>": the `terms`
392
+ # collection when populated, else `[term]` for legacy callers.
393
+ def terms_for(item)
394
+ collection = item.terms if item.is_a?(Coradoc::CoreModel::DefinitionItem)
395
+ return Array(collection) unless collection.nil? || collection.empty?
396
+
397
+ primary = item.term
398
+ primary.to_s.empty? ? [] : [primary.to_s]
399
+ end
400
+
385
401
  def transform_toc(_toc)
386
402
  Coradoc::AsciiDoc::Model::TextElement.new(
387
403
  content: 'toc::[]'
@@ -71,13 +71,13 @@ module Coradoc
71
71
  # Each 2-char pattern collapses to a single Unicode character
72
72
  # at parse time so downstream consumers (HTML, Markdown) see
73
73
  # the literal typographic char without any post-processing.
74
- # The mapping is the single source of truth declared on the
75
- # parser side (Parser::Inline::TYPOGRAPHIC_QUOTE_PATTERNS).
74
+ # The mapping lives in the shared TypographicQuotes module
75
+ # (not on Parser or Transformer) so both layers reference
76
+ # the same source of truth.
76
77
  rule(typographic_quote: simple(:pattern)) do
77
- char = Coradoc::AsciiDoc::Parser::Inline::TYPOGRAPHIC_QUOTE_PATTERNS.fetch(
78
- pattern.to_s, pattern.to_s
78
+ Model::TextElement.new(
79
+ content: Coradoc::AsciiDoc::TypographicQuotes.char_for(pattern)
79
80
  )
80
- Model::TextElement.new(content: char)
81
81
  end
82
82
 
83
83
  # Hard line break (` +\n` or `\\n`). Emitted as a dedicated
@@ -6,6 +6,19 @@ module Coradoc
6
6
  # Module containing list transformation rules
7
7
  module ListRules
8
8
  class << self
9
+ # Build a nested DefinitionList tree from a flat list of items.
10
+ #
11
+ # AsciiDoc's dlist syntax uses delimiter length to express
12
+ # nesting depth (`::` = 1, `:::` = 2, `::::` = 3). Consecutive
13
+ # term-only items at the SAME depth are merged into a single
14
+ # multi-term `<dt>` sharing the next item's `<dd>`:
15
+ #
16
+ # term1:: → <dt>term1</dt>
17
+ # term2:: <dt>term2</dt>
18
+ # def <dd>def</dd>
19
+ #
20
+ # Stack-based walk in source order. Each item carries its own
21
+ # `delimiter`; depth is derived via {#dlist_depth}.
9
22
  def build_dlist_tree(items)
10
23
  root = Model::List::Definition.new(items: [])
11
24
  stack = [[root, 0]]
@@ -14,16 +27,80 @@ module Coradoc
14
27
  depth = dlist_depth(item.delimiter)
15
28
  stack.pop while stack.last[1] >= depth
16
29
 
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])
30
+ parent_list = stack.last[0]
31
+ last = parent_list.items.last
32
+
33
+ if merge_with_predecessor?(last, item)
34
+ merge_term_only_item(last, item)
35
+ # Stack stays — deeper items still nest under `last`.
36
+ # Replace the stack top so deeper items land in `last`'s
37
+ # current nested list (already on the stack from when
38
+ # `last` was first added).
39
+ else
40
+ parent_list.items << item
41
+ item.nested << Model::List::Definition.new(items: [])
42
+ stack.push([item.nested.last, depth])
43
+ end
21
44
  end
22
45
 
23
46
  prune_empty_nested(root)
24
47
  root
25
48
  end
26
49
 
50
+ # Two consecutive items at the same depth become a multi-term
51
+ # `<dt>` sharing one `<dd>` when the PREVIOUS item is term-only
52
+ # (no def, no attached blocks). The previous item is the
53
+ # accumulating dt; the current item contributes either another
54
+ # term (if it's also term-only) or the terminal dt + the
55
+ # shared dd (if it has a def).
56
+ #
57
+ # Once an item has its own def/attached, it's the terminal dt
58
+ # of any in-progress multi-term group; the next same-depth
59
+ # item starts a fresh entry.
60
+ def merge_with_predecessor?(last, current)
61
+ return false unless last
62
+ return false unless last.delimiter == current.delimiter
63
+ return false unless term_only?(last)
64
+
65
+ true
66
+ end
67
+
68
+ # "Term-only" means the item carries no dd content of its own
69
+ # (no inline def, no `+`-attached blocks, no nested child
70
+ # items). Such items are eligible to merge with a following
71
+ # same-depth item to form a multi-term `<dt>` sharing one dd.
72
+ # Items with nested children have already "claimed" their dd
73
+ # slot for the nested content and can't merge.
74
+ def term_only?(item)
75
+ return false if contents_present?(item)
76
+ return false if attached_present?(item)
77
+ return false if nested_has_items?(item)
78
+
79
+ true
80
+ end
81
+
82
+ def contents_present?(item)
83
+ !item.contents.nil? && !item.contents.empty?
84
+ end
85
+
86
+ def attached_present?(item)
87
+ !item.attached.nil? && item.attached.any?
88
+ end
89
+
90
+ # `item.nested` is an Array<Definition>; each Definition has
91
+ # its own `items` Array. An item with populated nested items
92
+ # (deeper dlist children) is not term-only.
93
+ def nested_has_items?(item)
94
+ Array(item.nested).any? { |list| list.is_a?(Model::List::Definition) && list.items.any? }
95
+ end
96
+
97
+ def merge_term_only_item(last, current)
98
+ last.terms.concat(current.terms)
99
+ # contents and attached are arrays; concat is safe (no-op for empty)
100
+ last.contents.concat(current.contents.to_a)
101
+ last.attached.concat(current.attached.to_a)
102
+ end
103
+
27
104
  def dlist_depth(delimiter)
28
105
  delim = delimiter.to_s
29
106
  return 1 if delim == ';;' || delim.empty?
@@ -134,38 +211,30 @@ module Coradoc
134
211
  end
135
212
  end
136
213
 
137
- # Definition list item
138
- rule(
139
- definition_list_item: {
140
- terms: sequence(:terms),
141
- definition: simple(:contents)
142
- }
143
- ) do
144
- term_strings = terms.map do |t|
145
- t.is_a?(Hash) ? t[:text].to_s : t.to_s
146
- end
147
- item_id = nil
148
- item_delim = '::'
149
- terms.each do |t|
150
- next unless t.is_a?(Hash)
151
-
152
- item_id = t[:id].to_s if t[:id]
153
- item_delim = t[:delimiter].to_s if t[:delimiter]
154
- end
155
- Model::List::DefinitionItem.new(terms: term_strings, contents: contents,
156
- id: item_id, delimiter: item_delim)
157
- end
158
-
159
- # Definition list item with hash terms (single term case)
214
+ # Definition list item. The parser's dlist_definition rule
215
+ # always emits :lines (one entry per source line of the dd,
216
+ # including the single-line case as a one-element array).
217
+ # Join via the shared lines_to_text_elements helper used by
218
+ # ulist/olist — single source of truth for line joining.
219
+ # `attached:` carries any `+`-continuation blocks captured by
220
+ # the parser; pass through to the model so downstream stages
221
+ # can render them as additional dd children.
160
222
  rule(
161
223
  definition_list_item: subtree(:item_data)
162
224
  ) do
163
- data = item_data.is_a?(Hash) ? item_data : { terms: Array(item_data), definition: '' }
225
+ data = item_data.is_a?(Hash) ? item_data : { terms: Array(item_data), lines: [] }
164
226
 
165
227
  item_id = nil
166
228
  item_delim = '::'
167
229
  terms_data = data[:terms]
168
- definition = data[:definition].to_s
230
+ # Split lines on hard_line_break so each source line is one
231
+ # entry. Without this, text_any greedy-matches across
232
+ # newlines via hard_line_break, joining what should be
233
+ # separate source lines into one entry (and swallowing the
234
+ # `+` line-continuation marker).
235
+ split_lines = Transformer.split_lines_on_hard_break(data[:lines] || [])
236
+ definition = Transformer.lines_to_text_elements(split_lines)
237
+ attached = Array(data[:attached])
169
238
 
170
239
  terms = Array(terms_data).map do |t|
171
240
  case t
@@ -179,7 +248,8 @@ module Coradoc
179
248
  end
180
249
 
181
250
  Model::List::DefinitionItem.new(terms: terms, contents: definition,
182
- id: item_id, delimiter: item_delim)
251
+ id: item_id, delimiter: item_delim,
252
+ attached: attached)
183
253
  end
184
254
 
185
255
  rule(definition_list: sequence(:list_items)) do
@@ -114,7 +114,9 @@ module Coradoc
114
114
 
115
115
  col_count = parse_cols_attribute(attrs)
116
116
  if col_count.nil? && rows.first.is_a?(Model::TableRow) && rows.first.columns.any?
117
- col_count = rows.first.columns.sum { |c| (c.colspan || 1).to_i }
117
+ col_count = rows.first.columns.sum do |c|
118
+ (c.colspan || 1).to_i
119
+ end
118
120
  end
119
121
 
120
122
  all_cells = rows.flat_map do |r|