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,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Conversion
|
|
5
|
+
|
|
6
|
+
class Normaliser
|
|
7
|
+
# Normalises inline semantic constructs after their descendants have been prepared, including styled-span recovery and whitespace shifting.
|
|
8
|
+
class InlineSemantics
|
|
9
|
+
URL_SCHEME = /\A([A-Za-z][A-Za-z0-9+.-]*):/.freeze
|
|
10
|
+
STYLE_ELEMENTS = {
|
|
11
|
+
:strong => ["strong", :strong],
|
|
12
|
+
:emphasis => ["em", :emphasis],
|
|
13
|
+
:deletion => ["del", :deletion],
|
|
14
|
+
:underline => ["u", :underline]
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(policy, node_factory, content, breaks, url_rewriter, diagnostics)
|
|
18
|
+
@policy = policy
|
|
19
|
+
@node_factory = node_factory
|
|
20
|
+
@content = content
|
|
21
|
+
@breaks = breaks
|
|
22
|
+
@url_rewriter = url_rewriter
|
|
23
|
+
@diagnostics = diagnostics
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def normalise_span(node, children, path)
|
|
27
|
+
styles = node.semantic_values[:styled_as]
|
|
28
|
+
return children unless styles
|
|
29
|
+
|
|
30
|
+
@diagnostics.add(:styled_span_recovered, :info, "Converted presentational span styling to semantic content", path)
|
|
31
|
+
styles.reverse_each do |style|
|
|
32
|
+
children = apply_generated_style(style, children, path)
|
|
33
|
+
end
|
|
34
|
+
children
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def normalise_emphasis(node, semantic_kind, children, path)
|
|
38
|
+
wrap_inline_structure(children, path) do |fragment|
|
|
39
|
+
fragment = @content.normalise_inline_sequence(fragment, :trim => false)
|
|
40
|
+
normalised = @node_factory.semantic(node, semantic_kind, fragment, path)
|
|
41
|
+
normalise_wrapper_whitespace(normalised, path)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def normalise_wrapper_whitespace(node, path)
|
|
46
|
+
moved = move_edge_whitespace(node)
|
|
47
|
+
if moved.length != 1 || moved.first != node
|
|
48
|
+
@diagnostics.add(:emphasis_whitespace_moved, :info, "Moved collapsible whitespace outside inline formatting", path)
|
|
49
|
+
end
|
|
50
|
+
moved
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def trim_wrapper_whitespace(node)
|
|
54
|
+
children = @content.normalise_inline_sequence(node.children, :trim => true)
|
|
55
|
+
@content.visible_content?(children) ? [node.with(:children => children)] : []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Reopens an inline wrapper around phrasing runs while allowing nested block output to bubble to its block parent.
|
|
59
|
+
def wrap_inline_structure(children, path)
|
|
60
|
+
@content.split_around_blocks(children).flat_map do |part|
|
|
61
|
+
if part.is_a?(NormalisedNode)
|
|
62
|
+
part
|
|
63
|
+
else
|
|
64
|
+
@breaks.wrap_inline_fragments(part, path) { |fragment| yield fragment }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def normalise_link(node, children, path)
|
|
70
|
+
children = @content.normalise_inline_sequence(children, :trim => false)
|
|
71
|
+
return [] unless @content.visible_content?(children)
|
|
72
|
+
|
|
73
|
+
attributes = Hash[node.attributes]
|
|
74
|
+
destination = attributes["href"]
|
|
75
|
+
return children unless usable_url?(destination) && !blacklisted_scheme?(destination.strip)
|
|
76
|
+
|
|
77
|
+
children = flatten_links(children)
|
|
78
|
+
attributes["href"] = @url_rewriter.rewrite(destination.strip)
|
|
79
|
+
wrap_inline_structure(children, path) do |fragment|
|
|
80
|
+
normalised = @node_factory.semantic(node, :link, fragment, path, :attributes => ordered_attributes(node.attributes, attributes))
|
|
81
|
+
moved = move_edge_whitespace(normalised)
|
|
82
|
+
@diagnostics.add(:link_whitespace_moved, :info, "Moved collapsible whitespace outside link text", path) unless moved == [normalised]
|
|
83
|
+
moved
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalise_image(node, path)
|
|
88
|
+
attributes = Hash[node.attributes]
|
|
89
|
+
source = attributes["src"]
|
|
90
|
+
alt = @content.normalise_text(attributes.fetch("alt", "").to_s).strip
|
|
91
|
+
attributes["alt"] = alt if attributes.key?("alt")
|
|
92
|
+
|
|
93
|
+
unless source.is_a?(String) && !source.strip.empty?
|
|
94
|
+
return alt.empty? ? [] : [@node_factory.text(@content.normalise_text(alt), path)]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
attributes["src"] = @url_rewriter.rewrite(source.strip)
|
|
98
|
+
[@node_factory.semantic(node, :image, [], path, :attributes => ordered_attributes(node.attributes, attributes))]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def normalise_generic(node, semantic_kind, children, path)
|
|
102
|
+
wrap_inline_structure(children, path) do |fragment|
|
|
103
|
+
[@node_factory.semantic(node, semantic_kind, fragment, path)]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def apply_generated_style(style, children, path)
|
|
110
|
+
source_name, semantic_kind = STYLE_ELEMENTS.fetch(style)
|
|
111
|
+
action = @policy.action_for_source_name(source_name).value
|
|
112
|
+
|
|
113
|
+
case action
|
|
114
|
+
when :drop
|
|
115
|
+
[]
|
|
116
|
+
when :unwrap, :inline
|
|
117
|
+
children
|
|
118
|
+
when :preserve
|
|
119
|
+
wrap_generated(source_name, semantic_kind, children, path, :html)
|
|
120
|
+
when :convert
|
|
121
|
+
wrap_generated(source_name, semantic_kind, children, path, :semantic)
|
|
122
|
+
else
|
|
123
|
+
raise "unhandled generated element action #{action.inspect}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def wrap_generated(source_name, semantic_kind, children, path, representation)
|
|
128
|
+
wrap_inline_structure(children, path) do |fragment|
|
|
129
|
+
node = @node_factory.build(
|
|
130
|
+
:kind => :element,
|
|
131
|
+
:source_name => source_name,
|
|
132
|
+
:semantic_kind => semantic_kind,
|
|
133
|
+
:category => :span,
|
|
134
|
+
:children => fragment,
|
|
135
|
+
:representation => representation,
|
|
136
|
+
:source_path => path
|
|
137
|
+
)
|
|
138
|
+
if semantic_kind == :deletion
|
|
139
|
+
trim_wrapper_whitespace(node)
|
|
140
|
+
elsif [:emphasis, :strong].include?(semantic_kind)
|
|
141
|
+
normalise_wrapper_whitespace(node, path)
|
|
142
|
+
else
|
|
143
|
+
[node]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def flatten_links(children)
|
|
149
|
+
children.flat_map do |child|
|
|
150
|
+
if child.semantic_kind == :link
|
|
151
|
+
flatten_links(child.children)
|
|
152
|
+
elsif child.children.empty?
|
|
153
|
+
child
|
|
154
|
+
else
|
|
155
|
+
child.with(:children => flatten_links(child.children))
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Link labels and emphasis share the same rule: surrounding collapsible space belongs outside the generated syntax.
|
|
161
|
+
def move_edge_whitespace(node)
|
|
162
|
+
children = node.children.dup
|
|
163
|
+
leading = remove_leading_space(children)
|
|
164
|
+
trailing = remove_trailing_space(children)
|
|
165
|
+
return [node] if leading.empty? && trailing.empty?
|
|
166
|
+
|
|
167
|
+
children = @content.normalise_inline_sequence(children, :trim => false)
|
|
168
|
+
result = []
|
|
169
|
+
result << @node_factory.text(leading, node.source_path) unless leading.empty?
|
|
170
|
+
result << node.with(:children => children) if @content.visible_content?(children)
|
|
171
|
+
result << @node_factory.text(trailing, node.source_path) unless trailing.empty?
|
|
172
|
+
result
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def remove_leading_space(children)
|
|
176
|
+
return "" unless children.first && children.first.kind == :text
|
|
177
|
+
|
|
178
|
+
match = Content::LEADING_SPACE.match(children.first.value)
|
|
179
|
+
return "" unless match
|
|
180
|
+
|
|
181
|
+
remaining = children.first.value[match[0].length..-1]
|
|
182
|
+
remaining.empty? ? children.shift : children[0] = children.first.with(:value => remaining)
|
|
183
|
+
match[0]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def remove_trailing_space(children)
|
|
187
|
+
return "" unless children.last && children.last.kind == :text
|
|
188
|
+
|
|
189
|
+
match = Content::TRAILING_SPACE.match(children.last.value)
|
|
190
|
+
return "" unless match
|
|
191
|
+
|
|
192
|
+
remaining = children.last.value[0...-match[0].length]
|
|
193
|
+
remaining.empty? ? children.pop : children[-1] = children.last.with(:value => remaining)
|
|
194
|
+
match[0]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def usable_url?(value)
|
|
198
|
+
value.is_a?(String) && !value.strip.empty?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def blacklisted_scheme?(destination)
|
|
202
|
+
match = URL_SCHEME.match(destination)
|
|
203
|
+
return false unless match
|
|
204
|
+
|
|
205
|
+
scheme = match[1].downcase
|
|
206
|
+
scheme.end_with?("script") || ["file", "data", "content", "intent"].include?(scheme) || scheme.start_with?("ms-")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def ordered_attributes(original, values)
|
|
210
|
+
original.each_with_object([]) do |(name, _value), result|
|
|
211
|
+
result << [name, values[name]] if values.key?(name)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Conversion
|
|
5
|
+
|
|
6
|
+
class Normaliser
|
|
7
|
+
# Centralises construction of immutable normalised nodes so transformations only describe the fields they intend to change.
|
|
8
|
+
class NodeFactory
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
:kind => :element,
|
|
11
|
+
:source_name => nil,
|
|
12
|
+
:semantic_kind => nil,
|
|
13
|
+
:category => nil,
|
|
14
|
+
:value => nil,
|
|
15
|
+
:attributes => [],
|
|
16
|
+
:classes => [],
|
|
17
|
+
:id => nil,
|
|
18
|
+
:children => [],
|
|
19
|
+
:properties => {},
|
|
20
|
+
:semantic_values => {},
|
|
21
|
+
:representation => nil,
|
|
22
|
+
:synthetic => false,
|
|
23
|
+
:source_path => nil
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def build(values)
|
|
27
|
+
NormalisedNode.new(DEFAULTS.merge(values))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def text(value, path)
|
|
31
|
+
build(
|
|
32
|
+
:kind => :text,
|
|
33
|
+
:category => :span,
|
|
34
|
+
:value => value,
|
|
35
|
+
:source_path => path
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def semantic(node, semantic_kind, children, path, changes = {})
|
|
40
|
+
category = Content::BLOCK_SEMANTICS.include?(semantic_kind) ? :block : :span
|
|
41
|
+
build({
|
|
42
|
+
:kind => :element,
|
|
43
|
+
:source_name => node.source_name,
|
|
44
|
+
:semantic_kind => semantic_kind,
|
|
45
|
+
:category => category,
|
|
46
|
+
:value => node.value,
|
|
47
|
+
:attributes => node.attributes,
|
|
48
|
+
:classes => node.classes,
|
|
49
|
+
:id => node.id,
|
|
50
|
+
:children => children,
|
|
51
|
+
:properties => node.properties,
|
|
52
|
+
:semantic_values => node.semantic_values,
|
|
53
|
+
:representation => :semantic,
|
|
54
|
+
:synthetic => node.synthetic,
|
|
55
|
+
:source_path => path
|
|
56
|
+
}.merge(changes))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def preserved(node, semantic_kind, children, path)
|
|
60
|
+
build(
|
|
61
|
+
:kind => :element,
|
|
62
|
+
:source_name => node.source_name,
|
|
63
|
+
:semantic_kind => semantic_kind,
|
|
64
|
+
:category => node.category,
|
|
65
|
+
:value => node.value,
|
|
66
|
+
:attributes => node.attributes,
|
|
67
|
+
:classes => node.classes,
|
|
68
|
+
:id => node.id,
|
|
69
|
+
:children => children,
|
|
70
|
+
:properties => node.properties,
|
|
71
|
+
:semantic_values => node.semantic_values,
|
|
72
|
+
:representation => :html,
|
|
73
|
+
:synthetic => node.synthetic,
|
|
74
|
+
:source_path => path
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def copy_leaf(node, path)
|
|
79
|
+
build(
|
|
80
|
+
:kind => node.kind,
|
|
81
|
+
:category => node.category,
|
|
82
|
+
:value => node.value,
|
|
83
|
+
:properties => node.properties,
|
|
84
|
+
:source_path => path
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def paragraph(children, path)
|
|
89
|
+
build(
|
|
90
|
+
:kind => :element,
|
|
91
|
+
:semantic_kind => :paragraph,
|
|
92
|
+
:category => :block,
|
|
93
|
+
:children => children,
|
|
94
|
+
:representation => :semantic,
|
|
95
|
+
:source_path => path
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def paragraph_boundary(path)
|
|
100
|
+
build(
|
|
101
|
+
:kind => :element,
|
|
102
|
+
:semantic_kind => :paragraph_boundary,
|
|
103
|
+
:category => :block,
|
|
104
|
+
:representation => :boundary,
|
|
105
|
+
:source_path => path
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Conversion
|
|
5
|
+
|
|
6
|
+
class Normaliser
|
|
7
|
+
# Applies the validated URL pattern to usable link and image destinations without changing unrelated domains or root paths.
|
|
8
|
+
class UrlRewriter
|
|
9
|
+
ABSOLUTE_SCHEME = /\A[A-Za-z][A-Za-z0-9+.-]*:\/\//.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(options)
|
|
12
|
+
@force = options.fetch(:force)
|
|
13
|
+
@scheme = clean_scheme(options[:scheme])
|
|
14
|
+
@host = clean_host(options[:host])
|
|
15
|
+
@path = clean_path(options[:path])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def rewrite(value)
|
|
19
|
+
return value unless active?
|
|
20
|
+
|
|
21
|
+
uri = URI.parse(value)
|
|
22
|
+
return rewrite_absolute(uri, value) if absolute?(uri, value)
|
|
23
|
+
return rewrite_root_relative(value) if value.start_with?("/")
|
|
24
|
+
return value if value.start_with?("?", "#")
|
|
25
|
+
|
|
26
|
+
path = join_paths(@path, value)
|
|
27
|
+
@force == :absolute ? absolute_url(path) : root_relative(path)
|
|
28
|
+
rescue URI::InvalidURIError
|
|
29
|
+
value
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def active?
|
|
35
|
+
@scheme || @host || @path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clean_scheme(value)
|
|
39
|
+
return unless usable?(value)
|
|
40
|
+
|
|
41
|
+
value.strip.sub(/:\/\/\z/, "").sub(/:\z/, "")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def clean_host(value)
|
|
45
|
+
return unless usable?(value)
|
|
46
|
+
|
|
47
|
+
value.strip.sub(/\A\/\//, "").sub(/\/+\z/, "")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clean_path(value)
|
|
51
|
+
return unless usable?(value)
|
|
52
|
+
|
|
53
|
+
value.strip.gsub(/\A\/+|\/+\z/, "")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def usable?(value)
|
|
57
|
+
value.is_a?(String) && !value.strip.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def absolute?(uri, source)
|
|
61
|
+
uri.scheme || uri.host || ABSOLUTE_SCHEME.match(source) || source.start_with?("//")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def rewrite_absolute(uri, source)
|
|
65
|
+
return source unless @host && hosts_match?(uri.host, @host)
|
|
66
|
+
return source if @force == :relative && @scheme && uri.scheme.to_s.downcase != @scheme.downcase
|
|
67
|
+
return source unless path_matches?(uri.path)
|
|
68
|
+
|
|
69
|
+
path = uri.path.to_s.empty? ? "/" : uri.path
|
|
70
|
+
path = append_suffix(path, uri)
|
|
71
|
+
@force == :absolute ? absolute_url(path) : root_relative(path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def rewrite_root_relative(source)
|
|
75
|
+
return source unless @force == :absolute && path_matches?(split_suffix(source).first)
|
|
76
|
+
|
|
77
|
+
absolute_url(source)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def hosts_match?(first, second)
|
|
81
|
+
canonical_host(first) == canonical_host(second)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def canonical_host(value)
|
|
85
|
+
value.to_s.downcase.sub(/\Awww\./, "")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def path_matches?(value)
|
|
89
|
+
return true unless @path
|
|
90
|
+
|
|
91
|
+
path = value.to_s.sub(/\A\/+/, "")
|
|
92
|
+
path == @path || path.start_with?("#{@path}/")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def join_paths(prefix, value)
|
|
96
|
+
path, suffix = split_suffix(value)
|
|
97
|
+
segments = [prefix, path].compact.map { |part| part.gsub(/\A\/+|\/+\z/, "") }.reject(&:empty?)
|
|
98
|
+
segments.join("/") + suffix
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def split_suffix(value)
|
|
102
|
+
match = /\A([^?#]*)(.*)\z/.match(value)
|
|
103
|
+
[match[1], match[2]]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def append_suffix(path, uri)
|
|
107
|
+
result = path
|
|
108
|
+
result += "?#{uri.query}" if uri.query
|
|
109
|
+
result += "##{uri.fragment}" if uri.fragment
|
|
110
|
+
result
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def root_relative(path)
|
|
114
|
+
"/#{path.sub(/\A\/+/, "")}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def absolute_url(path)
|
|
118
|
+
"#{@scheme}://#{@host}#{root_relative(path)}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Conversion
|
|
5
|
+
|
|
6
|
+
# Coordinates one renderer-independent normalisation run through focused element, content, break and heading collaborators.
|
|
7
|
+
class Normaliser
|
|
8
|
+
BLOCK_SEMANTICS = Content::BLOCK_SEMANTICS
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def normalise(root, configuration)
|
|
12
|
+
new(configuration).normalise(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
|
+
@configuration = configuration
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def normalise(root)
|
|
23
|
+
validate_root(root)
|
|
24
|
+
node_factory = NodeFactory.new
|
|
25
|
+
diagnostics = CorrectionDiagnostics.new
|
|
26
|
+
content = Content.new(node_factory)
|
|
27
|
+
breaks = Breaks.new(node_factory, content, diagnostics)
|
|
28
|
+
element_actions = ElementActions.new(@configuration, node_factory, content, breaks, diagnostics)
|
|
29
|
+
children = element_actions.normalise_root(root)
|
|
30
|
+
|
|
31
|
+
if @configuration.inline
|
|
32
|
+
children = content.normalise_inline_sequence(children, :trim => true)
|
|
33
|
+
else
|
|
34
|
+
children = content.wrap_phrasing_runs(children)
|
|
35
|
+
children = HeadingLevels.new(@configuration, content, diagnostics).adjust(children)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
tree = node_factory.build(
|
|
39
|
+
:kind => :document,
|
|
40
|
+
:children => children,
|
|
41
|
+
:properties => root.kind == :document ? root.properties : {},
|
|
42
|
+
:source_path => []
|
|
43
|
+
)
|
|
44
|
+
Normalisation.new(tree, diagnostics.to_a)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate_root(root)
|
|
50
|
+
raise ArgumentError, "root must be a prepared Slamdown node" unless root.is_a?(PreparedNode)
|
|
51
|
+
return if root.kind == :document || root.category == :block
|
|
52
|
+
|
|
53
|
+
raise ArgumentError, "root must be a document or block-level prepared node"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Conversion
|
|
5
|
+
|
|
6
|
+
# Resolves caller policy over Slamdown's default semantic and retention policy while preserving where each decision originated.
|
|
7
|
+
module Policy
|
|
8
|
+
CONVERTER_NAMES = ([
|
|
9
|
+
"p", "blockquote", "ol", "ul", "li", "dl", "dt", "dd", "details", "summary", "hr", "br", "code", "kbd", "pre", "samp", "tt", "var", "a", "img", "em", "i", "strong", "b", "s", "strike",
|
|
10
|
+
"table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "col", "colgroup", "div", "span"
|
|
11
|
+
] + (1..6).map { |level| "h#{level}" }).freeze
|
|
12
|
+
PRESERVED_NAMES = %w[abbr cite del ins mark sub sup u].freeze
|
|
13
|
+
|
|
14
|
+
DEFAULT_ELEMENTS = begin
|
|
15
|
+
elements = {}
|
|
16
|
+
CONVERTER_NAMES.each { |name| elements[name] = {:action => :convert} }
|
|
17
|
+
PRESERVED_NAMES.each { |name| elements[name] = {:action => :preserve} }
|
|
18
|
+
%w[audio time video].each { |name| elements[name] = {:action => :inline} }
|
|
19
|
+
elements["a"][:attributes] = {"href" => :preserve}
|
|
20
|
+
elements["img"][:attributes] = {"src" => :preserve, "alt" => :preserve}
|
|
21
|
+
elements["ol"][:attributes] = {"start" => :preserve}
|
|
22
|
+
elements["abbr"][:attributes] = {"title" => :preserve}
|
|
23
|
+
%w[td th].each { |name| elements[name][:attributes] = {"colspan" => :preserve, "rowspan" => :preserve, "scope" => :preserve} }
|
|
24
|
+
%w[col colgroup].each { |name| elements[name][:attributes] = {"span" => :preserve} }
|
|
25
|
+
elements[:any] = {:attributes => {:any => :drop}}
|
|
26
|
+
Immutable.copy(elements)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# One immutable resolved value and the layer that supplied it.
|
|
30
|
+
class Decision
|
|
31
|
+
attr_reader :value, :provenance, :element_scope, :attribute_scope
|
|
32
|
+
|
|
33
|
+
def initialize(value, provenance, element_scope:, attribute_scope: nil)
|
|
34
|
+
@value = value
|
|
35
|
+
@provenance = provenance
|
|
36
|
+
@element_scope = element_scope
|
|
37
|
+
@attribute_scope = attribute_scope
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# One immutable allow/deny result for combined class or ID matchers.
|
|
43
|
+
class MatchDecision
|
|
44
|
+
attr_reader :allowed, :provenance
|
|
45
|
+
|
|
46
|
+
def initialize(allowed, provenance)
|
|
47
|
+
@allowed = allowed
|
|
48
|
+
@provenance = provenance.freeze
|
|
49
|
+
freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def allowed?
|
|
53
|
+
allowed
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Looks up policy for source-aware parsed nodes. Parser-synthetic nodes deliberately receive no invented element key.
|
|
58
|
+
class Resolver
|
|
59
|
+
def initialize(configuration)
|
|
60
|
+
@configuration = configuration
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def action_for(node)
|
|
64
|
+
return unless node.source_name
|
|
65
|
+
|
|
66
|
+
first_element_decision(node.source_name, :action) || Decision.new(generic_action(node), :default_any, :element_scope => :any)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Resolves policy for a semantic element generated during normalisation, such as a strong wrapper recovered from span styling.
|
|
70
|
+
def action_for_source_name(source_name)
|
|
71
|
+
decision = first_element_decision(source_name, :action)
|
|
72
|
+
return decision if decision
|
|
73
|
+
|
|
74
|
+
raise ArgumentError, "no element action is registered for #{source_name.inspect}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def attribute_for(node, attribute_name)
|
|
78
|
+
return unless node.source_name
|
|
79
|
+
|
|
80
|
+
first_attribute_decision(node.source_name, attribute_name.to_s.downcase)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def class_for(node, token)
|
|
84
|
+
match_decision(@configuration.classes, node, token)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def id_for(node, value)
|
|
88
|
+
match_decision(@configuration.ids, node, value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def first_element_decision(element_name, property)
|
|
94
|
+
policy_layers(element_name).each do |rule, provenance, element_scope|
|
|
95
|
+
return Decision.new(rule[property], provenance, :element_scope => element_scope) if rule && rule.key?(property)
|
|
96
|
+
end
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def first_attribute_decision(element_name, attribute_name)
|
|
101
|
+
policy_groups.each do |elements, source|
|
|
102
|
+
[
|
|
103
|
+
[elements[element_name], :specific],
|
|
104
|
+
[elements[:any], :any]
|
|
105
|
+
].each do |rule, element_scope|
|
|
106
|
+
next unless rule && rule[:attributes]
|
|
107
|
+
|
|
108
|
+
attributes = rule[:attributes]
|
|
109
|
+
provenance = provenance_for(source, element_scope)
|
|
110
|
+
return Decision.new(attributes[attribute_name], provenance, :element_scope => element_scope, :attribute_scope => :named) if attributes.key?(attribute_name)
|
|
111
|
+
return Decision.new(attributes[:any], provenance, :element_scope => element_scope, :attribute_scope => :any) if attributes.key?(:any)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def policy_layers(element_name)
|
|
118
|
+
policy_groups.flat_map do |elements, source|
|
|
119
|
+
[
|
|
120
|
+
[elements[element_name], provenance_for(source, :specific), :specific],
|
|
121
|
+
[elements[:any], provenance_for(source, :any), :any]
|
|
122
|
+
]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def policy_groups
|
|
127
|
+
[
|
|
128
|
+
[@configuration.elements, :user],
|
|
129
|
+
[DEFAULT_ELEMENTS, :default]
|
|
130
|
+
]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def provenance_for(source, element_scope)
|
|
134
|
+
return :overridden if source == :user && element_scope == :specific
|
|
135
|
+
return :user_any if source == :user
|
|
136
|
+
|
|
137
|
+
"default_#{element_scope}".to_sym
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def generic_action(node)
|
|
141
|
+
usable_content?(node) ? :unwrap : :drop
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def usable_content?(node)
|
|
145
|
+
node.children.any? { |child| ordinary_content?(child) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ordinary_content?(node)
|
|
149
|
+
return !node.value.empty? if node.kind == :text && node.value.is_a?(String)
|
|
150
|
+
return true if [:entity, :typographic_symbol, :smart_quote].include?(node.kind)
|
|
151
|
+
return false if [:raw, :comment, :processing_instruction].include?(node.kind)
|
|
152
|
+
return true if node.kind == :element && node.semantic_kind
|
|
153
|
+
|
|
154
|
+
node.children.any? { |child| ordinary_content?(child) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def match_decision(rules, node, value)
|
|
158
|
+
return MatchDecision.new(false, []) unless node.source_name
|
|
159
|
+
|
|
160
|
+
provenance = []
|
|
161
|
+
provenance << :user_any if matches?(rules[:any], value)
|
|
162
|
+
provenance << :overridden if matches?(rules[node.source_name], value)
|
|
163
|
+
MatchDecision.new(!provenance.empty?, provenance)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def matches?(matchers, value)
|
|
167
|
+
return false unless matchers
|
|
168
|
+
return true if matchers == :any
|
|
169
|
+
|
|
170
|
+
matchers.any? do |matcher|
|
|
171
|
+
matcher.is_a?(Regexp) ? matcher.match(value) : matcher == value
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
end
|
|
178
|
+
end
|