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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1022c0c170ad37a1dc791fd76797ac41173aead90dea397889d74813d083cc4d
4
+ data.tar.gz: 3d5fbf7537c7b012a21a7a1fe8eb256b9d4bebaabb6560609b7decac053d52c9
5
+ SHA512:
6
+ metadata.gz: cefee43327b751e2e646c71858ea15825f62fb9fab7cf506ebfb3e6f0db283c49b0ae0bc3b7a00987eb40e37640a0f891a6bdbdd8aa2c7dd0c744a343219216b
7
+ data.tar.gz: 6f82a32a29404aac296028981d8cbeb4af38db44b7842d90172f785841642e6c5c71f23546b6f8a71fd3bf9e1b834f4e6e25744cabe1932d272ea988fd8ed254
data/changelog.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+
4
+ ## 0.5.0
5
+
6
+ * Configurable conversion of messy HTML to clean Markdown and Kramdown.
7
+ * Diagnostics for parsing and conversion issues.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ # Holds the complete validated conversion options relied upon by all later conversion stages.
7
+ class Configuration
8
+ ATTRIBUTES = [:inline, :headings, :entities, :html_indent, :urls, :tables, :elements, :classes, :ids].freeze
9
+
10
+ attr_reader(*ATTRIBUTES)
11
+
12
+ class << self
13
+ # Validates caller options and fills every omitted value from Slamdown's defaults.
14
+ def build(options = {})
15
+ ConfigurationValidator.new(options).validate
16
+ end
17
+ end
18
+
19
+ def initialize(values)
20
+ ATTRIBUTES.each do |attribute|
21
+ instance_variable_set("@#{attribute}", Immutable.copy(values.fetch(attribute)))
22
+ end
23
+ freeze
24
+ end
25
+
26
+ # Provides a plain immutable projection for consumers that need the complete effective configuration.
27
+ def to_h
28
+ ATTRIBUTES.each_with_object({}) do |attribute, result|
29
+ result[attribute] = public_send(attribute)
30
+ end.freeze
31
+ end
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ # Performs small, section-local checks and normalisation before conversion code receives any options.
7
+ class ConfigurationValidator
8
+ TOP_LEVEL_KEYS = [:inline, :headings, :entities, :html_indent, :urls, :tables, :elements, :classes, :ids].freeze
9
+ HEADING_KEYS = [:start, :compact].freeze
10
+ URL_KEYS = [:scheme, :host, :path, :force].freeze
11
+ TABLE_KEYS = [:format, :max_width].freeze
12
+ ELEMENT_RULE_KEYS = [:action, :attributes].freeze
13
+ ELEMENT_ACTIONS = [:convert, :preserve, :unwrap, :inline, :drop].freeze
14
+ ATTRIBUTE_ACTIONS = [:preserve, :drop].freeze
15
+ ENTITY_MODES = [:symbolic, :character, :numeric].freeze
16
+ URL_FORCES = [:relative, :absolute].freeze
17
+ TABLE_FORMATS = [:auto, :html].freeze
18
+ HTML_NAME = /\A[a-z][a-z0-9._:-]*\z/.freeze
19
+ ATTRIBUTE_NAME = /\A[a-z_:][a-z0-9._:-]*\z/.freeze
20
+ HTML_WHITESPACE = /[\t\n\f\r ]/.freeze
21
+
22
+ DEFAULTS = Immutable.copy(
23
+ :inline => false,
24
+ :headings => {:start => 2, :compact => true},
25
+ :entities => :character,
26
+ :html_indent => "\t",
27
+ :urls => {:scheme => nil, :host => nil, :path => nil, :force => :relative},
28
+ :tables => {:format => :auto, :max_width => 150},
29
+ :elements => {},
30
+ :classes => {},
31
+ :ids => {}
32
+ )
33
+
34
+ def initialize(options)
35
+ @options = options
36
+ end
37
+
38
+ def validate
39
+ expect_hash(@options, "options")
40
+ @options = normalise_option_keys(@options, TOP_LEVEL_KEYS, "options")
41
+
42
+ Configuration.new(
43
+ :inline => boolean_option(:inline),
44
+ :headings => validate_headings,
45
+ :entities => closed_option(:entities, ENTITY_MODES),
46
+ :html_indent => validate_html_indent,
47
+ :urls => validate_urls,
48
+ :tables => validate_tables,
49
+ :elements => validate_elements,
50
+ :classes => validate_name_rules(:classes),
51
+ :ids => validate_name_rules(:ids)
52
+ )
53
+ end
54
+
55
+ private
56
+
57
+ def value_or_default(name)
58
+ @options.key?(name) ? @options[name] : DEFAULTS[name]
59
+ end
60
+
61
+ def boolean_option(name)
62
+ value = value_or_default(name)
63
+ return value if value == true || value == false
64
+
65
+ raise ArgumentError, "#{name} must be true or false"
66
+ end
67
+
68
+ def closed_option(name, allowed)
69
+ value = value_or_default(name)
70
+ return value if allowed.include?(value)
71
+
72
+ raise ArgumentError, "#{name} must be one of #{allowed.map(&:inspect).join(', ')}"
73
+ end
74
+
75
+ def validate_headings
76
+ values = merged_section(:headings, HEADING_KEYS)
77
+ start = values[:start]
78
+ compact = values[:compact]
79
+
80
+ raise ArgumentError, "headings[:start] must be an integer from 1 to 6" unless start.is_a?(Integer) && (1..6).include?(start)
81
+ raise ArgumentError, "headings[:compact] must be true or false" unless compact == true || compact == false
82
+
83
+ values
84
+ end
85
+
86
+ def validate_html_indent
87
+ value = value_or_default(:html_indent)
88
+ valid = value.is_a?(String) && (value.empty? || value == "\t" || /\A {1,8}\z/.match(value))
89
+ return value if valid
90
+
91
+ raise ArgumentError, "html_indent must be empty, one tab or one to eight spaces"
92
+ end
93
+
94
+ def validate_urls
95
+ values = merged_section(:urls, URL_KEYS)
96
+
97
+ [:scheme, :host, :path].each do |name|
98
+ value = values[name]
99
+ raise ArgumentError, "urls[:#{name}] must be a String or nil" unless value.nil? || value.is_a?(String)
100
+ end
101
+ raise ArgumentError, "urls[:force] must be :relative or :absolute" unless URL_FORCES.include?(values[:force])
102
+ if values[:force] == :absolute && (!usable_string?(values[:scheme]) || !usable_string?(values[:host]))
103
+ raise ArgumentError, "urls with force :absolute require usable scheme and host values"
104
+ end
105
+
106
+ values
107
+ end
108
+
109
+ def validate_tables
110
+ values = merged_section(:tables, TABLE_KEYS)
111
+
112
+ raise ArgumentError, "tables[:format] must be :auto or :html" unless TABLE_FORMATS.include?(values[:format])
113
+ max_width = values[:max_width]
114
+ raise ArgumentError, "tables[:max_width] must be nil or an integer of at least 20" unless max_width.nil? || (max_width.is_a?(Integer) && max_width >= 20)
115
+
116
+ values
117
+ end
118
+
119
+ def merged_section(name, keys)
120
+ provided = @options.key?(name) ? @options[name] : {}
121
+ expect_hash(provided, name.to_s)
122
+ provided = normalise_option_keys(provided, keys, name.to_s)
123
+
124
+ DEFAULTS.fetch(name).merge(provided)
125
+ end
126
+
127
+ def validate_elements
128
+ provided = value_or_default(:elements)
129
+ expect_hash(provided, "elements")
130
+
131
+ normalise_named_hash(provided, "elements") do |element_name, rule|
132
+ expect_hash(rule, "elements[#{element_name.inspect}]")
133
+ rule = normalise_option_keys(rule, ELEMENT_RULE_KEYS, "elements[#{element_name.inspect}]")
134
+ validate_element_rule(element_name, rule)
135
+ end
136
+ end
137
+
138
+ def validate_element_rule(element_name, rule)
139
+ result = {}
140
+ if rule.key?(:action)
141
+ action = rule[:action]
142
+ raise ArgumentError, "invalid action for #{element_name.inspect}: #{action.inspect}" unless ELEMENT_ACTIONS.include?(action)
143
+ if action == :convert && (element_name == :any || !Policy::CONVERTER_NAMES.include?(element_name))
144
+ raise ArgumentError, "#{element_name.inspect} has no registered Slamdown converter"
145
+ end
146
+ result[:action] = action
147
+ end
148
+
149
+ if rule.key?(:attributes)
150
+ attributes = rule[:attributes]
151
+ expect_hash(attributes, "attributes for #{element_name.inspect}")
152
+ result[:attributes] = normalise_named_hash(attributes, "attributes for #{element_name.inspect}", :attribute => true) do |attribute_name, action|
153
+ if ["class", "id"].include?(attribute_name)
154
+ raise ArgumentError, "#{attribute_name} must be configured through its dedicated section"
155
+ end
156
+ raise ArgumentError, "invalid attribute action for #{attribute_name.inspect}: #{action.inspect}" unless ATTRIBUTE_ACTIONS.include?(action)
157
+ action
158
+ end
159
+ end
160
+
161
+ result
162
+ end
163
+
164
+ def validate_name_rules(section)
165
+ provided = value_or_default(section)
166
+ expect_hash(provided, section.to_s)
167
+
168
+ normalise_named_hash(provided, section.to_s) do |element_name, matcher|
169
+ validate_matcher(matcher, "#{section}[#{element_name.inspect}]")
170
+ end
171
+ end
172
+
173
+ def validate_matcher(matcher, location)
174
+ return :any if matcher == :any
175
+
176
+ matchers = matcher.is_a?(Array) ? matcher : [matcher]
177
+ matchers.each do |candidate|
178
+ valid_string = candidate.is_a?(String) && !candidate.empty? && !HTML_WHITESPACE.match(candidate)
179
+ next if valid_string || candidate.is_a?(Regexp)
180
+
181
+ raise ArgumentError, "#{location} must contain exact strings, regular expressions or :any"
182
+ end
183
+ matchers
184
+ end
185
+
186
+ def normalise_named_hash(values, location, attribute: false)
187
+ result = {}
188
+ values.each do |source_name, value|
189
+ name = normalise_name(source_name, location, attribute)
190
+ raise ArgumentError, "#{location} contains duplicate normalised key #{name.inspect}" if result.key?(name)
191
+
192
+ result[name] = yield(name, value)
193
+ end
194
+ result
195
+ end
196
+
197
+ def normalise_name(source_name, location, attribute)
198
+ unless source_name.is_a?(String) || source_name.is_a?(Symbol)
199
+ raise ArgumentError, "#{location} keys must be strings or symbols"
200
+ end
201
+
202
+ name = source_name.to_s.downcase
203
+ return :any if name == "any"
204
+
205
+ pattern = attribute ? ATTRIBUTE_NAME : HTML_NAME
206
+ raise ArgumentError, "invalid name #{source_name.inspect} in #{location}" unless pattern.match(name)
207
+
208
+ name
209
+ end
210
+
211
+ def expect_hash(value, location)
212
+ raise ArgumentError, "#{location} must be a Hash" unless value.is_a?(Hash)
213
+ end
214
+
215
+ def normalise_option_keys(values, allowed, location)
216
+ values.each_with_object({}) do |(source_name, value), result|
217
+ name = source_name.is_a?(String) ? source_name.to_sym : source_name
218
+ unless allowed.include?(name)
219
+ raise ArgumentError, "unknown #{location} keys: #{source_name.inspect}"
220
+ end
221
+ if result.key?(name)
222
+ raise ArgumentError, "#{location} contains duplicate normalised key #{name.inspect}"
223
+ end
224
+
225
+ result[name] = value
226
+ end
227
+ end
228
+
229
+ def usable_string?(value)
230
+ value.is_a?(String) && !value.strip.empty?
231
+ end
232
+ end
233
+
234
+ end
235
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ # Coordinates the validated public conversion pipeline and applies the conversion error-reporting contract.
7
+ class Converter
8
+ FORMATS = {
9
+ "markdown" => Output::Markdown,
10
+ "kramdown" => Output::Kramdown
11
+ }.freeze
12
+ ERROR_MODES = [:raise, :warn, :silent].freeze
13
+
14
+ class << self
15
+ def convert(input, format:, options: {}, errors: :warn)
16
+ new(format, errors).convert(input, options)
17
+ end
18
+ end
19
+
20
+ def initialize(format, errors)
21
+ @format = normalise_format(format)
22
+ @output_class = FORMATS.fetch(@format)
23
+ validate_error_mode(errors)
24
+ @errors = errors
25
+ end
26
+
27
+ def convert(input, options)
28
+ root = conversion_root(input)
29
+ configuration = Configuration.build(options)
30
+ prepared = Preparer.prepare(root, configuration)
31
+ normalisation = Normaliser.normalise(prepared, configuration)
32
+ rendering = Renderer.render(normalisation.tree, configuration, @format)
33
+ report_rendering_errors(rendering.diagnostics)
34
+
35
+ @output_class.new(rendering.output, normalisation.diagnostics + rendering.diagnostics)
36
+ rescue ArgumentError
37
+ raise
38
+ rescue ConversionError
39
+ raise
40
+ rescue StandardError => error
41
+ handle_conversion_error(error, defined?(normalisation) && normalisation ? normalisation.diagnostics : [])
42
+ end
43
+
44
+ private
45
+
46
+ def normalise_format(format)
47
+ name = format.is_a?(Symbol) ? format.to_s : format
48
+ return name if name.is_a?(String) && FORMATS.key?(name)
49
+
50
+ raise ArgumentError, "format must be :markdown, :kramdown, \"markdown\" or \"kramdown\""
51
+ end
52
+
53
+ def validate_error_mode(errors)
54
+ return if ERROR_MODES.include?(errors)
55
+
56
+ raise ArgumentError, "errors must be :raise, :warn or :silent"
57
+ end
58
+
59
+ def conversion_root(input)
60
+ root = input.is_a?(Document) ? input.tree : input
61
+ unless root.is_a?(Parsing::Node)
62
+ raise ArgumentError, "input must be a Slamdown document or parsed tree"
63
+ end
64
+ unless root.kind == :document || root.category == :block
65
+ raise ArgumentError, "input tree must be a document or block-level subtree"
66
+ end
67
+
68
+ root
69
+ end
70
+
71
+ def handle_conversion_error(error, diagnostics)
72
+ diagnostic = Diagnostic.new(
73
+ :code => :conversion_error,
74
+ :severity => :error,
75
+ :message => "Unable to convert document: #{error.message}",
76
+ :node_path => nil
77
+ )
78
+ conversion_error = ConversionError.new(diagnostic)
79
+
80
+ raise conversion_error if @errors == :raise
81
+ Kernel.warn(conversion_error.message) if @errors == :warn
82
+
83
+ @output_class.new("", diagnostics + [diagnostic])
84
+ end
85
+
86
+ def report_rendering_errors(diagnostics)
87
+ errors = diagnostics.select { |diagnostic| diagnostic.severity == :error }
88
+ return if errors.empty?
89
+
90
+ raise ConversionError.new(errors.first) if @errors == :raise
91
+ return unless @errors == :warn
92
+
93
+ errors.each { |diagnostic| Kernel.warn(ConversionError.new(diagnostic).message) }
94
+ end
95
+ end
96
+
97
+ end
98
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ # Carries the immutable normalised conversion tree together with the corrective diagnostics produced while building it.
7
+ class Normalisation
8
+ attr_reader :tree, :diagnostics
9
+
10
+ def initialize(tree, diagnostics)
11
+ raise ArgumentError, "tree must be a normalised Slamdown node" unless tree.is_a?(NormalisedNode)
12
+ raise ArgumentError, "diagnostics must contain only Slamdown diagnostics" unless diagnostics.is_a?(Array) && diagnostics.all? { |diagnostic| diagnostic.is_a?(Diagnostic) }
13
+
14
+ @tree = tree
15
+ @diagnostics = diagnostics.dup.freeze
16
+ freeze
17
+ end
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ # Represents one immutable, renderer-independent construct after element policy and corrective normalisation have been applied.
7
+ class NormalisedNode
8
+ ATTRIBUTES = [:kind, :source_name, :semantic_kind, :category, :value, :attributes, :classes, :id, :children, :properties, :semantic_values, :representation, :synthetic, :source_path].freeze
9
+
10
+ attr_reader(*ATTRIBUTES)
11
+
12
+ def initialize(values)
13
+ ATTRIBUTES.each do |attribute|
14
+ instance_variable_set("@#{attribute}", Immutable.copy(values.fetch(attribute)))
15
+ end
16
+ freeze
17
+ end
18
+
19
+ # Creates a changed immutable node without making transformation code repeat every unchanged field.
20
+ def with(changes)
21
+ unknown = changes.keys - ATTRIBUTES
22
+ raise ArgumentError, "unknown normalised-node attributes: #{unknown.map(&:inspect).join(', ')}" unless unknown.empty?
23
+
24
+ values = ATTRIBUTES.each_with_object({}) do |attribute, result|
25
+ result[attribute] = changes.key?(attribute) ? changes[attribute] : public_send(attribute)
26
+ end
27
+ self.class.new(values)
28
+ end
29
+
30
+ # Provides a stable plain-Ruby projection for focused normalisation tests and later rendering diagnostics.
31
+ def to_h
32
+ ATTRIBUTES.each_with_object({}) do |attribute, result|
33
+ value = public_send(attribute)
34
+ result[attribute] = attribute == :children ? value.map(&:to_h) : value
35
+ end
36
+ end
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ class Normaliser
7
+ # Interprets line-break runs and bubbles recovered paragraph boundaries through inline wrappers without leaking internal sentinels.
8
+ class Breaks
9
+ def initialize(node_factory, content, diagnostics)
10
+ @node_factory = node_factory
11
+ @content = content
12
+ @diagnostics = diagnostics
13
+ end
14
+
15
+ def split_break_runs(children)
16
+ fragments = []
17
+ current = []
18
+ index = 0
19
+
20
+ while index < children.length
21
+ if paragraph_boundary?(children[index])
22
+ if @content.visible_content?(current) && @content.visible_content?(children[(index + 1)..-1] || [])
23
+ fragments << current
24
+ current = []
25
+ end
26
+ index += 1
27
+ next
28
+ end
29
+
30
+ unless line_break?(children[index])
31
+ current << children[index]
32
+ index += 1
33
+ next
34
+ end
35
+
36
+ run_path = children[index].source_path
37
+ break_count = 0
38
+ interstitial = []
39
+ cursor = index
40
+ loop do
41
+ if cursor < children.length && line_break?(children[cursor])
42
+ break_count += 1
43
+ cursor += 1
44
+ elsif cursor < children.length && @content.whitespace_text?(children[cursor]) && next_break_index(children, cursor)
45
+ interstitial << children[cursor]
46
+ cursor += 1
47
+ else
48
+ break
49
+ end
50
+ end
51
+
52
+ content_before = @content.visible_content?(current)
53
+ content_after = @content.visible_content?(children[cursor..-1] || [])
54
+ if break_count >= 2
55
+ if content_before && content_after
56
+ fragments << current
57
+ current = []
58
+ @diagnostics.add(:paragraph_break_recovered, :info, "Converted a repeated line-break run to a paragraph boundary", run_path)
59
+ end
60
+ elsif content_before && content_after
61
+ current << children[index]
62
+ current.concat(interstitial)
63
+ end
64
+ index = cursor
65
+ end
66
+
67
+ fragments << current if @content.visible_content?(current)
68
+ fragments
69
+ end
70
+
71
+ def wrap_inline_fragments(children, path)
72
+ fragments = split_inline_fragments(children)
73
+ fragments.each_with_index.each_with_object([]) do |(fragment, index), result|
74
+ result << @node_factory.paragraph_boundary(fragment.first ? fragment.first.source_path : path) if index > 0
75
+ result.concat(yield(fragment))
76
+ end
77
+ end
78
+
79
+ def split_inline_fragments(children)
80
+ return [children] unless children.any? { |child| line_break?(child) || paragraph_boundary?(child) }
81
+
82
+ split_break_runs(children)
83
+ end
84
+
85
+ def line_break?(node)
86
+ node.representation == :semantic && node.semantic_kind == :line_break
87
+ end
88
+
89
+ def paragraph_boundary?(node)
90
+ node.representation == :boundary && node.semantic_kind == :paragraph_boundary
91
+ end
92
+
93
+ private
94
+
95
+ def next_break_index(children, index)
96
+ cursor = index
97
+ cursor += 1 while cursor < children.length && @content.whitespace_text?(children[cursor])
98
+ cursor < children.length && line_break?(children[cursor])
99
+ end
100
+ end
101
+ end
102
+
103
+ end
104
+ end