slamdown 0.5.0

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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/changelog.md +7 -0
  3. data/lib/slamdown/conversion/configuration.rb +35 -0
  4. data/lib/slamdown/conversion/configuration_validator.rb +235 -0
  5. data/lib/slamdown/conversion/converter.rb +98 -0
  6. data/lib/slamdown/conversion/normalisation.rb +21 -0
  7. data/lib/slamdown/conversion/normalised_node.rb +40 -0
  8. data/lib/slamdown/conversion/normaliser/breaks.rb +104 -0
  9. data/lib/slamdown/conversion/normaliser/content.rb +174 -0
  10. data/lib/slamdown/conversion/normaliser/correction_diagnostics.rb +29 -0
  11. data/lib/slamdown/conversion/normaliser/element_actions.rb +423 -0
  12. data/lib/slamdown/conversion/normaliser/heading_levels.rb +45 -0
  13. data/lib/slamdown/conversion/normaliser/inline_semantics.rb +218 -0
  14. data/lib/slamdown/conversion/normaliser/node_factory.rb +112 -0
  15. data/lib/slamdown/conversion/normaliser/url_rewriter.rb +124 -0
  16. data/lib/slamdown/conversion/normaliser.rb +58 -0
  17. data/lib/slamdown/conversion/policy.rb +178 -0
  18. data/lib/slamdown/conversion/prepared_node.rb +23 -0
  19. data/lib/slamdown/conversion/preparer.rb +122 -0
  20. data/lib/slamdown/conversion/renderer.rb +432 -0
  21. data/lib/slamdown/conversion/rendering/entity_encoder.rb +56 -0
  22. data/lib/slamdown/conversion/rendering/escaper.rb +58 -0
  23. data/lib/slamdown/conversion/rendering/html.rb +183 -0
  24. data/lib/slamdown/conversion/rendering/result.rb +23 -0
  25. data/lib/slamdown/conversion/rendering/strategy.rb +157 -0
  26. data/lib/slamdown/conversion/semantic_extractor.rb +97 -0
  27. data/lib/slamdown/conversion/tables.rb +676 -0
  28. data/lib/slamdown/conversion.rb +34 -0
  29. data/lib/slamdown/diagnostic.rb +34 -0
  30. data/lib/slamdown/document.rb +107 -0
  31. data/lib/slamdown/errors.rb +25 -0
  32. data/lib/slamdown/immutable.rb +23 -0
  33. data/lib/slamdown/output.rb +30 -0
  34. data/lib/slamdown/parsing/html.rb +227 -0
  35. data/lib/slamdown/parsing/node.rb +50 -0
  36. data/lib/slamdown/parsing/parsed_node_adapter.rb +144 -0
  37. data/lib/slamdown/parsing.rb +17 -0
  38. data/lib/slamdown/version.rb +3 -0
  39. data/lib/slamdown.rb +10 -0
  40. data/readme.md +121 -0
  41. metadata +174 -0
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ class Normaliser
7
+ # Owns format-neutral whitespace, phrasing-run and content-inspection rules shared by structural transformations.
8
+ class Content
9
+ HTML_WHITESPACE = /[\t\n\f\r ]+/.freeze
10
+ LEADING_SPACE = /\A +/.freeze
11
+ TRAILING_SPACE = / +\z/.freeze
12
+ BLOCK_SEMANTICS = [
13
+ :paragraph, :heading, :blockquote, :ordered_list, :unordered_list, :list_item, :definition_list, :definition_term, :definition_description,
14
+ :horizontal_rule, :code_block, :table, :table_head, :table_body, :table_foot, :table_row, :table_cell, :table_caption, :table_colgroup, :table_column
15
+ ].freeze
16
+
17
+ def initialize(node_factory)
18
+ @node_factory = node_factory
19
+ end
20
+
21
+ def normalise_text(value)
22
+ value.to_s.gsub("\u00A0", " ").gsub(HTML_WHITESPACE, " ")
23
+ end
24
+
25
+ # Removes editor-layout whitespace around code while retaining meaningful indentation in already-left-aligned blocks.
26
+ def normalise_code_block(value)
27
+ lines = value.to_s.gsub("\r\n", "\n").gsub("\r", "\n").split("\n", -1)
28
+ lines.shift while lines.first && lines.first.strip.empty?
29
+ lines.pop while lines.last && lines.last.strip.empty?
30
+ return "" if lines.empty?
31
+
32
+ lines.map! { |line| line.sub(/[\t ]+\z/, "") }
33
+ prefixes = lines.reject { |line| line.empty? }.map { |line| line[/\A[\t ]*/] }
34
+ if !prefixes.empty? && prefixes.none?(&:empty?)
35
+ width = prefixes.map { |prefix| indentation_width(prefix) }.min
36
+ lines.map! { |line| remove_indentation(line, width) }
37
+ end
38
+ lines.join("\n")
39
+ end
40
+
41
+ # Splits inline runs around block children so a phrasing-only block can be closed and reopened canonically.
42
+ def split_around_blocks(nodes)
43
+ result = []
44
+ run = []
45
+ flush = lambda do
46
+ result << run unless run.empty?
47
+ run = []
48
+ end
49
+
50
+ nodes.each do |node|
51
+ if block_node?(node)
52
+ flush.call
53
+ result << node
54
+ else
55
+ run << node
56
+ end
57
+ end
58
+ flush.call
59
+ result
60
+ end
61
+
62
+ def normalise_inline_sequence(nodes, trim:)
63
+ result = []
64
+ nodes.each do |node|
65
+ if node.kind == :text && result.last && result.last.kind == :text
66
+ result[-1] = result.last.with(:value => normalise_text(result.last.value + node.value))
67
+ else
68
+ result << node
69
+ end
70
+ end
71
+
72
+ if trim
73
+ result[0] = result.first.with(:value => result.first.value.sub(LEADING_SPACE, "")) if result.first && result.first.kind == :text
74
+ result[-1] = result.last.with(:value => result.last.value.sub(TRAILING_SPACE, "")) if result.last && result.last.kind == :text
75
+ end
76
+ result.reject { |node| node.kind == :text && node.value.empty? }
77
+ end
78
+
79
+ def wrap_phrasing_runs(nodes)
80
+ result = []
81
+ run = []
82
+ flush = lambda do
83
+ run = normalise_inline_sequence(run, :trim => true)
84
+ result << @node_factory.paragraph(run, run.first.source_path) if visible_content?(run)
85
+ run = []
86
+ end
87
+
88
+ nodes.each do |node|
89
+ if node.semantic_kind == :paragraph_boundary
90
+ flush.call
91
+ elsif block_node?(node)
92
+ flush.call
93
+ result << node
94
+ else
95
+ run << node
96
+ end
97
+ end
98
+ flush.call
99
+ result
100
+ end
101
+
102
+ def plain_text(nodes)
103
+ nodes.map do |node|
104
+ if node.kind == :text
105
+ node.value
106
+ elsif node.kind == :entity && node.value.is_a?(Hash)
107
+ node.value[:character].to_s
108
+ elsif node.value.is_a?(String)
109
+ node.value
110
+ else
111
+ plain_text(node.children)
112
+ end
113
+ end.join
114
+ end
115
+
116
+ def textual_content?(nodes)
117
+ return true unless plain_text(nodes).strip.empty?
118
+
119
+ nodes.any? do |node|
120
+ image_alt = node.semantic_kind == :image ? Hash[node.attributes].fetch("alt", "").to_s : nil
121
+ [:typographic_symbol, :smart_quote].include?(node.kind) || !image_alt.to_s.strip.empty? || textual_content?(node.children)
122
+ end
123
+ end
124
+
125
+ def visible_content?(nodes)
126
+ nodes.any? do |node|
127
+ if node.kind == :text
128
+ !node.value.strip.empty?
129
+ elsif [:entity, :typographic_symbol, :smart_quote].include?(node.kind)
130
+ true
131
+ elsif [:line_break, :horizontal_rule, :paragraph_boundary].include?(node.semantic_kind)
132
+ false
133
+ else
134
+ textual_content?([node]) || [:image, :code_span].include?(node.semantic_kind) || node.representation == :html
135
+ end
136
+ end
137
+ end
138
+
139
+ def descendants(nodes)
140
+ nodes.flat_map { |node| [node] + descendants(node.children) }
141
+ end
142
+
143
+ def whitespace_text?(node)
144
+ node.kind == :text && node.value.strip.empty?
145
+ end
146
+
147
+ def block_node?(node)
148
+ node.category == :block || BLOCK_SEMANTICS.include?(node.semantic_kind)
149
+ end
150
+
151
+ private
152
+
153
+ # Tabs and complete four-space runs have equal indentation width, but a tab is never partially removed.
154
+ def indentation_width(value)
155
+ value.each_char.inject(0) { |width, character| width + (character == "\t" ? 4 : 1) }
156
+ end
157
+
158
+ def remove_indentation(line, width)
159
+ removed = 0
160
+ index = 0
161
+ while index < line.length && [" ", "\t"].include?(line[index])
162
+ character_width = line[index] == "\t" ? 4 : 1
163
+ break if removed + character_width > width
164
+
165
+ removed += character_width
166
+ index += 1
167
+ end
168
+ line[index..-1] || ""
169
+ end
170
+ end
171
+ end
172
+
173
+ end
174
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ class Normaliser
7
+ # Collects ordinary corrective observations for one normalisation run without invoking Ruby's warning mechanism.
8
+ class CorrectionDiagnostics
9
+ def initialize
10
+ @diagnostics = []
11
+ end
12
+
13
+ def add(code, severity, message, path)
14
+ @diagnostics << Diagnostic.new(
15
+ :code => code,
16
+ :severity => severity,
17
+ :message => message,
18
+ :node_path => path
19
+ )
20
+ end
21
+
22
+ def to_a
23
+ @diagnostics.dup
24
+ end
25
+ end
26
+ end
27
+
28
+ end
29
+ end
@@ -0,0 +1,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ class Normaliser
7
+ # Recursively applies resolved source-element actions and delegates specialised inline and break corrections to their focused collaborators.
8
+ class ElementActions
9
+ INTEGER_ATTRIBUTE = /\A[+-]?\d+\z/.freeze
10
+ ORDERED_LIST_START_RANGE = (2..100_000).freeze
11
+ TABLE_SEMANTICS = {
12
+ "caption" => :table_caption,
13
+ "colgroup" => :table_colgroup,
14
+ "col" => :table_column
15
+ }.freeze
16
+ SOURCE_SEMANTICS = {
17
+ "p" => :paragraph,
18
+ "blockquote" => :blockquote,
19
+ "ol" => :ordered_list,
20
+ "ul" => :unordered_list,
21
+ "li" => :list_item,
22
+ "dl" => :definition_list,
23
+ "dt" => :definition_term,
24
+ "dd" => :definition_description,
25
+ "details" => :definition_list,
26
+ "summary" => :definition_term,
27
+ "hr" => :horizontal_rule,
28
+ "br" => :line_break,
29
+ "code" => :code_span,
30
+ "kbd" => :code_span,
31
+ "samp" => :code_span,
32
+ "tt" => :code_span,
33
+ "var" => :code_span,
34
+ "pre" => :code_block,
35
+ "a" => :link,
36
+ "img" => :image,
37
+ "em" => :emphasis,
38
+ "i" => :emphasis,
39
+ "strong" => :strong,
40
+ "b" => :strong,
41
+ "s" => :deletion,
42
+ "strike" => :deletion,
43
+ "del" => :deletion,
44
+ "table" => :table,
45
+ "thead" => :table_head,
46
+ "tbody" => :table_body,
47
+ "tfoot" => :table_foot,
48
+ "tr" => :table_row,
49
+ "td" => :table_cell,
50
+ "th" => :table_cell
51
+ }.merge(TABLE_SEMANTICS).freeze
52
+
53
+ def initialize(configuration, node_factory, content, breaks, diagnostics)
54
+ @configuration = configuration
55
+ @node_factory = node_factory
56
+ @content = content
57
+ @breaks = breaks
58
+ @diagnostics = diagnostics
59
+ @policy = Policy::Resolver.new(configuration)
60
+ url_rewriter = UrlRewriter.new(configuration.urls)
61
+ @inline_semantics = InlineSemantics.new(@policy, node_factory, content, breaks, url_rewriter, diagnostics)
62
+ end
63
+
64
+ def normalise_root(root)
65
+ if root.kind == :document
66
+ normalise_children(root.children, [], @configuration.inline)
67
+ else
68
+ normalise_node(root, [], @configuration.inline)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def normalise_children(children, parent_path, force_inline, flatten_blocks = false)
75
+ children.each_with_index.each_with_object([]) do |(child, index), result|
76
+ result.concat(normalise_node(child, parent_path + [index], force_inline, flatten_blocks))
77
+ end
78
+ end
79
+
80
+ def normalise_node(node, path, force_inline, flatten_blocks = false)
81
+ return normalise_element(node, path, force_inline, flatten_blocks) if node.kind == :element
82
+
83
+ case node.kind
84
+ when :text
85
+ value = @content.normalise_text(node.value)
86
+ value.empty? ? [] : [@node_factory.text(value, path)]
87
+ when :entity
88
+ normalise_entity(node, path)
89
+ when :typographic_symbol, :smart_quote
90
+ [@node_factory.copy_leaf(node, path)]
91
+ when :raw, :comment, :processing_instruction
92
+ []
93
+ else
94
+ normalise_children(node.children, path, force_inline, flatten_blocks)
95
+ end
96
+ end
97
+
98
+ def normalise_element(node, path, force_inline, flatten_blocks)
99
+ action = node.action ? node.action.value : :convert
100
+ return [] if action == :drop
101
+ return inline_contents(node, path) if action == :inline
102
+ if action == :convert && converted_semantic_kind(node) == :table && !table_has_usable_cell?(node)
103
+ @diagnostics.add(:unrepresentable_table_dropped, :info, "Dropped a table without any usable rows and cells", path)
104
+ return force_inline ? [@node_factory.text(" ", path)] : [@node_factory.paragraph_boundary(path)]
105
+ end
106
+ return [@node_factory.text(" ", path)] if force_inline && node.source_name == "br"
107
+ return flatten_block(node, path, action) if force_inline && block_output?(node, action)
108
+ return flatten_block(node, path, action) if flatten_blocks && block_output?(node, action)
109
+
110
+ children = normalise_children(node.children, path, force_inline, flatten_blocks || converted_semantic_kind(node) == :heading)
111
+ case action
112
+ when :preserve
113
+ normalise_preserved(node, children, path)
114
+ when :unwrap
115
+ node.category == :block ? @content.wrap_phrasing_runs(children) : children
116
+ when :convert
117
+ convert_node(node, children, path, force_inline)
118
+ else
119
+ raise "unhandled element action #{action.inspect}"
120
+ end
121
+ end
122
+
123
+ def convert_node(node, children, path, force_inline)
124
+ case node.source_name
125
+ when "div"
126
+ result = @content.wrap_phrasing_runs(children)
127
+ result.empty? && !force_inline ? [@node_factory.paragraph_boundary(path)] : result
128
+ when "span"
129
+ @inline_semantics.normalise_span(node, children, path)
130
+ else
131
+ convert_semantic_node(node, converted_semantic_kind(node), children, path, force_inline)
132
+ end
133
+ end
134
+
135
+ def convert_semantic_node(node, semantic_kind, children, path, force_inline)
136
+ case semantic_kind
137
+ when :paragraph
138
+ normalise_paragraph(node, children, path)
139
+ when :heading
140
+ normalise_heading(node, children, path)
141
+ when :emphasis, :strong
142
+ @inline_semantics.normalise_emphasis(node, semantic_kind, children, path)
143
+ when :deletion
144
+ node.source_name == "s" ? @inline_semantics.normalise_generic(node, semantic_kind, children, path) : @inline_semantics.trim_wrapper_whitespace(@node_factory.semantic(node, semantic_kind, children, path))
145
+ when :link
146
+ @inline_semantics.normalise_link(node, children, path)
147
+ when :image
148
+ @inline_semantics.normalise_image(node, path)
149
+ when :code_span
150
+ normalise_code_span(node, path)
151
+ when :code_block
152
+ value = @content.normalise_code_block(code_source(node))
153
+ [@node_factory.semantic(node, semantic_kind, [], path, :value => value)]
154
+ when :line_break
155
+ force_inline ? [@node_factory.text(" ", path)] : [@node_factory.semantic(node, semantic_kind, [], path)]
156
+ when :horizontal_rule
157
+ force_inline ? [] : [@node_factory.semantic(node, semantic_kind, [], path)]
158
+ when :ordered_list, :unordered_list
159
+ normalise_list(node, semantic_kind, children, path)
160
+ when :definition_list
161
+ node.source_name == "details" ? normalise_details(node, children, path) : [@node_factory.semantic(node, semantic_kind, children, path)]
162
+ when :list_item
163
+ @content.visible_content?(children) ? [@node_factory.semantic(node, semantic_kind, children, path)] : []
164
+ else
165
+ if node.category == :span
166
+ @inline_semantics.normalise_generic(node, semantic_kind, children, path)
167
+ else
168
+ children = children.reject { |child| @content.whitespace_text?(child) } if children.any? { |child| @content.block_node?(child) }
169
+ [@node_factory.semantic(node, semantic_kind, children, path)]
170
+ end
171
+ end
172
+ end
173
+
174
+ def normalise_paragraph(node, children, path)
175
+ retained_id = false
176
+ @content.split_around_blocks(children).each_with_object([]) do |part, result|
177
+ if part.is_a?(NormalisedNode)
178
+ result << part
179
+ next
180
+ end
181
+
182
+ @breaks.split_break_runs(part).each do |fragment|
183
+ fragment = @content.normalise_inline_sequence(fragment, :trim => true)
184
+ next unless @content.visible_content?(fragment)
185
+
186
+ if horizontal_rule_content?(fragment)
187
+ @diagnostics.add(:horizontal_rule_recovered, :info, "Converted a repeated-marker paragraph to a horizontal rule", path)
188
+ result << @node_factory.semantic(node, :horizontal_rule, [], path)
189
+ else
190
+ result << normalised_paragraph(node, fragment, path, !retained_id)
191
+ retained_id = true
192
+ end
193
+ end
194
+ end
195
+ end
196
+
197
+ def normalised_paragraph(node, fragment, path, retain_id)
198
+ paragraph_id = retain_id ? node.id : nil
199
+ if fragment.length == 1 && fragment.first.semantic_kind == :code_span && fragment.first.source_name == "code" && fragment.first.properties[:code_source_has_break]
200
+ code = fragment.first
201
+ return code.with(
202
+ :semantic_kind => :code_block,
203
+ :category => :block,
204
+ :value => @content.normalise_code_block(code.properties.fetch(:code_source)),
205
+ :id => paragraph_id || code.id,
206
+ :classes => (code.classes + node.classes).uniq
207
+ )
208
+ end
209
+ return @node_factory.semantic(node, :paragraph, fragment, path, :id => paragraph_id) unless fragment.length == 1 && fragment.first.semantic_kind == :code_span
210
+ return @node_factory.semantic(node, :paragraph, fragment, path, :id => paragraph_id) unless paragraph_id || !node.classes.empty?
211
+
212
+ code = fragment.first.with(
213
+ :id => paragraph_id || fragment.first.id,
214
+ :classes => (fragment.first.classes + node.classes).uniq
215
+ )
216
+ @node_factory.semantic(node, :paragraph, [code], path, :id => nil, :classes => [])
217
+ end
218
+
219
+ def normalise_heading(node, children, path)
220
+ fragments = @breaks.split_inline_fragments(children).map { |fragment| @content.normalise_inline_sequence(fragment, :trim => true) }
221
+ fragments.select! { |fragment| @content.textual_content?(fragment) }
222
+ if fragments.empty?
223
+ @diagnostics.add(:blank_heading_removed, :info, "Removed a heading without non-blank textual content", path)
224
+ return []
225
+ end
226
+
227
+ properties = node.properties.merge(:source_level => node.properties.fetch(:level))
228
+ fragments.map { |fragment| @node_factory.semantic(node, :heading, fragment, path, :properties => properties) }
229
+ end
230
+
231
+ def normalise_ordered_list(node, children, path)
232
+ start = Hash[node.attributes]["start"]
233
+ properties = node.properties
234
+ attributes = node.attributes
235
+ parsed_start = start.to_i if start.is_a?(String) && INTEGER_ATTRIBUTE.match(start.strip)
236
+ if parsed_start && ORDERED_LIST_START_RANGE.include?(parsed_start)
237
+ properties = properties.merge(:start => parsed_start)
238
+ elsif start
239
+ # Implausible or malformed list starts are editor debris, so remove the attribute and use the normal starting number.
240
+ attributes = attributes.reject { |name, _value| name == "start" }
241
+ @diagnostics.add(:ordered_list_start_removed, :info, "Removed an implausible ordered-list start", path)
242
+ end
243
+ @node_factory.semantic(node, :ordered_list, children, path, :properties => properties, :attributes => attributes)
244
+ end
245
+
246
+ def normalise_list(node, semantic_kind, children, path)
247
+ result = []
248
+ segment = []
249
+ items = nil
250
+ flush = lambda do
251
+ unless segment.empty?
252
+ if items
253
+ list = semantic_kind == :ordered_list ? normalise_ordered_list(node, segment, path) : @node_factory.semantic(node, semantic_kind, segment, path)
254
+ result << list
255
+ else
256
+ result.concat(@content.wrap_phrasing_runs(segment))
257
+ end
258
+ end
259
+ segment = []
260
+ end
261
+
262
+ children.each do |child|
263
+ list_item = child.semantic_kind == :list_item
264
+ next if !list_item && @content.whitespace_text?(child)
265
+ if !items.nil? && items != list_item
266
+ flush.call
267
+ end
268
+ items = list_item
269
+ segment << child
270
+ end
271
+ flush.call
272
+ result
273
+ end
274
+
275
+ def normalise_details(node, children, path)
276
+ children = children.reject { |child| @content.whitespace_text?(child) }
277
+ descriptions = children.map do |child|
278
+ if child.semantic_kind == :definition_term
279
+ child
280
+ else
281
+ @node_factory.build(
282
+ :kind => :element,
283
+ :semantic_kind => :definition_description,
284
+ :category => :block,
285
+ :children => [child],
286
+ :representation => :semantic,
287
+ :source_path => child.source_path
288
+ )
289
+ end
290
+ end
291
+ [@node_factory.semantic(node, :definition_list, descriptions, path)]
292
+ end
293
+
294
+ def normalise_code_span(node, path)
295
+ value = @content.normalise_text(code_source(node)).strip
296
+ value.empty? ? [] : [@node_factory.semantic(node, :code_span, [], path, :value => value)]
297
+ end
298
+
299
+ def code_source(node)
300
+ node.properties.fetch(:code_source, node.value.to_s)
301
+ end
302
+
303
+ def inline_contents(node, path)
304
+ children = normalise_children(node.children, path, true)
305
+ if children.empty? && node.value.is_a?(String) && !node.value.empty?
306
+ children = [@node_factory.text(@content.normalise_text(node.value), path)]
307
+ end
308
+ @content.normalise_inline_sequence(children, :trim => false)
309
+ end
310
+
311
+ def flatten_block(node, path, action)
312
+ semantic_kind = converted_semantic_kind(node)
313
+ return [] if semantic_kind == :horizontal_rule
314
+
315
+ if action == :convert && semantic_kind == :code_block
316
+ value = @content.normalise_text(node.value.to_s)
317
+ content = value.empty? ? [] : [@node_factory.build(
318
+ :kind => :element,
319
+ :source_name => node.source_name,
320
+ :semantic_kind => :code_span,
321
+ :category => :span,
322
+ :value => value,
323
+ :semantic_values => node.semantic_values,
324
+ :representation => :semantic,
325
+ :source_path => path
326
+ )]
327
+ else
328
+ content = normalise_children(node.children, path, true)
329
+ if content.empty? && node.value.is_a?(String) && !node.value.empty?
330
+ content = [@node_factory.text(@content.normalise_text(node.value), path)]
331
+ end
332
+ end
333
+ return [] unless @content.visible_content?(content)
334
+
335
+ [@node_factory.text(" ", path)] + content + [@node_factory.text(" ", path)]
336
+ end
337
+
338
+ def normalise_preserved(node, children, path)
339
+ semantic_kind = converted_semantic_kind(node)
340
+ if semantic_kind == :code_span
341
+ return [@node_factory.preserved(node, semantic_kind, [], path).with(:value => code_source(node))]
342
+ end
343
+ if node.category == :span || (semantic_kind && !Content::BLOCK_SEMANTICS.include?(semantic_kind))
344
+ return [@node_factory.preserved(node, semantic_kind, [], path)] if children.empty?
345
+
346
+ return @inline_semantics.wrap_inline_structure(children, path) do |fragment|
347
+ preserved = @node_factory.preserved(node, semantic_kind, fragment, path)
348
+ if semantic_kind == :deletion
349
+ @inline_semantics.trim_wrapper_whitespace(preserved)
350
+ elsif [:emphasis, :strong].include?(semantic_kind)
351
+ @inline_semantics.normalise_wrapper_whitespace(preserved, path)
352
+ else
353
+ [preserved]
354
+ end
355
+ end
356
+ end
357
+ unless children.any? { |child| @breaks.line_break?(child) || @breaks.paragraph_boundary?(child) }
358
+ preserved = @node_factory.preserved(node, semantic_kind, children, path)
359
+ return [preserved]
360
+ end
361
+ if node.category != :span
362
+ return @breaks.split_inline_fragments(children).map { |fragment| @node_factory.preserved(node, semantic_kind, fragment, path) }
363
+ end
364
+
365
+ @breaks.wrap_inline_fragments(children, path) do |fragment|
366
+ [@node_factory.preserved(node, semantic_kind, fragment, path)]
367
+ end
368
+ end
369
+
370
+ def normalise_entity(node, path)
371
+ return [@node_factory.text(" ", path)] if node.value.is_a?(Hash) && node.value[:code_point] == 160
372
+
373
+ [@node_factory.copy_leaf(node, path)]
374
+ end
375
+
376
+ def horizontal_rule_content?(children)
377
+ markers = @content.plain_text(children).gsub(/\s+/, "")
378
+ markers.length >= 3 && ["-", "*", "_"].include?(markers[0]) && markers.chars.all? { |marker| marker == markers[0] }
379
+ end
380
+
381
+ def converted_semantic_kind(node)
382
+ return :heading if node.source_name && /\Ah[1-6]\z/.match(node.source_name)
383
+ return SOURCE_SEMANTICS[node.source_name] if SOURCE_SEMANTICS.key?(node.source_name)
384
+
385
+ node.semantic_kind
386
+ end
387
+
388
+ def table_has_usable_cell?(table)
389
+ table.children.any? { |child| table_branch_has_usable_cell?(child) }
390
+ end
391
+
392
+ def table_branch_has_usable_cell?(node)
393
+ semantic_kind = converted_semantic_kind(node)
394
+ return false if semantic_kind == :table
395
+ return usable_table_row?(node) if semantic_kind == :table_row
396
+ return false if [:drop, :inline].include?(action_value(node))
397
+
398
+ node.children.any? { |child| table_branch_has_usable_cell?(child) }
399
+ end
400
+
401
+ def usable_table_row?(row)
402
+ return false unless [:convert, :preserve].include?(action_value(row))
403
+
404
+ row.children.any? do |cell|
405
+ converted_semantic_kind(cell) == :table_cell && [:convert, :preserve].include?(action_value(cell))
406
+ end
407
+ end
408
+
409
+ def action_value(node)
410
+ node.action ? node.action.value : :convert
411
+ end
412
+
413
+ def block_output?(node, action)
414
+ return true if node.category == :block
415
+ return false unless action == :convert
416
+
417
+ Content::BLOCK_SEMANTICS.include?(converted_semantic_kind(node))
418
+ end
419
+ end
420
+ end
421
+
422
+ end
423
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ class Normaliser
7
+ # Calculates one conversion-root heading map after blank and policy-removed headings have left the tree.
8
+ class HeadingLevels
9
+ def initialize(configuration, content, diagnostics)
10
+ @configuration = configuration
11
+ @content = content
12
+ @diagnostics = diagnostics
13
+ end
14
+
15
+ def adjust(children)
16
+ headings = @content.descendants(children).select { |node| node.representation == :semantic && node.semantic_kind == :heading }
17
+ return children if headings.empty?
18
+
19
+ source_levels = headings.map { |heading| heading.properties[:source_level] }.uniq.sort
20
+ provisional_levels = source_levels.each_with_index.each_with_object({}) do |(level, index), result|
21
+ result[level] = @configuration.headings[:compact] ? index + 1 : level
22
+ end
23
+ offset = @configuration.headings[:start] - provisional_levels.values.min
24
+
25
+ children.map { |child| rewrite(child, provisional_levels, offset) }
26
+ end
27
+
28
+ private
29
+
30
+ def rewrite(node, provisional_levels, offset)
31
+ children = node.children.map { |child| rewrite(child, provisional_levels, offset) }
32
+ return node.with(:children => children) unless node.representation == :semantic && node.semantic_kind == :heading
33
+
34
+ source_level = node.properties[:source_level]
35
+ level = [[provisional_levels.fetch(source_level) + offset, 1].max, 6].min
36
+ if level != source_level
37
+ @diagnostics.add(:heading_level_adjusted, :info, "Adjusted heading level from #{source_level} to #{level}", node.source_path)
38
+ end
39
+ node.with(:children => children, :properties => node.properties.merge(:level => level))
40
+ end
41
+ end
42
+ end
43
+
44
+ end
45
+ end