coradoc-adoc 2.0.35 → 2.0.36

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/grammar/coradoc-adoc.artifact.json +1 -0
  3. data/grammar/coradoc-adoc.parg +396 -0
  4. data/lib/coradoc/asciidoc/formatter.rb +75 -0
  5. data/lib/coradoc/asciidoc/lint/document_title.rb +26 -0
  6. data/lib/coradoc/asciidoc/lint/duplicate_ids.rb +36 -0
  7. data/lib/coradoc/asciidoc/lint/empty_section.rb +45 -0
  8. data/lib/coradoc/asciidoc/lint/heading_levels.rb +44 -0
  9. data/lib/coradoc/asciidoc/lint.rb +27 -0
  10. data/lib/coradoc/asciidoc/model/text_element.rb +18 -5
  11. data/lib/coradoc/asciidoc/parser/base.rb +28 -11
  12. data/lib/coradoc/asciidoc/parser/citation.rb +10 -0
  13. data/lib/coradoc/asciidoc/parser/inline.rb +8 -4
  14. data/lib/coradoc/asciidoc/parser/list.rb +19 -2
  15. data/lib/coradoc/asciidoc/parser/paragraph.rb +1 -1
  16. data/lib/coradoc/asciidoc/parser/positioned_string.rb +40 -0
  17. data/lib/coradoc/asciidoc/parser/rule_dispatcher.rb +3 -2
  18. data/lib/coradoc/asciidoc/parser/tree_materializer.rb +28 -0
  19. data/lib/coradoc/asciidoc/parser.rb +2 -0
  20. data/lib/coradoc/asciidoc/serializer/serializers/block/source_code.rb +3 -1
  21. data/lib/coradoc/asciidoc/serializer/serializers/list/item.rb +4 -0
  22. data/lib/coradoc/asciidoc/serializer/serializers/table.rb +8 -2
  23. data/lib/coradoc/asciidoc/serializer/serializers/text_element.rb +3 -1
  24. data/lib/coradoc/asciidoc/transform/from_core_model/list_building.rb +86 -0
  25. data/lib/coradoc/asciidoc/transform/from_core_model.rb +100 -29
  26. data/lib/coradoc/asciidoc/transform/frontmatter_attribute_map.rb +3 -2
  27. data/lib/coradoc/asciidoc/transform/text_extract_visitor.rb +5 -2
  28. data/lib/coradoc/asciidoc/transform/to_core_model.rb +12 -2
  29. data/lib/coradoc/asciidoc/transform.rb +1 -0
  30. data/lib/coradoc/asciidoc/transformer/source_line_extractor.rb +1 -1
  31. data/lib/coradoc/asciidoc/transformer/table_cell_builder.rb +18 -7
  32. data/lib/coradoc/asciidoc/version.rb +1 -1
  33. data/lib/coradoc/asciidoc.rb +12 -0
  34. data/lib/coradoc/util/asciidoc.rb +56 -0
  35. metadata +16 -5
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ # AsciiDoc lint rules (#165). Each rule checks the AsciiDoc model
6
+ # tree in a model-driven way — no text scanning. Rules self-register
7
+ # into Coradoc::Lint.registry when this file is required; adding a
8
+ # rule is adding one file under lint/ plus its require here.
9
+ #
10
+ # NOTE: a "list starts deeper than level 1" rule is structurally
11
+ # unobservable here — the parser canonicalizes depth markers
12
+ # (#285), so a lone '**' list already reads as level 1 on the
13
+ # model tree. That concern lives in the parser/serializer, not
14
+ # in a lint.
15
+ module Lint
16
+ require_relative 'lint/heading_levels'
17
+ require_relative 'lint/document_title'
18
+ require_relative 'lint/empty_section'
19
+ require_relative 'lint/duplicate_ids'
20
+
21
+ RULES = [HeadingLevels, DocumentTitle, EmptySection,
22
+ DuplicateIds].freeze
23
+
24
+ RULES.each { |rule| Coradoc::Lint.registry.register(rule) }
25
+ end
26
+ end
27
+ end
@@ -40,14 +40,27 @@ module Coradoc
40
40
  line_break.to_s.empty?
41
41
  end
42
42
 
43
+ # Escape whole runs of '*'/'_' that touch whitespace on either
44
+ # side. Run-aware two-pass scan — the lookaround + alternation
45
+ # + quantifier regex is modeled as polynomial-redos by CodeQL.
43
46
  def self.escape_keychars(string)
44
47
  subs = { '*' => '\*', '_' => '\_' }
45
- string
46
- .gsub(/((?<=\s)[*_]+)|[*_]+(?=\s)/) do |n|
47
- n.chars.map do |char|
48
- subs[char]
49
- end.join
48
+ chars = string.chars
49
+ escaped = Array.new(chars.length, false)
50
+ i = 0
51
+ while i < chars.length
52
+ unless subs.key?(chars[i])
53
+ i += 1
54
+ next
55
+ end
56
+ j = i
57
+ j += 1 while j < chars.length && subs.key?(chars[j])
58
+ touches = (i.positive? && chars[i - 1].match?(/\s/)) ||
59
+ (j < chars.length && chars[j].match?(/\s/))
60
+ (i...j).each { |k| escaped[k] = true } if touches
61
+ i = j
50
62
  end
63
+ chars.each_with_index.map { |ch, k| escaped[k] ? subs[ch] : ch }.join
51
64
  end
52
65
 
53
66
  # Get text content as a string. Handles the polymorphic shape of
@@ -85,17 +85,16 @@ module Coradoc
85
85
  warn e.parse_failure_cause.ascii_tree
86
86
  end
87
87
 
88
- # The Ruby engine is the required surface for this grammar.
89
- # parsanol 1.3.44 fixes capture WRITES across the bridge
90
- # (parsanol-ruby#80 — verified with a continuation-style repro),
91
- # but the native tree still diverges from the Ruby-engine tree
92
- # on attribute-list constructs: 69 suite failures natively from
93
- # transform rules receiving raw Hashes where models are
94
- # expected (e.g. Image::AttributeExtractor.promote_positional).
95
- # Drop the override when native/Ruby tree parity holds for this
96
- # grammar.
97
- def parse(string, **options)
98
- super(string, **options, mode: options.fetch(:mode, :ruby))
88
+ # The compiled PARG artifact (grammar/coradoc-adoc.parg, the
89
+ # single source of truth) is the parse backend; the Ruby-DSL
90
+ # grammar defined by this class remains the parity reference
91
+ # (parg_parity_spec + engine_differential_spec assert the two
92
+ # produce identical trees). The artifact routes through
93
+ # parsanol's compiled-program fast lane (parsanol-ruby#162,
94
+ # 1.3.81) and parses native by default; the historical
95
+ # ruby-mode artifact divergence is fixed (#185, 1.3.82).
96
+ def parse(string, **)
97
+ GrammarBackend.parse(string, **)
99
98
  end
100
99
 
101
100
  def rule_dispatch(rule_name, *, **)
@@ -111,6 +110,24 @@ module Coradoc
111
110
  end
112
111
  end
113
112
 
113
+ # Lazily-loaded compiled-grammar backend (the shipped artifact).
114
+ module GrammarBackend
115
+ class << self
116
+ def parse(string, **)
117
+ TreeMaterializer.convert(artifact.parse('document', string, **))
118
+ end
119
+
120
+ private
121
+
122
+ def artifact
123
+ require 'parsanol/parg'
124
+ @artifact ||= Parsanol::PARG::Artifact.load(
125
+ File.expand_path('../../../../grammar/coradoc-adoc.artifact.json', __dir__)
126
+ )
127
+ end
128
+ end
129
+ end
130
+
114
131
  # Wrap every parser rule for Parsanol memoization. Must run after all
115
132
  # parser modules are included in Base so that instance_method(rule_name)
116
133
  # finds the methods defined by every module.
@@ -22,6 +22,16 @@ module Coradoc
22
22
  def escaped_xref
23
23
  str('\\') >> str('<<').as(:text)
24
24
  end
25
+
26
+ # General AsciiDoc escape: \X (X punctuation, minus < for
27
+ # escaped_xref and \ itself) yields the literal character X
28
+ # without firing any inline rule (#281).
29
+ def escape_char
30
+ # Explicit class (all ASCII punctuation except < for
31
+ # escaped_xref and \ itself): the native engine does not
32
+ # compile && class intersections.
33
+ str('\\') >> match("[!\"#$%&'()*+,\\-./:;=?@\\[\\]^_`{|}~]").as(:text)
34
+ end
25
35
  end
26
36
  end
27
37
  end
@@ -46,10 +46,14 @@ module Coradoc
46
46
  # highlight_unconstrained was previously inconsistent (excluded
47
47
  # newlines). All four are now uniform.
48
48
 
49
+ # Constrained markers delimit at boundaries only (#281): the
50
+ # opening marker must not follow a word char, the closing one
51
+ # must not precede one — `foo_bar_baz` stays plain text.
49
52
  def constrained_mark(marker, reject_paragraph_break: false, content: nil)
50
- open_guard = str(marker) >> str(marker).absent?
53
+ open_guard = Parsanol::Atoms::Lookbehind.regex('[[:word:]]', positive: false) >>
54
+ str(marker) >> str(marker).absent?
51
55
  content_rule = content || default_constrained_content(marker)
52
- close_guard = str(marker) >> str(marker).absent?
56
+ close_guard = str(marker) >> str(marker).absent? >> match('[[:word:]]').absent?
53
57
  sequence = open_guard >> content_rule >> close_guard
54
58
  sequence >>= str("\n\n").absent? if reject_paragraph_break
55
59
  sequence
@@ -204,7 +208,7 @@ module Coradoc
204
208
  # Single lookahead over the whole dispatch alternation; the former
205
209
  # per-branch .present? chain put zero-width branches in every
206
210
  # non-last slot (shadowed-alternative lint, parsanol 1.3.55+).
207
- (match('[\[*#_{<^~`]') |
211
+ (match('[\\\\\[*#_{<^~`]') |
208
212
  typographic_quote |
209
213
  str('http') |
210
214
  str('https') |
@@ -247,7 +251,7 @@ module Coradoc
247
251
  monospace_unconstrained monospace_constrained
248
252
  superscript subscript
249
253
  attribute_reference
250
- escaped_xref cross_reference
254
+ escaped_xref escape_char cross_reference
251
255
  term_inline term_inline2
252
256
  footnote stem
253
257
  link inline_image
@@ -13,6 +13,16 @@ module Coradoc
13
13
  ).as(:list)
14
14
  end
15
15
 
16
+ # A list item at ANY depth starts a list construct: a nested
17
+ # list (`* a` followed by `** b`) must stop the parent item's
18
+ # continuation text and attach as `nested` instead. Full items
19
+ # (not bare markers) so `**un**constrained` — no space after
20
+ # the marker — stays ordinary text.
21
+ def list_start
22
+ (1..5).map { |level| unordered_list(level) | ordered_list(level) }
23
+ .reduce(:|)
24
+ end
25
+
16
26
  def list_continuation
17
27
  line_start? >> str("+\n")
18
28
  end
@@ -133,6 +143,13 @@ module Coradoc
133
143
  end
134
144
 
135
145
  def ulist_item(nesting_level = 1)
146
+ # A bare marker line (`*` then newline) is an empty list
147
+ # item — without this arm it fell through to a continuation
148
+ # text line and corrupted the preceding item (#139).
149
+ empty_item = ulist_marker(nesting_level).as(:marker) >>
150
+ str(' [[[').absent? >>
151
+ line_ending.as(:lines)
152
+
136
153
  item = ulist_marker(nesting_level).as(:marker) >>
137
154
  str(' [[[').absent? >>
138
155
  match("\n").absent? >> space >>
@@ -145,7 +162,7 @@ module Coradoc
145
162
  item >>= (list_marker(nesting_level + 1).present? >>
146
163
  list(nesting_level + 1)).repeat(0).as(:nested)
147
164
  end
148
- ulist_marker(nesting_level).present? >> item.as(:list_item)
165
+ (empty_item | item).as(:list_item)
149
166
  end
150
167
 
151
168
  # Continuation lines of a list item's first paragraph. AsciiDoc
@@ -242,7 +259,7 @@ module Coradoc
242
259
  monospace_unconstrained monospace_constrained
243
260
  superscript subscript
244
261
  attribute_reference
245
- escaped_xref cross_reference
262
+ escaped_xref escape_char cross_reference
246
263
  term_inline term_inline2
247
264
  footnote stem
248
265
  link inline_image
@@ -9,7 +9,7 @@ module Coradoc
9
9
  (attribute_list >> newline).absent? >>
10
10
  block_delimiter.absent? >>
11
11
  (str('|===') >> newline).absent? >>
12
- list.absent? >>
12
+ list_start.absent? >>
13
13
  list_prefix.absent? >>
14
14
  list_continuation.absent? >>
15
15
  element_id.absent? >>
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parsanol'
4
+
5
+ module Coradoc
6
+ module AsciiDoc
7
+ module Parser
8
+ # An immutable String carrying its byte offset and source input.
9
+ #
10
+ # The native engine hands pooled, mutable Parsanol::Slice objects
11
+ # back in the parse tree; storing those in document models is
12
+ # unsafe (the pool resets them) and they fail serializer type
13
+ # checks. TreeMaterializer converts every Slice into a
14
+ # PositionedString at the GrammarBackend boundary, so models only
15
+ # ever see Strings — while SourceLineExtractor can still recover
16
+ # positions (line_and_column delegates the computation to a
17
+ # throwaway Slice).
18
+ class PositionedString < String
19
+ attr_reader :byte_position, :source_input
20
+
21
+ def initialize(content, byte_position: 0, source_input: nil)
22
+ super(content)
23
+ @byte_position = byte_position
24
+ @source_input = source_input
25
+ end
26
+
27
+ def self.from_slice(slice)
28
+ new(slice.to_s, byte_position: slice.offset, source_input: slice.input)
29
+ end
30
+
31
+ def line_and_column
32
+ raise ArgumentError, 'Line/column requires input' unless @source_input
33
+
34
+ @line_and_column ||= Parsanol::Slice.new(@byte_position, to_s,
35
+ @source_input).line_and_column
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -47,7 +47,7 @@ module Coradoc
47
47
  end
48
48
  cache[key] = rule_name
49
49
  end
50
- parser_instance.public_send(cache[key])
50
+ TreeMaterializer.convert(parser_instance.public_send(cache[key]))
51
51
  end
52
52
 
53
53
  private
@@ -59,7 +59,8 @@ module Coradoc
59
59
  # Wrapping `__send__` in particular causes infinite recursion.
60
60
  # @return [Hash{Symbol => Array<Module>}]
61
61
  def collect_rule_names
62
- parser_constants = Parser.constants - %i[Base Cache FixFiles RuleDispatcher]
62
+ parser_constants = Parser.constants - %i[Base Cache FixFiles RuleDispatcher
63
+ PositionedString TreeMaterializer]
63
64
  parser_constants.each_with_object({}) do |const, acc|
64
65
  Parser.const_get(const).instance_methods(false).each do |name|
65
66
  acc[name] ||= []
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parsanol'
4
+
5
+ module Coradoc
6
+ module AsciiDoc
7
+ module Parser
8
+ # Converts a native-engine parse tree: every
9
+ # Parsanol::Slice becomes an immutable PositionedString. Runs at
10
+ # the GrammarBackend boundary so the transformer and document
11
+ # models never see pooled slices, without losing source
12
+ # positions.
13
+ module TreeMaterializer
14
+ module_function
15
+
16
+ def convert(node)
17
+ case node
18
+ when Parsanol::Slice then PositionedString.from_slice(node)
19
+ when Hash then node.transform_values { |value| convert(value) }
20
+ when Array then node.map { |value| convert(value) }
21
+ else
22
+ node
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -7,6 +7,8 @@ module Coradoc
7
7
  autoload :Cache, "#{__dir__}/parser/cache"
8
8
  autoload :FrontmatterParser, "#{__dir__}/parser/frontmatter_parser"
9
9
  autoload :Inline, "#{__dir__}/parser/inline"
10
+ autoload :PositionedString, "#{__dir__}/parser/positioned_string"
11
+ autoload :TreeMaterializer, "#{__dir__}/parser/tree_materializer"
10
12
  end
11
13
  end
12
14
  end
@@ -8,7 +8,9 @@ module Coradoc
8
8
  class SourceCode < Core
9
9
  def to_adoc(model, _options = {})
10
10
  @model = model
11
- "\n\n#{gen_anchor}[source,#{model.lang}]\n#{gen_delimiter}\n" <<
11
+ lang = model.lang.to_s.strip
12
+ header = lang.empty? ? '[source]' : "[source,#{lang}]"
13
+ "\n\n#{gen_anchor}#{header}\n#{gen_delimiter}\n" <<
12
14
  gen_lines << "\n#{gen_delimiter}\n\n"
13
15
  end
14
16
  end
@@ -87,6 +87,10 @@ module Coradoc
87
87
  "+\n#{elem.to_adoc}"
88
88
  end.join
89
89
  nest = model.nested.nil? || (model.nested.is_a?(Array) && model.nested.empty?) ? '' : model.nested.to_adoc
90
+ # The nested list serializer opens with a block separator;
91
+ # adjacent to the item line it would read as a detached
92
+ # sibling list on re-parse.
93
+ nest = nest.sub(/\A\n+/, '')
90
94
  out = " #{_anchor}#{out}#{model.line_break}"
91
95
  out + attach + nest
92
96
  end
@@ -5,14 +5,20 @@ module Coradoc
5
5
  module Serializer
6
6
  module Serializers
7
7
  class Table < Base
8
- def to_adoc(model, _options = {})
8
+ def to_adoc(model, options_or_context = {})
9
9
  @model = model
10
+ context = normalize_context(options_or_context)
10
11
  _anchor = @model.anchor.nil? ? '' : "#{serialize_child(@model.anchor)}\n"
11
12
  _attrs = @model.attrs.to_s.empty? ? '' : "#{serialize_child(@model.attrs)}\n"
12
13
  _title = @model.title.nil? || @model.title.empty? ? '' : ".#{serialize_child(@model.title)}\n"
13
14
  _content = @model.rows.map { |row| serialize_child(row) }.join
14
15
 
15
- "\n\n#{_anchor}#{_attrs}#{_title}|===\n#{_content}|===\n"
16
+ # Blank-line separation after the table so a following
17
+ # block never glues onto the closer (#151) — same
18
+ # convention as the paragraph serializer, suppressed for
19
+ # the document's last element.
20
+ separator = context.last_element ? '' : "\n"
21
+ "\n\n#{_anchor}#{_attrs}#{_title}|===\n#{_content}|===\n#{separator}"
16
22
  end
17
23
  end
18
24
  end
@@ -9,7 +9,9 @@ module Coradoc
9
9
  def to_adoc(model, _options = {})
10
10
  result = ''
11
11
  result += "[[#{model.id}]] " if model.id
12
- result += serialize_content(model.content)
12
+ content = serialize_content(model.content)
13
+ content = Coradoc::Util::AsciiDoc.escape_text_delimiters(content) if content.is_a?(String)
14
+ result += content
13
15
  result += model.line_break.to_s
14
16
  result
15
17
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ module Transform
6
+ # List construction for the AsciiDoc view: nested lists flatten
7
+ # into the item stream with depth markers so AsciiDoc reads them
8
+ # as nested rather than sibling lists (#68) — a marker-type
9
+ # change alone signals nesting; same-type nesting goes one level
10
+ # deeper.
11
+ module ListBuilding
12
+ def transform_list(list, depth = 1)
13
+ items = []
14
+ Array(list.items).each do |item|
15
+ nested_blocks = [item.nested_list,
16
+ *Array(item.children).grep(CoreModel::ListBlock)].compact
17
+ text = inline_text(item)
18
+ attached = attached_blocks(item)
19
+ if text.empty? && attached.empty? && nested_blocks.any?
20
+ # A wrapper item whose only content is its nested list
21
+ # cannot render as a deeper list in AsciiDoc (lists start
22
+ # at level 1, #68), so splice the nested list at this depth.
23
+ nested_blocks.each do |child|
24
+ child_depth = child.marker_type == list.marker_type ? depth : 1
25
+ items.concat(transform_list(child, child_depth).items)
26
+ end
27
+ next
28
+ end
29
+ items << Coradoc::AsciiDoc::Model::List::Item.new(
30
+ content: text,
31
+ marker: item.marker || (default_marker(list.marker_type) * depth),
32
+ attached: attached
33
+ )
34
+ nested_blocks.each do |child|
35
+ child_depth = child.marker_type == list.marker_type ? depth + 1 : 1
36
+ items.concat(transform_list(child, child_depth).items)
37
+ end
38
+ end
39
+
40
+ case list.marker_type
41
+ when 'ordered'
42
+ Coradoc::AsciiDoc::Model::List::Ordered.new(items: items)
43
+ when 'definition'
44
+ Coradoc::AsciiDoc::Model::List::Definition.new(items: items)
45
+ else
46
+ Coradoc::AsciiDoc::Model::List::Unordered.new(items: items)
47
+ end
48
+ end
49
+
50
+ # Inline children render on the item line; CoreModel::Block
51
+ # children become attached blocks (`+` continuations) instead of
52
+ # being glued into the item text.
53
+ def inline_text(item)
54
+ case (rc = item.renderable_content)
55
+ when String then rc
56
+ when Array
57
+ rc.each_with_object(+'') do |child, out|
58
+ next if child.is_a?(CoreModel::Block) || child.is_a?(CoreModel::ListBlock)
59
+
60
+ out << case child
61
+ when CoreModel::TextContent then child.text.to_s
62
+ when String then child
63
+ when CoreModel::Base then child.flat_text.to_s
64
+ end
65
+ end
66
+ else
67
+ ''
68
+ end
69
+ end
70
+
71
+ def attached_blocks(item)
72
+ Array(item.children)
73
+ .grep(CoreModel::Block)
74
+ .flat_map { |block| FromCoreModel.flatten_children([block]) }
75
+ end
76
+
77
+ def default_marker(marker_type)
78
+ case marker_type
79
+ when 'ordered' then '.'
80
+ else '*'
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -5,6 +5,8 @@ module Coradoc
5
5
  module Transform
6
6
  # Transforms CoreModel to AsciiDoc models
7
7
  class FromCoreModel
8
+ extend Transform::ListBuilding
9
+
8
10
  @registered = false
9
11
 
10
12
  class << self
@@ -216,24 +218,11 @@ module Coradoc
216
218
  )
217
219
  end
218
220
 
219
- def transform_list(list)
220
- items = Array(list.items).map do |item|
221
- Coradoc::AsciiDoc::Model::List::Item.new(
222
- content: item.flat_text,
223
- marker: item.marker || default_marker(list.marker_type)
224
- )
225
- end
226
-
227
- case list.marker_type
228
- when 'ordered'
229
- Coradoc::AsciiDoc::Model::List::Ordered.new(items: items)
230
- when 'definition'
231
- Coradoc::AsciiDoc::Model::List::Definition.new(items: items)
232
- else
233
- Coradoc::AsciiDoc::Model::List::Unordered.new(items: items)
234
- end
235
- end
236
-
221
+ # Lists nested inside list items carry depth markers so
222
+ # AsciiDoc reads them as nested rather than sibling lists —
223
+ # without this, item children other than text were dropped
224
+ # entirely (#68 territory). Nested lists keep their own
225
+ # marker type one level deeper.
237
226
  def transform_list_item(item)
238
227
  Coradoc::AsciiDoc::Model::List::Item.new(
239
228
  content: item.flat_text,
@@ -256,12 +245,95 @@ module Coradoc
256
245
  )
257
246
  end
258
247
 
259
- def transform_inline(inline)
248
+ # Nested inline structure lives in +children+ (e.g. an
249
+ # ItalicElement whose single child is a BoldElement);
250
+ # +content+ is only the flattened display string. Build the
251
+ # adoc model from children so nesting survives, and default
252
+ # to the constrained marker — the constrained/unstrained
253
+ # distinction is not carried in CoreModel, and constrained
254
+ # is the correct rendering for span-boundary emphasis (#159).
255
+ def inline_model(klass, inline, unconstrained: false)
256
+ content = if inline.children.nil? || inline.children.empty?
257
+ inline.content.to_s
258
+ else
259
+ create_text_elements(inline.children)
260
+ end
261
+
262
+ klass.new(content: content, unconstrained: unconstrained)
263
+ end
264
+
265
+ # `_*text*_` parses to Italic{Bold{text}}; Metanorma renders
266
+ # same-span emphasis correctly only with italic innermost
267
+ # (`*_text_*`, #159). When an italic's entire content is one
268
+ # bold (or a bold's one italic), rebuild as Bold outer /
269
+ # Italic inner, both constrained.
270
+ def swap_emphasis_nesting(inline, unconstrained: false)
271
+ children = Array(inline.children)
272
+ return nil unless children.size == 1
273
+
274
+ only = children.first
275
+ return nil unless only.is_a?(CoreModel::InlineElement)
276
+
277
+ inner_kids = Array(only.children)
278
+ return nil unless inner_kids.empty? || inner_kids.all?(CoreModel::TextContent)
279
+
280
+ outer_type = inline.resolve_format_type
281
+ inner_type = only.resolve_format_type
282
+ return nil unless %w[bold italic].include?(outer_type) && outer_type != inner_type &&
283
+ %w[bold italic].include?(inner_type)
284
+
285
+ classes = {
286
+ 'bold' => Coradoc::AsciiDoc::Model::Inline::Bold,
287
+ 'italic' => Coradoc::AsciiDoc::Model::Inline::Italic
288
+ }
289
+ inner = classes[outer_type].new(
290
+ content: only.children.nil? || only.children.empty? ? only.content.to_s : create_text_elements(only.children),
291
+ unconstrained: false
292
+ )
293
+ classes[inner_type].new(content: inner, unconstrained: unconstrained)
294
+ end
295
+
296
+ # AsciiDoc constrained markers only reparse as emphasis when
297
+ # the opening delimiter follows a boundary and the closing
298
+ # one precedes one. CoreModel does not carry the constraint,
299
+ # so derive it from the adjacent sibling text: an inline
300
+ # touching a word character on either side must serialize
301
+ # with the unconstrained form or the emphasis is lost on
302
+ # reparse (mid-word bolding is common in Word documents).
303
+ def unconstrained_flanks?(left, right)
304
+ left_ok = left.nil? || left.match?(/\s/)
305
+ right_ok = right.nil? || right.match?(/\s|[[:punct:]]/)
306
+ !(left_ok && right_ok)
307
+ end
308
+
309
+ def sibling_edge(items, index, direction)
310
+ item = items[index]
311
+ text = case item
312
+ when Coradoc::CoreModel::TextContent then item.text.to_s
313
+ when Coradoc::CoreModel::InlineElement then item.content.to_s
314
+ when String then item
315
+ else return nil
316
+ end
317
+ return nil if text.empty?
318
+
319
+ direction == :last ? text[-1] : text[0]
320
+ end
321
+
322
+ def transform_inline(inline, siblings: nil, index: 0)
323
+ left = siblings && index.positive? ? sibling_edge(siblings, index - 1, :last) : nil
324
+ right = siblings ? sibling_edge(siblings, index + 1, :first) : nil
325
+ unconstrained = unconstrained_flanks?(left, right)
260
326
  case inline.resolve_format_type
261
327
  when 'bold'
262
- Coradoc::AsciiDoc::Model::Inline::Bold.new(content: inline.content)
328
+ inline_model(Coradoc::AsciiDoc::Model::Inline::Bold, inline,
329
+ unconstrained: unconstrained)
263
330
  when 'italic'
264
- Coradoc::AsciiDoc::Model::Inline::Italic.new(content: inline.content)
331
+ # #159: italic directly wrapping only bold (`_*text*_`)
332
+ # re-serializes as Bold outer / Italic inner (`*_text_*`)
333
+ # for correct Metanorma rendering.
334
+ swap_emphasis_nesting(inline, unconstrained: unconstrained) ||
335
+ inline_model(Coradoc::AsciiDoc::Model::Inline::Italic, inline,
336
+ unconstrained: unconstrained)
265
337
  when 'monospace'
266
338
  Coradoc::AsciiDoc::Model::Inline::Monospace.new(content: inline.content)
267
339
  when 'highlight'
@@ -546,7 +618,13 @@ module Coradoc
546
618
  def create_text_elements(content)
547
619
  case content
548
620
  when Array
549
- content.map { |item| create_text_elements(item) }
621
+ content.each_with_index.map do |item, i|
622
+ if item.is_a?(Coradoc::CoreModel::InlineElement)
623
+ transform_inline(item, siblings: content, index: i)
624
+ else
625
+ create_text_elements(item)
626
+ end
627
+ end
550
628
  when Coradoc::CoreModel::InlineElement
551
629
  transform_inline(content)
552
630
  when Coradoc::CoreModel::TextContent
@@ -577,13 +655,6 @@ module Coradoc
577
655
  attrs['language'] = block.language if block.language
578
656
  attrs
579
657
  end
580
-
581
- def default_marker(marker_type)
582
- case marker_type
583
- when 'ordered' then '.'
584
- else '*'
585
- end
586
- end
587
658
  end
588
659
 
589
660
  def transform(model) = self.class.transform(model)
@@ -59,10 +59,11 @@ module Coradoc
59
59
  # @return [Hash{String=>String}]
60
60
  def attributes_from_block(block)
61
61
  result = {}
62
- return result unless block&.data
62
+ data = CoreModel::FrontmatterBlock::Codec.to_hash(block)
63
+ return result if data.empty?
63
64
 
64
65
  MAPPINGS.each do |(_, front_key, _)|
65
- value = block.data[front_key]
66
+ value = data[front_key]
66
67
  next if value.nil?
67
68
 
68
69
  attr_name = front_key_to_attribute(front_key)