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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05d6ea3852e5fb5c4bae0176556b9dd3b7a572b09ab1fc8c9d3752ebe0833548
4
- data.tar.gz: c9d4a61637e3e04a3fb0ad4b8394b8577236dbf0a9853375f01d750d8ff30f64
3
+ metadata.gz: 22eb5a515f97c8954148e99ef280dd4bb8ac3bed03d59d745291dc3d7ac302c2
4
+ data.tar.gz: 0435ded18a2f7895426d1de9aba45c5c3ccaeea442c287fb1f5cd6d14581b117
5
5
  SHA512:
6
- metadata.gz: 2927618c321c352e622dfd8dc69dbeb5658715e76d4062b05793641dc8c35a829c168a8d1e446b7f382bfb86c7f09a0919b4c05409e0a262e41aa8f327a82755
7
- data.tar.gz: 1fd71a5b04f52748ef48b0529b7d6d8dcbb46987002c6ec442d511fa5902b31761eff9c83d1f3bbb1c0992b7e9d34a544443b55ef5f4580247d93591bbc3f98d
6
+ metadata.gz: cc35eb17f93b7c78fc2f935cf71ef18d76af08eb36a52ab7040d90e735c72a4d1f990339fc414803ccbf767d0b57bf636be79d76940fa8a091e52bcd37091544
7
+ data.tar.gz: 8c93968c63d400dfb86d0855c6bb1a2ebc6bbe200a85b07da5d2861771baeacf2beb11cd811c136e912fae302d0003c8aa822f12c73ef2ae5d127ced9caea829
@@ -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