coradoc-adoc 2.0.29 → 2.0.30

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: 05d6ea3852e5fb5c4bae0176556b9dd3b7a572b09ab1fc8c9d3752ebe0833548
4
- data.tar.gz: c9d4a61637e3e04a3fb0ad4b8394b8577236dbf0a9853375f01d750d8ff30f64
3
+ metadata.gz: 79a252c883acda46234ba968656577308075f2c1694ee2cdff96aa78006d3fe4
4
+ data.tar.gz: 20186d4848bfe7a63a16a11a43ce3b6ae861bb1bf60be7a71af4d96e7e31ea2b
5
5
  SHA512:
6
- metadata.gz: 2927618c321c352e622dfd8dc69dbeb5658715e76d4062b05793641dc8c35a829c168a8d1e446b7f382bfb86c7f09a0919b4c05409e0a262e41aa8f327a82755
7
- data.tar.gz: 1fd71a5b04f52748ef48b0529b7d6d8dcbb46987002c6ec442d511fa5902b31761eff9c83d1f3bbb1c0992b7e9d34a544443b55ef5f4580247d93591bbc3f98d
6
+ metadata.gz: dbb9841fe7829f87693c9d31bea1303d2f00c1762810e0f948112e01a6958210700bf90e5df15b3685fb49868dba22cf77f169e8842e420c15fa43e1160ec266
7
+ data.tar.gz: eb765458ac486a19fca72b69fcb15dea61eb09bb4c0f7ab1bc48793997653ae64dc24dbcd070fa526f3f9b8f467a6a7e157e353719a08d6ed2f795e5e2a1de7d
@@ -33,6 +33,19 @@ module Coradoc
33
33
  attribute :terms, Coradoc::AsciiDoc::Model::Term, collection: true
34
34
  attribute :contents, Coradoc::AsciiDoc::Model::TextElement, collection: true
35
35
  attribute :delimiter, :string, default: -> { '::' }
36
+ # `+`-continuation blocks attached to this dd (paragraphs,
37
+ # admonitions, delimited blocks). Same shape as Model::List::Item's
38
+ # attached collection — populated by the parser when an AsciiDoc
39
+ # `+` line follows the dd.
40
+ attribute :attached,
41
+ Coradoc::AsciiDoc::Model::Attached,
42
+ polymorphic: [
43
+ Coradoc::AsciiDoc::Model::Admonition,
44
+ Coradoc::AsciiDoc::Model::Paragraph,
45
+ Coradoc::AsciiDoc::Model::Block::Core
46
+ ],
47
+ collection: true,
48
+ initialize_empty: true
36
49
  attribute :nested,
37
50
  Coradoc::AsciiDoc::Model::Base,
38
51
  polymorphic: [Coradoc::AsciiDoc::Model::List::Definition],
@@ -50,16 +50,21 @@ module Coradoc
50
50
  end
51
51
  end
52
52
 
53
- # Get text content as string
53
+ # Get text content as a string. Handles the polymorphic shape of
54
+ # `content` (String, Array of mixed String/Model elements, nested
55
+ # Serializable). When the content is an Array, each element is
56
+ # rendered via its canonical source representation — Model::Base
57
+ # elements go through `to_adoc`, Strings pass through unchanged —
58
+ # so a downstream re-parse (e.g. by `ToCoreModel.parse_inline_text`)
59
+ # sees the original inline-mark syntax rather than `#<…>` dumps.
54
60
  #
55
61
  # @return [String] The text content
56
- #
57
62
  def to_s
58
63
  case content
59
64
  when String
60
65
  content
61
66
  when Array
62
- content.map(&:to_s).join
67
+ content.map { |element| element_to_s(element) }.join
63
68
  when Coradoc::AsciiDoc::Model::Base
64
69
  content.to_adoc
65
70
  when Lutaml::Model::Serializable
@@ -82,6 +87,17 @@ module Coradoc
82
87
  def text
83
88
  to_s
84
89
  end
90
+
91
+ private
92
+
93
+ def element_to_s(element)
94
+ case element
95
+ when Coradoc::AsciiDoc::Model::Base then element.to_adoc
96
+ when String then element
97
+ when nil then ''
98
+ else element.to_s
99
+ end
100
+ end
85
101
  end
86
102
  end
87
103
  end
@@ -88,6 +88,14 @@ module Coradoc
88
88
  def rule_dispatch(rule_name, *, **)
89
89
  RuleDispatcher.dispatch(self, rule_name, *, **)
90
90
  end
91
+
92
+ # Per-instance dispatch cache used by RuleDispatcher. Encapsulated
93
+ # accessor — the dispatcher reads this instead of poking
94
+ # instance_variable_get/set. Lazy-initialized; returns the same
95
+ # Hash across calls on the same instance.
96
+ def _rule_dispatch_cache
97
+ @_rule_dispatch_cache ||= {}
98
+ end
91
99
  end
92
100
 
93
101
  # Wrap every parser rule for Parslet memoization. Must run after all
@@ -4,25 +4,18 @@ module Coradoc
4
4
  module AsciiDoc
5
5
  module Parser
6
6
  module Inline
7
- # AsciiDoc typographic quote syntax: a 2-char pattern that
8
- # Asciidoctor substitutes with the corresponding Unicode curly
9
- # quote. Single source of truth for the pattern Unicode char
10
- # mapping; the transformer reads this table.
11
- #
12
- # The patterns MUST be recognised before +monospace_constrained+
13
- # in the +inline+ alternation, otherwise the lone backtick in
14
- # +`"`+ or +`"``+ fires monospace and the surrounding quote
15
- # collapses to straight ASCII quotes wrapped around a spurious
16
- # code span.
17
- TYPOGRAPHIC_QUOTE_PATTERNS = {
18
- '"`' => "“", # U+201C left double
19
- '`"' => "”", # U+201D right double
20
- "'`" => "‘", # U+2018 left single
21
- "`'" => "’" # U+2019 right single
22
- }.freeze
7
+ # Typographic quote patterns are owned by the shared
8
+ # {Coradoc::AsciiDoc::TypographicQuotes} module so both the
9
+ # parser and the transformer reference the same source of
10
+ # truth without one layer reaching across the other.
23
11
 
24
12
  def typographic_quote
25
- (str('"`') | str('`"') | str("'`") | str("`'")).as(:typographic_quote)
13
+ patterns = AsciiDoc::TypographicQuotes::PATTERNS
14
+ combined = patterns.reduce(nil) do |acc, pat|
15
+ atom = str(pat)
16
+ acc ? (acc | atom) : atom
17
+ end
18
+ combined.as(:typographic_quote)
26
19
  end
27
20
 
28
21
  def attribute_reference
@@ -31,19 +24,54 @@ module Coradoc
31
24
  str('}')
32
25
  end
33
26
 
27
+ # ── Constrained / unconstrained mark builders ──
28
+ #
29
+ # Single source of truth for the four constrained + four
30
+ # unconstrained inline mark rules. Before this extraction each
31
+ # rule hand-rolled the same open → content → close → lookahead
32
+ # shape with subtly different guards (presence checks, newline
33
+ # exclusions, inner-marker allowances). Now each rule is a
34
+ # 1-line wrapper over one of two parameterised builders.
35
+ #
36
+ # The constrained builder adds a `marker.absent?` guard on both
37
+ # ends so single-marker constrained never matches when a
38
+ # double-marker unconstrained is intended (e.g. `*` defers to
39
+ # `**`). The alternation order in `inline` handles this too,
40
+ # but the guard is belt-and-suspenders — it keeps each rule
41
+ # self-contained for unit testing.
42
+ #
43
+ # The unconstrained builder allows newlines in the content
44
+ # (Asciidoctor's behaviour for `__`, `**`, `##`, ``` `` ``` — a
45
+ # mark can span line breaks). This was Bug 15B's fix scope;
46
+ # highlight_unconstrained was previously inconsistent (excluded
47
+ # newlines). All four are now uniform.
48
+
49
+ def constrained_mark(marker, reject_paragraph_break: false, content: nil)
50
+ open_guard = str(marker) >> str(marker).absent?
51
+ content_rule = content || default_constrained_content(marker)
52
+ close_guard = str(marker) >> str(marker).absent?
53
+ sequence = open_guard >> content_rule >> close_guard
54
+ sequence >>= str("\n\n").absent? if reject_paragraph_break
55
+ sequence
56
+ end
57
+
58
+ def default_constrained_content(marker)
59
+ match("[^#{marker}\n]").repeat(1).as(:text).repeat(1, 1)
60
+ end
61
+
62
+ def unconstrained_mark(marker)
63
+ double = marker * 2
64
+ str(double) >>
65
+ match("[^#{marker}]").repeat(1).as(:text).repeat(1, 1) >>
66
+ str(double)
67
+ end
68
+
34
69
  def bold_constrained
35
- (str('*').present? >> str('*') >>
36
- match('[^*\n]').repeat(1).as(:text).repeat(1, 1) >>
37
- str('*') >> str('*').absent? >>
38
- str("\n\n").absent?
39
- ).as(:bold_constrained)
70
+ constrained_mark('*', reject_paragraph_break: true).as(:bold_constrained)
40
71
  end
41
72
 
42
73
  def bold_unconstrained
43
- (str('**').present? >> str('**') >>
44
- match('[^*]').repeat(1).as(:text).repeat(1, 1) >>
45
- str('**')
46
- ).as(:bold_unconstrained)
74
+ unconstrained_mark('*').as(:bold_unconstrained)
47
75
  end
48
76
 
49
77
  def span_constrained
@@ -63,45 +91,28 @@ module Coradoc
63
91
  end
64
92
 
65
93
  def italic_constrained
66
- (str('_') >> str('_').absent? >>
67
- match('[^_\n]').repeat(1).as(:text).repeat(1, 1) >>
68
- str('_') >> str('_').absent?
69
- ).as(:italic_constrained)
94
+ constrained_mark('_').as(:italic_constrained)
70
95
  end
71
96
 
72
97
  def italic_unconstrained
73
- (str('__') >>
74
- match('[^_]').repeat(1).as(:text).repeat(1, 1) >>
75
- str('__')
76
- ).as(:italic_unconstrained)
98
+ unconstrained_mark('_').as(:italic_unconstrained)
77
99
  end
78
100
 
79
101
  def highlight_constrained
80
- (str('#') >>
81
- match('[^#\n]').repeat(1).as(:text).repeat(1, 1) >>
82
- str('#') >> str('#').absent?
83
- ).as(:highlight_constrained)
102
+ constrained_mark('#').as(:highlight_constrained)
84
103
  end
85
104
 
86
105
  def highlight_unconstrained
87
- (str('##') >>
88
- match('[^#\n]').repeat(1).as(:text).repeat(1, 1) >>
89
- str('##')
90
- ).as(:highlight_unconstrained)
106
+ unconstrained_mark('#').as(:highlight_unconstrained)
91
107
  end
92
108
 
93
109
  def monospace_constrained
94
- (str('`') >>
95
- constrained_span_content('`').as(:text).repeat(1, 1) >>
96
- str('`') >> str('`').absent?
97
- ).as(:monospace_constrained)
110
+ content = constrained_span_content('`').as(:text).repeat(1, 1)
111
+ constrained_mark('`', content: content).as(:monospace_constrained)
98
112
  end
99
113
 
100
114
  def monospace_unconstrained
101
- (str('``') >>
102
- match('[^\`]').repeat(1).as(:text).repeat(1, 1) >>
103
- str('``')
104
- ).as(:monospace_unconstrained)
115
+ unconstrained_mark('`').as(:monospace_unconstrained)
105
116
  end
106
117
 
107
118
  def superscript
@@ -219,33 +230,31 @@ module Coradoc
219
230
  (str('\\') >> str("\n"))).as(:hard_line_break)
220
231
  end
221
232
 
233
+ # Priority-ordered registry of inline rules. The ORDER IS LOAD-BEARING:
234
+ # typographic_quote MUST come before monospace_constrained (Bug 14);
235
+ # each unconstrained rule MUST come before its constrained sibling
236
+ # (`` `` `` before `` ` ``, `**` before `*`, `__` before `_`).
237
+ # Adding a new inline rule = appending one symbol here (OCP).
238
+ INLINE_RULE_ORDER = %i[
239
+ typographic_quote
240
+ bold_unconstrained bold_constrained
241
+ span_unconstrained span_constrained
242
+ italic_unconstrained italic_constrained
243
+ highlight_unconstrained highlight_constrained
244
+ monospace_unconstrained monospace_constrained
245
+ superscript subscript
246
+ attribute_reference
247
+ escaped_xref cross_reference
248
+ term_inline term_inline2
249
+ footnote stem
250
+ link inline_image
251
+ inline_passthrough
252
+ underline small
253
+ hard_line_break
254
+ ].freeze
255
+
222
256
  def inline
223
- typographic_quote |
224
- bold_unconstrained |
225
- bold_constrained |
226
- span_unconstrained |
227
- span_constrained |
228
- italic_unconstrained |
229
- italic_constrained |
230
- highlight_unconstrained |
231
- highlight_constrained |
232
- monospace_unconstrained |
233
- monospace_constrained |
234
- superscript |
235
- subscript |
236
- attribute_reference |
237
- escaped_xref |
238
- cross_reference |
239
- term_inline |
240
- term_inline2 |
241
- footnote |
242
- stem |
243
- link |
244
- inline_image |
245
- inline_passthrough |
246
- underline |
247
- small |
248
- hard_line_break
257
+ INLINE_RULE_ORDER.map { |name| public_send(name) }.reduce(:|)
249
258
  end
250
259
 
251
260
  def text_unformatted
@@ -53,7 +53,7 @@ module Coradoc
53
53
  def olist_item(nesting_level = 1)
54
54
  item = olist_marker(nesting_level).as(:marker) >>
55
55
  match("\n").absent? >> space >>
56
- (text_line(false, unguarded: true) >>
56
+ (list_body_text_line >>
57
57
  list_item_continuation_lines).as(:lines)
58
58
 
59
59
  att = (list_continuation.present? >>
@@ -105,7 +105,7 @@ module Coradoc
105
105
  item = ulist_marker(nesting_level).as(:marker) >>
106
106
  str(' [[[').absent? >>
107
107
  match("\n").absent? >> space >>
108
- (text_line(false, unguarded: true) >>
108
+ (list_body_text_line >>
109
109
  list_item_continuation_lines).as(:lines)
110
110
 
111
111
  att = (list_continuation.present? >>
@@ -128,14 +128,11 @@ module Coradoc
128
128
  # id, table boundary, list continuation, or list prefix).
129
129
  # `line_not_text?` (from Paragraph) is that exact lookahead.
130
130
  #
131
- # Each iteration matches exactly one source line via
132
- # `text_line(false, ...)` (single-newline termination). The
133
- # `.repeat(0)` walks multiple continuation lines one at a time.
134
- # Using `text_line(true, ...)` here would let `line_ending.repeat(1)`
135
- # greedy-match across blank lines, silently absorbing the
136
- # follow-up paragraph into the last list item's content.
131
+ # Uses `list_body_text_line` (not raw `text_line`) so a hard break
132
+ # at end of one source line doesn't greedy-pull the next line's
133
+ # content via text_any same fix as dlist.
137
134
  def list_item_continuation_lines
138
- (line_not_text? >> text_line(false, unguarded: true)).repeat(0)
135
+ (line_not_text? >> list_body_text_line).repeat(0)
139
136
  end
140
137
 
141
138
  def dlist_delimiter
@@ -161,23 +158,128 @@ module Coradoc
161
158
  # the line as a continuation of the dlist item), not content.
162
159
  # Consume it without capturing so downstream CoreModel text
163
160
  # doesn't carry the source indentation into HTML/Markdown.
164
- (match('[ \t]').repeat(0) >> text.as(:definition)) >>
165
- line_ending >> empty_line.repeat(0)
161
+ #
162
+ # Multi-line definitions: AsciiDoc joins consecutive non-blank
163
+ # lines into a single paragraph within the dd. We capture each
164
+ # source line so the transformer can join them.
165
+ #
166
+ # Two load-bearing guards:
167
+ # 1. `line_not_text?` — prevents matching a `+` (list
168
+ # continuation marker) or `[example]` (block attribute)
169
+ # as the dd's text content.
170
+ # 2. `dlist_definition_line` (custom text_line variant) —
171
+ # text_line's text_any is greedy and matches across
172
+ # newlines via hard_line_break, swallowing the next
173
+ # source line's content (e.g. the `+` marker on the
174
+ # line after a hard break). The custom matcher excludes
175
+ # hard_line_break from the inline set so each `:lines`
176
+ # entry corresponds to exactly one source line.
177
+ (line_not_text? >> match('[ \t]').repeat(0) >>
178
+ (list_body_text_line >> list_item_continuation_lines).as(:lines)
179
+ ) >> empty_line.repeat(0)
180
+ end
181
+
182
+ # Single source line of text for any list item body (dlist dd,
183
+ # ulist item, olist item). Like text_line(false, unguarded: true)
184
+ # but uses `LIST_BODY_INLINE_RULE_NAMES` (excludes hard_line_break)
185
+ # so hard_break's ` +\n` consumption doesn't greedy-pull the next
186
+ # source line's content (e.g. the `+` line-continuation marker).
187
+ #
188
+ # Shared across list types — single source of truth for the
189
+ # "text inside a list item" grammar.
190
+ def list_body_text_line
191
+ literal_space? >>
192
+ list_body_text_any.as(:text) >>
193
+ list_body_line_ending
194
+ end
195
+
196
+ def list_body_text_any
197
+ (list_body_inline_except_hard_break |
198
+ list_body_text_unformatted.as(:text)).repeat(1)
199
+ end
200
+
201
+ def list_body_inline_except_hard_break
202
+ LIST_BODY_INLINE_RULE_NAMES.map { |name| public_send(name) }.reduce(:|)
203
+ end
204
+
205
+ # Single source of truth for "inline rules usable inside a list
206
+ # item body line". Mirrors INLINE_RULE_ORDER minus hard_line_break.
207
+ # If INLINE_RULE_ORDER changes, update this list (the
208
+ # list_body_inline_rule_names spec catches drift).
209
+ LIST_BODY_INLINE_RULE_NAMES = %i[
210
+ typographic_quote
211
+ bold_unconstrained bold_constrained
212
+ span_unconstrained span_constrained
213
+ italic_unconstrained italic_constrained
214
+ highlight_unconstrained highlight_constrained
215
+ monospace_unconstrained monospace_constrained
216
+ superscript subscript
217
+ attribute_reference
218
+ escaped_xref cross_reference
219
+ term_inline term_inline2
220
+ footnote stem
221
+ link inline_image
222
+ inline_passthrough
223
+ underline small
224
+ ].freeze
225
+
226
+ def list_body_text_unformatted
227
+ (str('\\<<').absent? >>
228
+ list_body_inline_except_hard_break.absent? >>
229
+ list_body_hard_break_marker?.absent? >>
230
+ match("[^\n]")
231
+ ).repeat(1)
232
+ end
233
+
234
+ def list_body_hard_break_marker?
235
+ (str(' +') >> str("\n")).present? |
236
+ (str('\\') >> str("\n")).present?
237
+ end
238
+
239
+ # Recognizes line endings for a list body source line, including
240
+ # the ` +\n` hard-break form. The hard_break marker is preserved
241
+ # as a `:hard_line_break` capture so downstream can render it
242
+ # as `<br>`.
243
+ def list_body_line_ending
244
+ (str(' +').as(:hard_line_break) >> line_ending.as(:line_break)) |
245
+ line_ending.as(:line_break) |
246
+ eof?
166
247
  end
167
248
 
168
249
  def dlist_item(_delimiter = nil)
169
- # Both forms below produce the same AST shape:
170
- # {terms: [<dlist_term>, ...], definition: <text>}
171
- # so the transformer's definition_list_item rule matches uniformly.
250
+ # AST shape: { terms: [single dlist_term], delimiter: <delim>,
251
+ # lines: <def or nil>, attached: [...] }
172
252
  #
173
- # Multi-line form: one or more term-lines (`term::` + newline +
174
- # optional blank lines), then the definition on its own line(s).
175
- # Single-line form: one term + inline space + definition.
176
- term_line = dlist_term >> line_ending >> empty_line.repeat(0)
177
-
178
- ((term_line.repeat(1).as(:terms) >> dlist_definition) |
179
- (dlist_term.repeat(1, 1).as(:terms) >> space >>
180
- dlist_definition)).as(:definition_list_item)
253
+ # ONE term per dlist_item. Multi-term `<dt>`'s and multi-level
254
+ # nesting are both handled by the transformer's `build_dlist_tree`,
255
+ # which inspects each item's `delimiter` to infer depth and
256
+ # merges consecutive same-depth term-only items into multi-term
257
+ # items. Keeping the parser's term-emission at one-per-item lets
258
+ # `delimiter` carry the nesting signal unambiguously — a parser
259
+ # that greedily groups `parent::` and `child:::` into one item
260
+ # loses the depth change between them.
261
+ #
262
+ # `repeat(1, 1).as(:terms)` ensures `:terms` is captured as a
263
+ # single-element Array (matching the shape transformer's `Array()`
264
+ # wrap expects) rather than a bare Hash — bare Hashes confuse
265
+ # `Array(hash)` into nested-pair conversion.
266
+ #
267
+ # Two forms:
268
+ # 1. multi-line: term on its own line, optional def on next line(s)
269
+ # 2. inline: term + def on same line
270
+ multi_line = dlist_term.repeat(1, 1).as(:terms) >> line_ending >>
271
+ empty_line.repeat(0) >> dlist_definition.maybe
272
+ inline = dlist_term.repeat(1, 1).as(:terms) >> space >> dlist_definition
273
+
274
+ item = multi_line | inline
275
+
276
+ attached = (list_continuation.present? >>
277
+ list_continuation >>
278
+ (admonition_line | paragraph | block)
279
+ ).repeat(0).as(:attached)
280
+ item >>= attached.maybe
281
+
282
+ item.as(:definition_list_item)
181
283
  end
182
284
  end
183
285
  end
@@ -119,8 +119,10 @@ module Coradoc
119
119
  end
120
120
 
121
121
  def dispatch_cache(parser_instance)
122
- parser_instance.instance_variable_get(:@_rule_dispatch_cache) ||
123
- parser_instance.instance_variable_set(:@_rule_dispatch_cache, {})
122
+ # Per-instance memoization of dispatch rules. Exposed via
123
+ # the public `_rule_dispatch_cache` method on Parser::Base
124
+ # so we don't bypass encapsulation with instance_variable_*.
125
+ parser_instance._rule_dispatch_cache
124
126
  end
125
127
 
126
128
  def dispatch?
@@ -6,6 +6,7 @@ module Coradoc
6
6
  autoload :Base, "#{__dir__}/parser/base"
7
7
  autoload :Cache, "#{__dir__}/parser/cache"
8
8
  autoload :FrontmatterParser, "#{__dir__}/parser/frontmatter_parser"
9
+ autoload :Inline, "#{__dir__}/parser/inline"
9
10
  end
10
11
  end
11
12
  end
@@ -24,9 +24,7 @@ module Coradoc
24
24
  # Frontmatter prefix, if present. The raw YAML text is
25
25
  # already canonical (single source of truth is Codec), so we
26
26
  # only wrap it with `---` delimiters.
27
- if @model.frontmatter && !@model.frontmatter.strip.empty?
28
- parts << "---\n#{@model.frontmatter.chomp}\n---\n\n"
29
- end
27
+ parts << "---\n#{@model.frontmatter.chomp}\n---\n\n" if @model.frontmatter && !@model.frontmatter.strip.empty?
30
28
 
31
29
  # Only add leading newline if we have sections but no header
32
30
  parts << "\n" if @model.sections && !@model.sections.empty? && !@model.header
@@ -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|
@@ -128,17 +128,31 @@ module Coradoc
128
128
  # Used by the paragraph and reviewer_note rules to share the same
129
129
  # line-shape handling (DRY).
130
130
  def self.lines_to_text_elements(lines)
131
- Array(lines).map do |line|
131
+ # Parslet may deliver `lines` as a single Hash (one line captured)
132
+ # or an Array of Hashes (multiple lines). `Array(hash)` converts
133
+ # to nested pairs, which we don't want — normalize explicitly.
134
+ normalized = case lines
135
+ when nil then []
136
+ when Array then lines
137
+ when Hash then [lines]
138
+ else Array(lines)
139
+ end
140
+ normalized.map do |line|
132
141
  next line unless line.is_a?(Hash) && line.key?(:text)
133
142
 
134
143
  text_content = line[:text]
135
- transformed = if text_content.is_a?(Array)
136
- text_content.map do |item|
137
- item.is_a?(Hash) ? new.apply(item) : item
138
- end
139
- else
140
- text_content
141
- end
144
+ # `text_content` may be a single Hash (one inline), an Array
145
+ # of Hashes (multiple inlines), or a String. Normalize to an
146
+ # Array of Hashes for uniform processing.
147
+ text_array = case text_content
148
+ when Array then text_content
149
+ when Hash then [text_content]
150
+ when String then [{ text: text_content }]
151
+ else [{ text: text_content.to_s }]
152
+ end
153
+ transformed = text_array.map do |item|
154
+ item.is_a?(Hash) ? new.apply(item) : item
155
+ end
142
156
 
143
157
  Model::TextElement.new(
144
158
  content: transformed,
@@ -147,6 +161,57 @@ module Coradoc
147
161
  end
148
162
  end
149
163
 
164
+ # Split `:lines` entries that span multiple source lines (via
165
+ # text_any greedy-matching across hard_line_break). Each call
166
+ # returns one entry per logical source line: when a line entry's
167
+ # `:text` array contains a `hard_line_break`, the entry is split
168
+ # at each hard_break, producing N+1 entries for N hard breaks.
169
+ # The hard_break is preserved as the `:line_break` of its segment.
170
+ #
171
+ # Without this, a dd source like:
172
+ # `term::` First line. +
173
+ # +
174
+ # attached
175
+ # produces a single `:lines` entry whose text contains
176
+ # ["First line.", hard_line_break(" +\n"), "+", "attached..."],
177
+ # which the renderer joins into "First line. + attached" — the
178
+ # `+` continuation marker ends up inside the dd's text instead
179
+ # of triggering the attached-block path.
180
+ #
181
+ # Used by the dlist transformer before lines_to_text_elements.
182
+ def self.split_lines_on_hard_break(lines)
183
+ normalized = case lines
184
+ when nil then []
185
+ when Array then lines
186
+ when Hash then [lines]
187
+ else Array(lines)
188
+ end
189
+ normalized.flat_map do |line|
190
+ next [line] unless line.is_a?(Hash) && line[:text].is_a?(Array)
191
+ next [line] unless line[:text].any? { |p| p.is_a?(Hash) && p.key?(:hard_line_break) }
192
+
193
+ segments = []
194
+ current_text = []
195
+ current_break = nil
196
+ line[:text].each do |part|
197
+ if part.is_a?(Hash) && part.key?(:hard_line_break)
198
+ segments << { text: current_text, line_break: part[:hard_line_break] }
199
+ current_text = []
200
+ current_break = :had_hard_break
201
+ else
202
+ current_text << part
203
+ end
204
+ end
205
+ # Final segment gets the original line's line_break (or empty
206
+ # if it ended with a hard break).
207
+ final_break = current_break == :had_hard_break ? "\n" : line[:line_break]
208
+ segments << { text: current_text, line_break: final_break }
209
+ # Drop leading empty segment if the line started with a hard break
210
+ # (shouldn't happen in practice but be defensive).
211
+ segments.reject { |s| s[:text].empty? && s != segments.last }
212
+ end
213
+ end
214
+
150
215
  # Legacy transform method (deprecated)
151
216
  # @deprecated Use {.transform} instead
152
217
  def self.legacy_transform(syntax_tree)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ # Single source of truth for AsciiDoc's typographic quote substitution.
6
+ #
7
+ # Asciidoctor replaces four 2-char patterns with the corresponding
8
+ # Unicode curly quotes. Both the parser (to recognise the patterns)
9
+ # and the transformer (to emit the Unicode chars) reference this
10
+ # module — the mapping lives here, not on either consumer, so the
11
+ # parser/transformer seam stays clean.
12
+ #
13
+ # The patterns MUST be recognised before +monospace_constrained+ in
14
+ # the +inline+ alternation, otherwise the lone backtick in +`"`+ or
15
+ # +`"``+ fires monospace and the surrounding quote collapses to
16
+ # straight ASCII quotes wrapped around a spurious code span.
17
+ module TypographicQuotes
18
+ PATTERN_TO_CHAR = {
19
+ '"`' => '“', # U+201C left double
20
+ '`"' => '”', # U+201D right double
21
+ "'`" => '‘', # U+2018 left single
22
+ "`'" => '’' # U+2019 right single
23
+ }.freeze
24
+
25
+ # All four 2-char patterns, suitable for building a Parslet alternation.
26
+ # @return [Array<String>]
27
+ PATTERNS = PATTERN_TO_CHAR.keys.freeze
28
+
29
+ # Look up the Unicode char for a matched pattern.
30
+ # @param pattern [String, Parslet::Slice] the matched 2-char pattern
31
+ # @return [String] the Unicode curly quote char
32
+ def self.char_for(pattern)
33
+ PATTERN_TO_CHAR.fetch(pattern.to_s, pattern.to_s)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Coradoc
4
4
  module AsciiDoc
5
- VERSION = '2.0.29'
5
+ VERSION = '2.0.30'
6
6
  end
7
7
  end
@@ -28,6 +28,7 @@ end
28
28
  module Coradoc
29
29
  module AsciiDoc
30
30
  autoload :DelimiterMapping, "#{__dir__}/asciidoc/delimiter_mapping"
31
+ autoload :TypographicQuotes, "#{__dir__}/asciidoc/typographic_quotes"
31
32
  autoload :Model, "#{__dir__}/asciidoc/model"
32
33
  autoload :Parser, "#{__dir__}/asciidoc/parser"
33
34
  autoload :Transformer, "#{__dir__}/asciidoc/transformer"
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.29
4
+ version: 2.0.30
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -348,6 +348,7 @@ files:
348
348
  - lib/coradoc/asciidoc/transformer/table_cell_builder.rb
349
349
  - lib/coradoc/asciidoc/transformer/table_layout.rb
350
350
  - lib/coradoc/asciidoc/transformer/text_rules.rb
351
+ - lib/coradoc/asciidoc/typographic_quotes.rb
351
352
  - lib/coradoc/asciidoc/version.rb
352
353
  - lib/coradoc/util.rb
353
354
  - lib/coradoc/util/asciidoc.rb
@@ -357,6 +358,7 @@ licenses:
357
358
  metadata:
358
359
  homepage_uri: https://github.com/lutaml/coradoc
359
360
  source_code_uri: https://github.com/lutaml/coradoc
361
+ rubygems_mfa_required: 'true'
360
362
  rdoc_options: []
361
363
  require_paths:
362
364
  - lib