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 +4 -4
- data/lib/coradoc/asciidoc/model/list/definition_item.rb +13 -0
- data/lib/coradoc/asciidoc/model/text_element.rb +19 -3
- data/lib/coradoc/asciidoc/parser/base.rb +8 -0
- data/lib/coradoc/asciidoc/parser/inline.rb +85 -76
- data/lib/coradoc/asciidoc/parser/list.rb +124 -22
- data/lib/coradoc/asciidoc/parser/rule_dispatcher.rb +4 -2
- data/lib/coradoc/asciidoc/parser.rb +1 -0
- data/lib/coradoc/asciidoc/serializer/serializers/document.rb +1 -3
- data/lib/coradoc/asciidoc/transform/element_transformers/document_transformer.rb +1 -1
- data/lib/coradoc/asciidoc/transform/element_transformers/inline_transformer.rb +27 -22
- data/lib/coradoc/asciidoc/transform/element_transformers/list_transformer.rb +61 -11
- data/lib/coradoc/asciidoc/transform/from_core_model.rb +27 -11
- data/lib/coradoc/asciidoc/transformer/inline_rules.rb +5 -5
- data/lib/coradoc/asciidoc/transformer/list_rules.rb +100 -30
- data/lib/coradoc/asciidoc/transformer/table_layout.rb +3 -1
- data/lib/coradoc/asciidoc/transformer.rb +73 -8
- data/lib/coradoc/asciidoc/typographic_quotes.rb +37 -0
- data/lib/coradoc/asciidoc/version.rb +1 -1
- data/lib/coradoc/asciidoc.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 79a252c883acda46234ba968656577308075f2c1694ee2cdff96aa78006d3fe4
|
|
4
|
+
data.tar.gz: 20186d4848bfe7a63a16a11a43ce3b6ae861bb1bf60be7a71af4d96e7e31ea2b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
95
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
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? >>
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
#
|
|
170
|
-
#
|
|
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
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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?
|
|
@@ -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
|
-
|
|
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).
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
35
|
+
term_parts = Array(item.terms)
|
|
36
36
|
def_content = item.contents
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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:
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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:
|
|
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
|
|
75
|
-
#
|
|
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
|
-
|
|
78
|
-
|
|
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]
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
data/lib/coradoc/asciidoc.rb
CHANGED
|
@@ -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.
|
|
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
|