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