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,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ # Captures parser facts together with the immutable, conversion-specific decisions needed by normalisation and rendering.
7
+ class PreparedNode
8
+ PARSER_ATTRIBUTES = [:kind, :source_name, :semantic_kind, :category, :content_model, :value, :properties, :source_form, :synthetic].freeze
9
+ PREPARATION_ATTRIBUTES = [:action, :attributes, :attribute_policies, :classes, :class_policies, :id, :id_policy, :semantic_values, :children].freeze
10
+ ATTRIBUTES = (PARSER_ATTRIBUTES + PREPARATION_ATTRIBUTES).freeze
11
+
12
+ attr_reader(*ATTRIBUTES)
13
+
14
+ def initialize(values)
15
+ ATTRIBUTES.each do |attribute|
16
+ instance_variable_set("@#{attribute}", Immutable.copy(values.fetch(attribute)))
17
+ end
18
+ freeze
19
+ end
20
+ end
21
+
22
+ end
23
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ # Produces a fresh prepared tree by combining one parsed tree with one validated conversion configuration.
7
+ class Preparer
8
+ HTML_WHITESPACE = /[\t\n\f\r ]+/.freeze
9
+
10
+ class << self
11
+ def prepare(root, configuration)
12
+ new(configuration).prepare(root)
13
+ end
14
+ end
15
+
16
+ def initialize(configuration)
17
+ raise ArgumentError, "configuration must be a Slamdown conversion configuration" unless configuration.is_a?(Configuration)
18
+
19
+ @policy = Policy::Resolver.new(configuration)
20
+ @semantic_extractor = SemanticExtractor.new
21
+ end
22
+
23
+ def prepare(root)
24
+ raise ArgumentError, "root must be a parsed Slamdown node" unless root.is_a?(Parsing::Node)
25
+
26
+ prepare_node(root)
27
+ end
28
+
29
+ private
30
+
31
+ def prepare_node(node)
32
+ semantic_values = @semantic_extractor.extract(node)
33
+ attributes, attribute_policies = prepare_attributes(node, semantic_values)
34
+ classes, class_policies = prepare_classes(node, semantic_values)
35
+ id, id_policy = prepare_id(node)
36
+
37
+ values = parser_values(node).merge(
38
+ :action => @policy.action_for(node),
39
+ :attributes => attributes,
40
+ :attribute_policies => attribute_policies,
41
+ :classes => classes,
42
+ :class_policies => class_policies,
43
+ :id => id,
44
+ :id_policy => id_policy,
45
+ :semantic_values => semantic_values,
46
+ :children => node.children.map { |child| prepare_node(child) }
47
+ )
48
+
49
+ PreparedNode.new(values)
50
+ end
51
+
52
+ def parser_values(node)
53
+ PreparedNode::PARSER_ATTRIBUTES.each_with_object({}) do |attribute, result|
54
+ result[attribute] = node.public_send(attribute)
55
+ end
56
+ end
57
+
58
+ def prepare_attributes(node, semantic_values)
59
+ effective_attributes = node.attributes.reject { |name, _value| ["class", "id"].include?(name) }
60
+ if semantic_values.key?(:image_fallback_alt)
61
+ effective_attributes = effective_attributes + [["alt", semantic_values[:image_fallback_alt]]]
62
+ end
63
+
64
+ policies = {}
65
+ retained = effective_attributes.each_with_object([]) do |(name, value), result|
66
+ decision = @policy.attribute_for(node, name)
67
+ policies[name] = decision if decision
68
+ result << [name, decode_attribute(value)] if decision && decision.value == :preserve
69
+ end
70
+
71
+ [retained, policies]
72
+ end
73
+
74
+ def prepare_classes(node, semantic_values)
75
+ value = Hash[node.attributes]["class"]
76
+ return [[], {}] unless value.is_a?(String)
77
+
78
+ tokens = value.split(HTML_WHITESPACE).each_with_object([]) do |token, result|
79
+ result << token unless token.empty? || result.include?(token)
80
+ end
81
+ policies = {}
82
+ retained = tokens.each_with_object([]) do |token, result|
83
+ decision = @policy.class_for(node, token)
84
+ policies[token] = decision
85
+ consumed = semantic_values.key?(:code_language) && @semantic_extractor.consumed_class?(node, token)
86
+ result << token if decision.allowed? && !consumed
87
+ end
88
+
89
+ [retained, policies]
90
+ end
91
+
92
+ def prepare_id(node)
93
+ value = Hash[node.attributes]["id"]
94
+ return [nil, nil] unless value.is_a?(String)
95
+
96
+ decision = @policy.id_for(node, value)
97
+ valid = !value.empty? && !HTML_WHITESPACE.match(value)
98
+ [valid && decision.allowed? ? value : nil, decision]
99
+ end
100
+
101
+ def decode_attribute(value)
102
+ return value unless value.is_a?(String)
103
+
104
+ value.gsub(/&(?:#(\d+)|#x([0-9A-Fa-f]+)|([A-Za-z][A-Za-z0-9]+));/) do |reference|
105
+ begin
106
+ code_point = if $1
107
+ $1.to_i
108
+ elsif $2
109
+ $2.to_i(16)
110
+ else
111
+ Kramdown::Utils::Entities.entity($3).code_point
112
+ end
113
+ [code_point].pack("U")
114
+ rescue Kramdown::Error, RangeError
115
+ reference
116
+ end
117
+ end
118
+ end
119
+ end
120
+
121
+ end
122
+ end
@@ -0,0 +1,432 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ # Traverses the normalised tree through shared inline and block primitives, delegating only genuine format differences to a strategy.
7
+ class Renderer
8
+ ENTITY_KINDS = [:entity, :typographic_symbol, :smart_quote].freeze
9
+ LIST_KINDS = [:ordered_list, :unordered_list].freeze
10
+ FAILED_RENDERING = Object.new.freeze
11
+
12
+ class << self
13
+ def render(root, configuration, format)
14
+ new(configuration, format).render(root)
15
+ end
16
+ end
17
+
18
+ def initialize(configuration, format)
19
+ raise ArgumentError, "configuration must be a Slamdown conversion configuration" unless configuration.is_a?(Configuration)
20
+ raise ArgumentError, "format must be markdown or kramdown" unless ["markdown", "kramdown"].include?(format)
21
+
22
+ @configuration = configuration
23
+ @escaper = Rendering::Escaper.new
24
+ @entity_encoder = Rendering::EntityEncoder.new(configuration.entities)
25
+ @html = Rendering::Html.new(configuration, @entity_encoder)
26
+ @strategy = Rendering::Strategy.build(format)
27
+ @format = format
28
+ @diagnostics = []
29
+ end
30
+
31
+ def render(root)
32
+ raise ArgumentError, "root must be a normalised Slamdown document" unless root.is_a?(NormalisedNode) && root.kind == :document
33
+ @diagnostics = []
34
+
35
+ Rendering::Result.new(canonicalise(render_blocks(root.children)), @diagnostics)
36
+ end
37
+
38
+ # Renders a block sequence with exactly one blank line between neighbouring constructs.
39
+ def render_blocks(nodes)
40
+ parts = []
41
+ inline_run = []
42
+ flush = lambda do
43
+ unless inline_run.empty?
44
+ value = render_inlines(inline_run)
45
+ parts << value unless value.empty?
46
+ inline_run = []
47
+ end
48
+ end
49
+
50
+ nodes.each do |node|
51
+ if block_node?(node)
52
+ flush.call
53
+ value = safely_render(node) { render_block(node) }
54
+ parts << value unless value.equal?(FAILED_RENDERING) || value.empty?
55
+ else
56
+ inline_run << node
57
+ end
58
+ end
59
+ flush.call
60
+ parts.join("\n\n")
61
+ end
62
+
63
+ # Renders the children of structural containers whose parser shape may be inline or block-oriented.
64
+ def render_container_contents(node)
65
+ node.children.any? { |child| block_node?(child) } ? render_blocks(node.children) : render_inlines(node.children)
66
+ end
67
+
68
+ # Renders one inline sequence, with optional strong-marker suppression used by Markdown definition terms.
69
+ def render_inlines(nodes, suppress_strong: false, line_start: true)
70
+ result = ""
71
+ failed_rendering = false
72
+ nodes.each do |node|
73
+ value = safely_render(node) { render_inline(node, line_start && result.empty?, suppress_strong) }
74
+ if value.equal?(FAILED_RENDERING)
75
+ failed_rendering = true
76
+ next
77
+ end
78
+
79
+ # Dropping a failed inline node can make its surrounding text spaces newly adjacent, so restore the already-normalised boundary.
80
+ if failed_rendering
81
+ value = value.sub(/\A +/, "") if result.empty? || (result.end_with?(" ") && value.start_with?(" "))
82
+ failed_rendering = false
83
+ end
84
+ result += value
85
+ end
86
+ failed_rendering ? result.sub(/ +\z/, "") : result
87
+ end
88
+
89
+ private
90
+
91
+ def render_block(node)
92
+ return @html.render(node) if node.representation == :html
93
+
94
+ case node.semantic_kind
95
+ when :paragraph
96
+ @strategy.decorate_block(node, render_inlines(node.children))
97
+ when :heading
98
+ content = render_inlines(node.children, :line_start => false)
99
+ @strategy.decorate_block(node, "#{'#' * node.properties.fetch(:level)} #{content}")
100
+ when :blockquote
101
+ @strategy.decorate_block(node, render_blockquote(node))
102
+ when :ordered_list
103
+ @strategy.decorate_block(node, render_list(node, true))
104
+ when :unordered_list
105
+ @strategy.decorate_block(node, render_list(node, false))
106
+ when :definition_list
107
+ @strategy.render_definition_list(self, node)
108
+ when :horizontal_rule
109
+ @strategy.decorate_block(node, "---")
110
+ when :code_block
111
+ @strategy.decorate_block(node, render_code_block(node))
112
+ when :table
113
+ render_table(node)
114
+ else
115
+ render_container_contents(node)
116
+ end
117
+ end
118
+
119
+ def render_inline(node, line_start, suppress_strong)
120
+ return @escaper.text(node.value, :line_start => line_start, :escape_definition_marker => @strategy.escape_definition_marker?) if node.kind == :text
121
+ return render_entity(node, line_start) if ENTITY_KINDS.include?(node.kind)
122
+ return @html.render(node) if node.representation == :html
123
+
124
+ case node.semantic_kind
125
+ when :emphasis
126
+ content = render_inlines(node.children, :suppress_strong => suppress_strong, :line_start => false)
127
+ decorate_inline(node, "*#{content}*")
128
+ when :strong
129
+ content = render_inlines(node.children, :suppress_strong => suppress_strong, :line_start => false)
130
+ suppress_strong ? content : decorate_inline(node, "**#{content}**")
131
+ when :deletion, :line_break
132
+ @html.render(node)
133
+ when :code_span
134
+ content = node.value.to_s.strip
135
+ content.empty? ? "" : decorate_inline(node, render_code_span(content))
136
+ when :link
137
+ decorate_inline(node, render_link(node, suppress_strong))
138
+ when :image
139
+ decorate_inline(node, render_image(node))
140
+ else
141
+ if node.value.is_a?(String) && node.children.empty?
142
+ @escaper.text(node.value, :line_start => line_start, :escape_definition_marker => @strategy.escape_definition_marker?)
143
+ else
144
+ render_inlines(node.children, :suppress_strong => suppress_strong, :line_start => line_start)
145
+ end
146
+ end
147
+ end
148
+
149
+ def decorate_inline(node, syntax)
150
+ @strategy.decorate_inline(node, syntax)
151
+ end
152
+
153
+ def render_entity(node, line_start)
154
+ value = @entity_encoder.encode(node)
155
+ return value unless @entity_encoder.character_mode?
156
+
157
+ @escaper.text(value, :line_start => line_start, :escape_definition_marker => @strategy.escape_definition_marker?)
158
+ end
159
+
160
+ def render_link(node, suppress_strong)
161
+ attributes = Hash[node.attributes]
162
+ content = render_inlines(node.children, :suppress_strong => suppress_strong, :line_start => false)
163
+ destination = @escaper.destination(attributes.fetch("href"), :escape_ampersand => @strategy.escape_link_ampersands?)
164
+ "[#{content}](#{destination}#{render_title(attributes)})"
165
+ end
166
+
167
+ def render_image(node)
168
+ attributes = Hash[node.attributes]
169
+ alt = @escaper.label(attributes.fetch("alt", ""))
170
+ destination = @escaper.destination(attributes.fetch("src"), :escape_ampersand => @strategy.escape_link_ampersands?)
171
+ "![#{alt}](#{destination}#{render_title(attributes)})"
172
+ end
173
+
174
+ def render_title(attributes)
175
+ title = attributes["title"]
176
+ return "" unless title.is_a?(String) && !title.strip.empty?
177
+
178
+ " \"#{@escaper.title(title.strip, :escape_ampersand => @strategy.escape_link_ampersands?)}\""
179
+ end
180
+
181
+ def render_code_span(value)
182
+ content = value.gsub("\r\n", "\n").gsub("\r", "\n")
183
+ content = content.gsub(/\s+/, " ") if @configuration.inline
184
+ longest_run = content.scan(/`+/).map(&:length).max || 0
185
+ delimiter = "`" * [longest_run + 1, 1].max
186
+ padding_needed = content.start_with?("`") || content.end_with?("`") || (@strategy.pad_space_bounded_code_span? && surrounded_by_spaces?(content))
187
+ padding_needed ? "#{delimiter} #{content} #{delimiter}" : "#{delimiter}#{content}#{delimiter}"
188
+ end
189
+
190
+ def surrounded_by_spaces?(content)
191
+ content.start_with?(" ") && content.end_with?(" ") && !content.strip.empty?
192
+ end
193
+
194
+ def render_code_block(node)
195
+ content = node.value.to_s.gsub("\r\n", "\n").gsub("\r", "\n")
196
+ longest_run = content.scan(/~+/).map(&:length).max || 0
197
+ fence = "~" * [longest_run + 1, 3].max
198
+ language = node.semantic_values[:code_language].to_s
199
+ opening = language.empty? ? fence : fence + language
200
+ body = content.empty? || content.end_with?("\n") ? content : content + "\n"
201
+ "#{opening}\n#{body}#{fence}"
202
+ end
203
+
204
+ # Selects native pipe syntax only after structure, cell content and final padded width have proved representable.
205
+ def render_table(node)
206
+ analysis = Tables::Analyser.analyse(node)
207
+ if analysis.captions_merged?
208
+ @diagnostics << Diagnostic.new(
209
+ :code => :duplicate_table_captions_merged,
210
+ :severity => :info,
211
+ :message => "Merged duplicate plain table captions",
212
+ :node_path => node.source_path
213
+ )
214
+ end
215
+ html_table = analysis.table_for(@format)
216
+ return @html.render(html_table) if @configuration.tables[:format] == :html || analysis.html_required?
217
+
218
+ layout = analysis.layout(@format)
219
+ if layout.complexity_limited?
220
+ @diagnostics << Diagnostic.new(
221
+ :code => :table_complexity_limited,
222
+ :severity => :warning,
223
+ :message => table_complexity_message(layout.complexity_reason),
224
+ :node_path => node.source_path
225
+ )
226
+ return @html.render(html_table)
227
+ end
228
+ rows = render_table_rows(layout)
229
+ caption = render_table_caption(analysis.caption)
230
+ return @html.render(html_table) unless native_table?(layout, rows) && !caption.nil?
231
+ if layout.ragged_padded?
232
+ @diagnostics << Diagnostic.new(
233
+ :code => :ragged_table_padded,
234
+ :severity => :info,
235
+ :message => "Padded incomplete table rows with empty cells",
236
+ :node_path => node.source_path
237
+ )
238
+ end
239
+
240
+ # Native table syntax consumes structural classes and IDs because neither output dialect can attach them without changing the table representation.
241
+ syntax = render_pipe_table(layout, rows)
242
+ caption.empty? ? syntax : "#{caption}\n\n#{syntax}"
243
+ end
244
+
245
+ def table_complexity_message(reason)
246
+ detail = {
247
+ :row_count => "more than #{Tables::MAX_ROWS} rows",
248
+ :column_count => "more than #{Tables::MAX_COLUMNS} columns",
249
+ :grid_positions => "more than #{Tables::MAX_GRID_POSITIONS} expanded grid positions",
250
+ :span => "a row or column span greater than #{Tables::MAX_SPAN}"
251
+ }.fetch(reason)
252
+ "Rendered a complex table as canonical HTML because it contained #{detail}"
253
+ end
254
+
255
+ def render_table_rows(layout)
256
+ layout.rows.map do |row|
257
+ row.map { |cell| render_table_cell(cell) }
258
+ end
259
+ end
260
+
261
+ def render_table_cell(cell)
262
+ return "" if cell.placeholder?
263
+
264
+ children = cell.node.children
265
+ value = if children.length == 1 && children.first.semantic_kind == :paragraph
266
+ render_inlines(children.first.children, :line_start => false)
267
+ elsif children.none? { |child| block_node?(child) }
268
+ render_inlines(children, :line_start => false)
269
+ end
270
+ return unless value && !value.include?("\n")
271
+
272
+ value.gsub(/(?<!\\)\|/) { "\\|" }
273
+ end
274
+
275
+ def native_table?(layout, rows)
276
+ return false unless layout.rectangular? && rows.flatten.none?(&:nil?)
277
+ if @format == "markdown"
278
+ return false unless layout.head_rows.length == 1 && layout.body_groups.length == 1 && layout.foot_rows.empty?
279
+ end
280
+ if @format == "kramdown" && layout.body_groups.empty? && (layout.head_rows.any? || layout.foot_rows.any?)
281
+ return false
282
+ end
283
+
284
+ widths = table_column_widths(rows, table_has_separators?(layout))
285
+ max_width = @configuration.tables[:max_width]
286
+ max_width.nil? || pipe_row_width(widths) <= max_width
287
+ end
288
+
289
+ def render_pipe_table(layout, rows)
290
+ row_index = 0
291
+ head = rows.slice(row_index, layout.head_rows.length)
292
+ row_index += layout.head_rows.length
293
+ bodies = layout.body_groups.map do |group|
294
+ value = rows.slice(row_index, group.length)
295
+ row_index += group.length
296
+ value
297
+ end
298
+ foot = rows.slice(row_index, layout.foot_rows.length)
299
+ widths = table_column_widths(rows, table_has_separators?(layout))
300
+ lines = head.map { |row| pipe_row(row, widths) }
301
+ lines << separator_row(widths, "-") if !head.empty? && !bodies.empty?
302
+ bodies.each_with_index do |body, index|
303
+ lines << separator_row(widths, "-") if index > 0
304
+ lines.concat(body.map { |row| pipe_row(row, widths) })
305
+ end
306
+ unless foot.empty?
307
+ lines << separator_row(widths, "=")
308
+ lines.concat(foot.map { |row| pipe_row(row, widths) })
309
+ end
310
+ lines.join("\n")
311
+ end
312
+
313
+ def table_has_separators?(layout)
314
+ (!layout.head_rows.empty? && !layout.body_groups.empty?) || layout.body_groups.length > 1 || !layout.foot_rows.empty?
315
+ end
316
+
317
+ def table_column_widths(rows, separators)
318
+ (0...rows.first.length).map do |column|
319
+ width = rows.map { |row| row[column].length }.max
320
+ separators ? [width, 3].max : width
321
+ end
322
+ end
323
+
324
+ def pipe_row(values, widths)
325
+ "| " + values.each_with_index.map { |value, index| value.ljust(widths[index]) }.join(" | ") + " |"
326
+ end
327
+
328
+ def separator_row(widths, marker)
329
+ pipe_row(widths.map { |width| marker * width }, widths)
330
+ end
331
+
332
+ def pipe_row_width(widths)
333
+ widths.inject(1) { |total, width| total + width + 3 }
334
+ end
335
+
336
+ def render_table_caption(caption)
337
+ return "" unless caption
338
+
339
+ children = caption.children
340
+ content = if children.length == 1 && children.first.semantic_kind == :paragraph
341
+ render_inlines(children.first.children, :line_start => false)
342
+ elsif children.none? { |child| block_node?(child) }
343
+ render_inlines(children, :line_start => false)
344
+ end
345
+ return unless content && !content.include?("\n")
346
+
347
+ content = content.strip
348
+ content.empty? ? "" : content
349
+ end
350
+
351
+ def render_blockquote(node)
352
+ render_blocks(node.children).split("\n", -1).map do |line|
353
+ line.empty? ? ">" : "> #{line}"
354
+ end.join("\n")
355
+ end
356
+
357
+ def render_list(node, ordered)
358
+ items = node.children.select { |child| child.semantic_kind == :list_item }
359
+ loose = items.any? { |item| loose_list_item?(item) }
360
+ start = ordered ? node.properties.fetch(:start, 1) : nil
361
+ items.each_with_index.map do |item, index|
362
+ marker = ordered ? "#{start + index}. " : "* "
363
+ render_list_item(item, marker)
364
+ end.join(ordered && loose ? "\n\n" : "\n")
365
+ end
366
+
367
+ def render_list_item(item, marker)
368
+ children = item.children
369
+ return marker.rstrip if children.empty?
370
+
371
+ first = render_item_child(children.first)
372
+ result = prefix_lines(first, marker, " " * marker.length)
373
+ tight = tight_list_item?(item)
374
+ children[1..-1].to_a.each do |child|
375
+ separator = tight && LIST_KINDS.include?(child.semantic_kind) ? "\n" : "\n\n"
376
+ result += separator + indent_lines(render_item_child(child), marker.length)
377
+ end
378
+ result
379
+ end
380
+
381
+ def render_item_child(child)
382
+ value = safely_render(child) do
383
+ block_node?(child) ? render_block(child) : render_inline(child, true, false)
384
+ end
385
+ value.equal?(FAILED_RENDERING) ? "" : value
386
+ end
387
+
388
+ def tight_list_item?(item)
389
+ children = item.children
390
+ return true if children.length == 1 && children.first.semantic_kind == :paragraph
391
+
392
+ children.length == 2 && children.first.semantic_kind == :paragraph && LIST_KINDS.include?(children.last.semantic_kind)
393
+ end
394
+
395
+ def loose_list_item?(item)
396
+ !tight_list_item?(item)
397
+ end
398
+
399
+ def prefix_lines(value, first_prefix, continuation_prefix)
400
+ value.split("\n", -1).each_with_index.map do |line, index|
401
+ prefix = index.zero? ? first_prefix : continuation_prefix
402
+ line.empty? ? prefix.rstrip : prefix + line
403
+ end.join("\n")
404
+ end
405
+
406
+ def indent_lines(value, width)
407
+ prefix_lines(value, " " * width, " " * width)
408
+ end
409
+
410
+ def block_node?(node)
411
+ node.category == :block || Normaliser::BLOCK_SEMANTICS.include?(node.semantic_kind)
412
+ end
413
+
414
+ def safely_render(node)
415
+ yield
416
+ rescue StandardError => error
417
+ @diagnostics << Diagnostic.new(
418
+ :code => :conversion_error,
419
+ :severity => :error,
420
+ :message => "Unable to convert node: #{error.message}",
421
+ :node_path => node.source_path
422
+ )
423
+ FAILED_RENDERING
424
+ end
425
+
426
+ def canonicalise(value)
427
+ value.gsub("\r\n", "\n").gsub("\r", "\n").sub(/\A\n+/, "").sub(/\n+\z/, "")
428
+ end
429
+ end
430
+
431
+ end
432
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+ module Rendering
6
+
7
+ # Converts parsed entity-like leaves according to the selected symbolic, character or numeric output policy.
8
+ class EntityEncoder
9
+ def initialize(mode)
10
+ @mode = mode
11
+ end
12
+
13
+ def character_mode?
14
+ @mode == :character
15
+ end
16
+
17
+ def character(node)
18
+ entity_values(node)[:character]
19
+ end
20
+
21
+ def encode(node)
22
+ values = entity_values(node)
23
+ case @mode
24
+ when :character
25
+ values[:character]
26
+ when :numeric
27
+ "&##{values[:code_point]};"
28
+ else
29
+ values[:name] ? "&#{values[:name]};" : "&##{values[:code_point]};"
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def entity_values(node)
36
+ return node.value if node.kind == :entity && node.value.is_a?(Hash)
37
+
38
+ entity = Kramdown::Utils::Entities.entity(node.value.to_s)
39
+ {
40
+ :code_point => entity.code_point,
41
+ :name => entity.name,
42
+ :character => entity.char
43
+ }
44
+ rescue Kramdown::Error
45
+ value = node.value.to_s
46
+ {
47
+ :code_point => value.ord,
48
+ :name => nil,
49
+ :character => value
50
+ }
51
+ end
52
+ end
53
+
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+ module Rendering
6
+
7
+ # Escapes generated Markdown contextually so source text cannot become unintended block or inline syntax when reparsed.
8
+ class Escaper
9
+ INLINE_PUNCTUATION = /[*_\[\]`|]/.freeze
10
+ SPACE_OR_CONTROL = /[\x00-\x20]/.freeze
11
+
12
+ def text(value, line_start: false, escape_definition_marker: false)
13
+ result = value.to_s.gsub("\\", "\\\\").gsub("<", "&lt;").gsub(">", "&gt;")
14
+ result = result.gsub(INLINE_PUNCTUATION) { |character| "\\#{character}" }
15
+ result = result.gsub(/\{(?=:)/, "\\{")
16
+ line_start ? escape_block_start(result, escape_definition_marker) : result
17
+ end
18
+
19
+ def label(value)
20
+ text(value.to_s.gsub(/[\r\n]+/, " "))
21
+ end
22
+
23
+ def destination(value, escape_ampersand: true)
24
+ value.to_s.each_char.map do |character|
25
+ if SPACE_OR_CONTROL.match(character) || ["<", ">"].include?(character)
26
+ character.bytes.map { |byte| "%%%02X" % byte }.join
27
+ elsif character == "&" && escape_ampersand
28
+ "&amp;"
29
+ elsif ["\\", "(", ")"].include?(character)
30
+ "\\#{character}"
31
+ else
32
+ character
33
+ end
34
+ end.join
35
+ end
36
+
37
+ def title(value, escape_ampersand: true)
38
+ result = value.to_s.gsub(/[\r\n]+/, " ").gsub("\\", "\\\\")
39
+ result = result.gsub("&", "&amp;") if escape_ampersand
40
+ result.gsub("<", "\\<").gsub('"', '\\"')
41
+ end
42
+
43
+ private
44
+
45
+ def escape_block_start(value, escape_definition_marker)
46
+ return value.sub(/\A#/, "\\#") if /\A#+(?=\s)/.match(value)
47
+ return value.sub(/\A>/, "\\>") if /\A>(?=\s)/.match(value)
48
+ return value.sub(/\A[+-]/) { |marker| "\\#{marker}" } if /\A[+-](?=\s)/.match(value)
49
+ return value.sub(/\A(\d+)([.)])(?=\s)/) { "#{$1}\\#{$2}" } if /\A\d+[.)](?=\s)/.match(value)
50
+ return value.sub(/\A:/, "\\:") if escape_definition_marker && /\A:(?=\s)/.match(value)
51
+
52
+ value
53
+ end
54
+ end
55
+
56
+ end
57
+ end
58
+ end