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.
- checksums.yaml +7 -0
- data/changelog.md +7 -0
- data/lib/slamdown/conversion/configuration.rb +35 -0
- data/lib/slamdown/conversion/configuration_validator.rb +235 -0
- data/lib/slamdown/conversion/converter.rb +98 -0
- data/lib/slamdown/conversion/normalisation.rb +21 -0
- data/lib/slamdown/conversion/normalised_node.rb +40 -0
- data/lib/slamdown/conversion/normaliser/breaks.rb +104 -0
- data/lib/slamdown/conversion/normaliser/content.rb +174 -0
- data/lib/slamdown/conversion/normaliser/correction_diagnostics.rb +29 -0
- data/lib/slamdown/conversion/normaliser/element_actions.rb +423 -0
- data/lib/slamdown/conversion/normaliser/heading_levels.rb +45 -0
- data/lib/slamdown/conversion/normaliser/inline_semantics.rb +218 -0
- data/lib/slamdown/conversion/normaliser/node_factory.rb +112 -0
- data/lib/slamdown/conversion/normaliser/url_rewriter.rb +124 -0
- data/lib/slamdown/conversion/normaliser.rb +58 -0
- data/lib/slamdown/conversion/policy.rb +178 -0
- data/lib/slamdown/conversion/prepared_node.rb +23 -0
- data/lib/slamdown/conversion/preparer.rb +122 -0
- data/lib/slamdown/conversion/renderer.rb +432 -0
- data/lib/slamdown/conversion/rendering/entity_encoder.rb +56 -0
- data/lib/slamdown/conversion/rendering/escaper.rb +58 -0
- data/lib/slamdown/conversion/rendering/html.rb +183 -0
- data/lib/slamdown/conversion/rendering/result.rb +23 -0
- data/lib/slamdown/conversion/rendering/strategy.rb +157 -0
- data/lib/slamdown/conversion/semantic_extractor.rb +97 -0
- data/lib/slamdown/conversion/tables.rb +676 -0
- data/lib/slamdown/conversion.rb +34 -0
- data/lib/slamdown/diagnostic.rb +34 -0
- data/lib/slamdown/document.rb +107 -0
- data/lib/slamdown/errors.rb +25 -0
- data/lib/slamdown/immutable.rb +23 -0
- data/lib/slamdown/output.rb +30 -0
- data/lib/slamdown/parsing/html.rb +227 -0
- data/lib/slamdown/parsing/node.rb +50 -0
- data/lib/slamdown/parsing/parsed_node_adapter.rb +144 -0
- data/lib/slamdown/parsing.rb +17 -0
- data/lib/slamdown/version.rb +3 -0
- data/lib/slamdown.rb +10 -0
- data/readme.md +121 -0
- 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
|
+
"})"
|
|
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("<", "<").gsub(">", ">")
|
|
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
|
+
"&"
|
|
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("&", "&") 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
|