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,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
|