sitediff 0.0.6 → 1.2.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 (55) hide show
  1. checksums.yaml +5 -5
  2. data/.eslintignore +1 -0
  3. data/.eslintrc.json +28 -0
  4. data/.project +11 -0
  5. data/.rubocop.yml +179 -0
  6. data/.rubocop_todo.yml +51 -0
  7. data/CHANGELOG.md +28 -0
  8. data/Dockerfile +33 -0
  9. data/Gemfile +11 -0
  10. data/Gemfile.lock +85 -0
  11. data/INSTALLATION.md +146 -0
  12. data/LICENSE +339 -0
  13. data/README.md +810 -0
  14. data/Rakefile +12 -0
  15. data/Thorfile +135 -0
  16. data/bin/sitediff +9 -2
  17. data/config/.gitkeep +0 -0
  18. data/config/sanitize_domains.example.yaml +8 -0
  19. data/config/sitediff.example.yaml +81 -0
  20. data/docker-compose.test.yml +3 -0
  21. data/lib/sitediff/api.rb +276 -0
  22. data/lib/sitediff/cache.rb +57 -8
  23. data/lib/sitediff/cli.rb +156 -176
  24. data/lib/sitediff/config/creator.rb +61 -77
  25. data/lib/sitediff/config/preset.rb +75 -0
  26. data/lib/sitediff/config.rb +436 -31
  27. data/lib/sitediff/crawler.rb +27 -21
  28. data/lib/sitediff/diff.rb +32 -9
  29. data/lib/sitediff/fetch.rb +10 -3
  30. data/lib/sitediff/files/diff.html.erb +20 -2
  31. data/lib/sitediff/files/jquery.min.js +2 -0
  32. data/lib/sitediff/files/normalize.css +349 -0
  33. data/lib/sitediff/files/report.html.erb +171 -0
  34. data/lib/sitediff/files/sidebyside.html.erb +5 -2
  35. data/lib/sitediff/files/sitediff.css +303 -30
  36. data/lib/sitediff/files/sitediff.js +367 -0
  37. data/lib/sitediff/presets/drupal.yaml +63 -0
  38. data/lib/sitediff/report.rb +254 -0
  39. data/lib/sitediff/result.rb +50 -20
  40. data/lib/sitediff/sanitize/dom_transform.rb +47 -8
  41. data/lib/sitediff/sanitize/regexp.rb +24 -3
  42. data/lib/sitediff/sanitize.rb +81 -12
  43. data/lib/sitediff/uriwrapper.rb +65 -23
  44. data/lib/sitediff/webserver/resultserver.rb +30 -33
  45. data/lib/sitediff/webserver.rb +15 -3
  46. data/lib/sitediff.rb +130 -83
  47. data/misc/sitediff - overview report.png +0 -0
  48. data/misc/sitediff - page report.png +0 -0
  49. data/package-lock.json +878 -0
  50. data/package.json +25 -0
  51. data/sitediff.gemspec +51 -0
  52. metadata +91 -29
  53. data/lib/sitediff/files/html_report.html.erb +0 -66
  54. data/lib/sitediff/files/rules/drupal.yaml +0 -63
  55. data/lib/sitediff/rules.rb +0 -65
@@ -8,21 +8,26 @@ require 'nokogiri'
8
8
  require 'set'
9
9
 
10
10
  class SiteDiff
11
+ # SiteDiff Sanitizer.
11
12
  class Sanitizer
12
13
  class InvalidSanitization < SiteDiffException; end
13
14
 
14
15
  TOOLS = {
15
16
  array: %w[dom_transform sanitization],
16
- scalar: %w[selector remove_spacing]
17
+ scalar: %w[selector remove_spacing ignore_whitespace]
17
18
  }.freeze
18
- DOM_TRANSFORMS = Set.new(%w[remove unwrap_root unwrap remove_class])
19
+ DOM_TRANSFORMS = Set.new(%w[remove strip unwrap_root unwrap remove_class])
19
20
 
21
+ ##
22
+ # Creates a Sanitizer.
20
23
  def initialize(html, config, opts = {})
21
24
  @html = html
22
25
  @config = config
23
26
  @opts = opts
24
27
  end
25
28
 
29
+ ##
30
+ # Performs sanitization.
26
31
  def sanitize
27
32
  return '' if @html == '' # Quick return on empty input
28
33
 
@@ -30,7 +35,7 @@ class SiteDiff
30
35
  @html = nil
31
36
 
32
37
  remove_spacing
33
- selector
38
+ regions || selector
34
39
  dom_transforms
35
40
  regexps
36
41
 
@@ -56,13 +61,13 @@ class SiteDiff
56
61
  def canonicalize_rule(name)
57
62
  (rules = @config[name]) || (return nil)
58
63
 
59
- if rules[0]&.respond_to?(:[]) && rules[0]['value']
60
- # Already an array
64
+ # Already an array? Do nothing.
65
+ if rules[0].respond_to?('each') && rules[0]&.fetch('value')
66
+ # If it is a hash, put it in an array.
61
67
  elsif rules['value']
62
- # Hash, put it in an array
63
68
  rules = [rules]
69
+ # If it is a scalar value, put it in an array.
64
70
  else
65
- # Scalar, put it in a hash
66
71
  rules = [{ 'value' => rules }]
67
72
  end
68
73
 
@@ -79,6 +84,13 @@ class SiteDiff
79
84
  Sanitizer.remove_node_spacing(@node) if rule['value']
80
85
  end
81
86
 
87
+ # Perform 'regions' action, don't perform 'selector' if regions exist.
88
+ def regions
89
+ return unless validate_regions
90
+
91
+ @node = select_regions(@node, @config['regions'], @opts[:output])
92
+ end
93
+
82
94
  # Perform 'selector' action, to choose a new root
83
95
  def selector
84
96
  (rule = canonicalize_rule('selector')) || return
@@ -99,7 +111,13 @@ class SiteDiff
99
111
  # Prevent potential UTF-8 encoding errors by removing bytes
100
112
  # Not the only solution. An alternative is to return the
101
113
  # string unmodified.
102
- @html = @html.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')
114
+ @html = @html.encode(
115
+ 'UTF-8',
116
+ 'binary',
117
+ invalid: :replace,
118
+ undef: :replace,
119
+ replace: ''
120
+ )
103
121
  global.each { |r| r.apply(@html) }
104
122
  end
105
123
 
@@ -124,6 +142,20 @@ class SiteDiff
124
142
  end
125
143
  end
126
144
 
145
+ # Restructure the node into regions.
146
+ def select_regions(node, regions, output)
147
+ regions = output.map do |name|
148
+ selector = get_named_region(regions, name)['selector']
149
+ region = Nokogiri::XML.fragment("<region id=\"#{name}\"></region>").at_css('region')
150
+ matching = node.css(selector)
151
+ matching.each { |m| region.add_child m }
152
+ region
153
+ end
154
+ node = Nokogiri::HTML.fragment('')
155
+ regions.each { |r| node.add_child r }
156
+ node
157
+ end
158
+
127
159
  # Get a fragment consisting of the elements matching the selector(s)
128
160
  def self.select_fragments(node, sel)
129
161
  # When we choose a new root, we always become a DocumentFragment,
@@ -151,7 +183,13 @@ class SiteDiff
151
183
  # Prevent potential UTF-8 encoding errors by removing invalid bytes.
152
184
  # Not the only solution.
153
185
  # An alternative is to return the string unmodified.
154
- str = str.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')
186
+ str = str.encode(
187
+ 'UTF-8',
188
+ 'binary',
189
+ invalid: :replace,
190
+ undef: :replace,
191
+ replace: ''
192
+ )
155
193
  # Remove xml declaration and <html> tags
156
194
  str.sub!(/\A<\?xml.*$\n/, '')
157
195
  str.sub!(/\A^<html>$\n/, '')
@@ -164,11 +202,15 @@ class SiteDiff
164
202
  # Remove blank lines
165
203
  str.gsub!(/^\s*$\n/, '')
166
204
 
205
+ # Remove DOS newlines
206
+ str.gsub!(/\x0D$/, '')
207
+ str.gsub!(/&#13;$/, '')
208
+
167
209
  str
168
210
  end
169
211
 
170
212
  # Parse HTML into a node
171
- def self.domify(str, force_doc = false)
213
+ def self.domify(str, force_doc: false)
172
214
  if force_doc || /<!DOCTYPE/.match(str[0, 512])
173
215
  Nokogiri::HTML(str)
174
216
  else
@@ -182,10 +224,37 @@ class SiteDiff
182
224
  obj
183
225
  # node or fragment
184
226
  elsif Nokogiri::XML::Node == obj.class || Nokogiri::HTML::DocumentFragment == obj.class
185
- domify(obj.to_s, true)
227
+ domify(obj.to_s, force_doc: true)
186
228
  else
187
- to_document(domify(obj, false))
229
+ to_document(domify(obj, force_doc: false))
230
+ end
231
+ end
232
+
233
+ private
234
+
235
+ # Validate `regions` and `output` from config.
236
+ def validate_regions
237
+ return false unless @config['regions'].is_a?(Array)
238
+
239
+ return false unless @opts[:output].is_a?(Array)
240
+
241
+ regions = @config['regions']
242
+ output = @opts[:output]
243
+ regions.each do |region|
244
+ return false unless region.key?('name') && region.key?('selector')
245
+ end
246
+
247
+ # Check that each named output has an associated region.
248
+ output.each do |name|
249
+ return false unless get_named_region(regions, name)
188
250
  end
251
+
252
+ true
253
+ end
254
+
255
+ # Return the selector from a named region.
256
+ def get_named_region(regions, name)
257
+ regions.find { |region| region['name'] == name }
189
258
  end
190
259
  end
191
260
  end
@@ -7,19 +7,28 @@ require 'addressable/uri'
7
7
  class SiteDiff
8
8
  class SiteDiffReadFailure < SiteDiffException; end
9
9
 
10
+ # SiteDiff URI Wrapper.
10
11
  class UriWrapper
12
+ # TODO: Move these CURL OPTS to Config.DEFAULT_CONFIG.
11
13
  DEFAULT_CURL_OPTS = {
12
- connecttimeout: 3, # Don't hang on servers that don't exist
13
- followlocation: true, # Follow HTTP redirects (code 301 and 302)
14
+ # Don't hang on servers that don't exist.
15
+ connecttimeout: 3,
16
+ # Follow HTTP redirects (code 301 and 302).
17
+ followlocation: true,
14
18
  headers: {
15
19
  'User-Agent' => 'Sitediff - https://github.com/evolvingweb/sitediff'
16
- }
20
+ },
21
+ # always accept SSL certs
22
+ ssl_verifypeer: false,
23
+ ssl_verifyhost: 0
17
24
  }.freeze
18
25
 
19
26
  # This lets us treat errors or content as one object
20
27
  class ReadResult
21
28
  attr_accessor :encoding, :content, :error_code, :error
22
29
 
30
+ ##
31
+ # Creates a ReadResult.
23
32
  def initialize(content = nil, encoding = 'utf-8')
24
33
  @content = content
25
34
  @encoding = encoding
@@ -27,15 +36,19 @@ class SiteDiff
27
36
  @error_code = nil
28
37
  end
29
38
 
30
- def self.error(err, code = nil)
39
+ ##
40
+ # Creates a ReadResult with an error.
41
+ def self.error(message, code = nil)
31
42
  res = new
32
43
  res.error_code = code
33
- res.error = err
44
+ res.error = message
34
45
  res
35
46
  end
36
47
  end
37
48
 
38
- def initialize(uri, curl_opts = DEFAULT_CURL_OPTS, debug = true)
49
+ ##
50
+ # Creates a UriWrapper.
51
+ def initialize(uri, curl_opts = DEFAULT_CURL_OPTS, debug: true)
39
52
  @uri = uri.respond_to?(:scheme) ? uri : Addressable::URI.parse(uri)
40
53
  # remove trailing '/'s from local URIs
41
54
  @uri.path.gsub!(%r{/*$}, '') if local?
@@ -43,14 +56,20 @@ class SiteDiff
43
56
  @debug = debug
44
57
  end
45
58
 
59
+ ##
60
+ # Returns the "user" part of the URI.
46
61
  def user
47
62
  @uri.user
48
63
  end
49
64
 
65
+ ##
66
+ # Returns the "password" part of the URI.
50
67
  def password
51
68
  @uri.password
52
69
  end
53
70
 
71
+ ##
72
+ # Converts the URI to a string.
54
73
  def to_s
55
74
  uri = @uri.dup
56
75
  uri.user = nil
@@ -58,19 +77,22 @@ class SiteDiff
58
77
  uri.to_s
59
78
  end
60
79
 
80
+ ##
61
81
  # Is this a local filesystem path?
62
82
  def local?
63
83
  @uri.scheme.nil?
64
84
  end
65
85
 
86
+ ## What does this one do?
66
87
  # FIXME: this is not used anymore
67
- def +(path)
88
+ def +(other)
68
89
  # 'path' for SiteDiff includes (parts of) path, query, and fragment.
69
90
  sep = ''
70
91
  sep = '/' if local? || @uri.path.empty?
71
- self.class.new(@uri.to_s + sep + path)
92
+ self.class.new(@uri.to_s + sep + other)
72
93
  end
73
94
 
95
+ ##
74
96
  # Reads a file and yields to the completion handler, see .queue()
75
97
  def read_file
76
98
  File.open(@uri.to_s, 'r:UTF-8') { |f| yield ReadResult.new(f.read) }
@@ -81,10 +103,9 @@ class SiteDiff
81
103
  # Returns the encoding of an HTTP response from headers , nil if not
82
104
  # specified.
83
105
  def charset_encoding(http_headers)
84
- if (content_type = http_headers['Content-Type'])
85
- if (md = /;\s*charset=([-\w]*)/.match(content_type))
86
- md[1]
87
- end
106
+ content_type = http_headers['Content-Type']
107
+ if (md = /;\s*charset=([-\w]*)/.match(content_type))
108
+ md[1]
88
109
  end
89
110
  end
90
111
 
@@ -95,7 +116,7 @@ class SiteDiff
95
116
  def typhoeus_request
96
117
  params = @curl_opts.dup
97
118
  # Allow basic auth
98
- params[:userpwd] = @uri.user + ':' + @uri.password if @uri.user
119
+ params[:userpwd] = "#{@uri.user}: #{@uri.password}" if @uri.user
99
120
 
100
121
  req = Typhoeus::Request.new(to_s, params)
101
122
 
@@ -114,25 +135,34 @@ class SiteDiff
114
135
  rescue ArgumentError => e
115
136
  raise if @debug
116
137
 
117
- yield ReadResult.error("Parsing error for #{@uri}: #{e.message}")
118
- rescue => e
138
+ yield ReadResult.error(
139
+ "Parsing error for #{@uri}: #{e.message}"
140
+ )
141
+ rescue StandardError => e
119
142
  raise if @debug
120
143
 
121
- yield ReadResult.error("Unknown parsing error for #{@uri}: #{e.message}")
144
+ yield ReadResult.error(
145
+ "Unknown parsing error for #{@uri}: #{e.message}"
146
+ )
122
147
  end
123
148
  end
124
149
 
125
150
  req.on_failure do |resp|
126
151
  if resp&.status_message
127
- msg = resp.status_message
128
- yield ReadResult.error("HTTP error when loading #{@uri}: #{msg}",
129
- resp.response_code)
152
+ yield ReadResult.error(
153
+ "HTTP error when loading #{@uri} : [#{resp.response_code}] #{resp.status_message}",
154
+ resp.response_code
155
+ )
130
156
  elsif (msg = resp.options[:return_code])
131
- yield ReadResult.error("Connection error when loading #{@uri}: #{msg}",
132
- resp.response_code)
157
+ yield ReadResult.error(
158
+ "Connection error when loading #{@uri} : [#{resp.options[:return_code]}] #{resp.status_message} #{msg}",
159
+ resp.response_code
160
+ )
133
161
  else
134
- yield ReadResult.error("Unknown error when loading #{@uri}: #{msg}",
135
- resp.response_code)
162
+ yield ReadResult.error(
163
+ "Unknown error when loading #{@uri} : [#{resp.response_code}] #{resp.status_message}",
164
+ resp.response_code
165
+ )
136
166
  end
137
167
  end
138
168
 
@@ -152,5 +182,17 @@ class SiteDiff
152
182
  hydra.queue(typhoeus_request(&handler))
153
183
  end
154
184
  end
185
+
186
+ ##
187
+ # Canonicalize a path.
188
+ #
189
+ # @param [String] path
190
+ # A base relative path. Example: /foo/bar
191
+ def self.canonicalize(path)
192
+ # Ignore trailing slashes for all paths except "/" (front page).
193
+ path = path.chomp('/') unless path == '/'
194
+ # If the path is empty, assume that it's the front page.
195
+ path.empty? ? '/' : path
196
+ end
155
197
  end
156
198
  end
@@ -1,18 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'sitediff'
4
+ require 'sitediff/report'
4
5
  require 'sitediff/webserver'
5
6
  require 'erb'
6
7
 
7
8
  class SiteDiff
8
9
  class Webserver
10
+ # SiteDiff Result Server.
9
11
  class ResultServer < Webserver
10
12
  # Display a page from the cache
11
13
  class CacheServlet < WEBrick::HTTPServlet::AbstractServlet
14
+ ##
15
+ # Creates a Cache Servlet.
12
16
  def initialize(_server, cache)
17
+ super
13
18
  @cache = cache
14
19
  end
15
20
 
21
+ ##
22
+ # Performs a GET request.
16
23
  def do_GET(req, res)
17
24
  path = req.path_info
18
25
  (md = %r{^/([^/]+)(/.*)$}.match(path)) ||
@@ -29,13 +36,19 @@ class SiteDiff
29
36
  end
30
37
  end
31
38
 
32
- # Display two pages side by side
39
+ ##
40
+ # Display two pages side by side.
33
41
  class SideBySideServlet < WEBrick::HTTPServlet::AbstractServlet
42
+ ##
43
+ # Creates a Side By Side Servlet.
34
44
  def initialize(_server, cache, settings)
45
+ super
35
46
  @cache = cache
36
47
  @settings = settings
37
48
  end
38
49
 
50
+ ##
51
+ # Generates URLs for a given path.
39
52
  def urls(path)
40
53
  %w[before after].map do |tag|
41
54
  base = @settings[tag]
@@ -44,6 +57,8 @@ class SiteDiff
44
57
  end
45
58
  end
46
59
 
60
+ ##
61
+ # Performs a GET request.
47
62
  def do_GET(req, res)
48
63
  path = req.path_info
49
64
  before, after = *urls(path)
@@ -54,52 +69,29 @@ class SiteDiff
54
69
  end
55
70
  end
56
71
 
57
- # Run sitediff command from browser. Probably dangerous in general.
58
- class RunServlet < WEBrick::HTTPServlet::AbstractServlet
59
- def initialize(_server, dir)
60
- @dir = dir
61
- end
62
-
63
- def do_GET(req, res)
64
- path = req.path_info
65
- if path != '/diff'
66
- res['content-type'] = 'text/plain'
67
- res.body = 'ERROR: Only /run/diff is supported by the /run API at the moment'
68
- return
69
- end
70
- # Thor assumes only one command is called and some values like
71
- # `options` are share across all SiteDiff::Cli instances so
72
- # we can't just call SiteDiff::Cli.new().diff
73
- # This is likely to go very wrong depending on how `sitediff serve`
74
- # was actually called
75
- cmd = "#{$PROGRAM_NAME} diff -C #{@dir} --cached=all"
76
- system(cmd)
77
-
78
- # Could also add a message to indicate success/failure
79
- # But for the moment, all our files are static
80
- res.set_redirect(WEBrick::HTTPStatus::Found,
81
- "/files/#{SiteDiff::REPORT_FILE}")
82
- end
83
- end
84
-
72
+ ##
73
+ # Creates a Result Server.
85
74
  def initialize(port, dir, opts = {})
86
- unless File.exist?(File.join(dir, SiteDiff::SETTINGS_FILE))
75
+ unless File.exist?(File.join(dir, Report::SETTINGS_FILE))
87
76
  raise SiteDiffException,
88
77
  "Please run 'sitediff diff' before running 'sitediff serve'"
89
78
  end
90
79
 
91
- @settings = YAML.load_file(File.join(dir, SiteDiff::SETTINGS_FILE))
80
+ @settings = YAML.load_file(File.join(dir, Report::SETTINGS_FILE))
81
+ puts @settings
92
82
  @cache = opts[:cache]
93
83
  super(port, [dir], opts)
94
84
  end
95
85
 
86
+ ##
87
+ # TODO: Document what this method does.
96
88
  def server(opts)
97
89
  dir = opts.delete(:DocumentRoot)
98
90
  srv = super(opts)
99
91
  srv.mount_proc('/') do |req, res|
100
92
  if req.path == '/'
101
93
  res.set_redirect(WEBrick::HTTPStatus::Found,
102
- "/files/#{SiteDiff::REPORT_FILE}")
94
+ "/files/#{Report::REPORT_FILE_HTML}")
103
95
  else
104
96
  res.set_redirect(WEBrick::HTTPStatus::TemporaryRedirect,
105
97
  "#{@settings['after']}#{req.path}")
@@ -109,10 +101,11 @@ class SiteDiff
109
101
  srv.mount('/files', WEBrick::HTTPServlet::FileHandler, dir, true)
110
102
  srv.mount('/cache', CacheServlet, @cache)
111
103
  srv.mount('/sidebyside', SideBySideServlet, @cache, @settings)
112
- srv.mount('/run', RunServlet, dir)
113
104
  srv
114
105
  end
115
106
 
107
+ ##
108
+ # Sets up the server.
116
109
  def setup
117
110
  super
118
111
  root = uris.first
@@ -120,6 +113,8 @@ class SiteDiff
120
113
  open_in_browser(root) if @opts[:browse]
121
114
  end
122
115
 
116
+ ##
117
+ # Opens a URL in a browser.
123
118
  def open_in_browser(url)
124
119
  commands = %w[xdg-open open]
125
120
  cmd = commands.find { |c| which(c) }
@@ -127,6 +122,8 @@ class SiteDiff
127
122
  cmd
128
123
  end
129
124
 
125
+ ##
126
+ # TODO: Document what this method does.
130
127
  def which(cmd)
131
128
  ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
132
129
  file = File.join(path, cmd)
@@ -3,13 +3,15 @@
3
3
  require 'webrick'
4
4
 
5
5
  class SiteDiff
6
+ # SiteDiff Web Server.
6
7
  class Webserver
7
- # Simple webserver for testing purposes
8
+ # Simple web server for testing purposes.
8
9
  DEFAULT_PORT = 13_080
9
10
 
10
11
  attr_accessor :ports
11
12
 
12
- # Serve a list of directories
13
+ ##
14
+ # Serve a list of directories.
13
15
  def initialize(start_port, dirs, opts = {})
14
16
  start_port ||= DEFAULT_PORT
15
17
  @ports = (start_port...(start_port + dirs.size)).to_a
@@ -25,14 +27,20 @@ class SiteDiff
25
27
  end
26
28
  end
27
29
 
30
+ ##
31
+ # Kills the server.
28
32
  def kill
29
33
  @threads.each(&:kill)
30
34
  end
31
35
 
36
+ ##
37
+ # Waits for the server.
32
38
  def wait
33
39
  @threads.each(&:join)
34
40
  end
35
41
 
42
+ ##
43
+ # Maps URIs to defined ports and returns a list of URIs.
36
44
  def uris
37
45
  ports.map { |p| "http://localhost:#{p}" }
38
46
  end
@@ -63,20 +71,24 @@ class SiteDiff
63
71
 
64
72
  public
65
73
 
74
+ # SiteDiff Fixture Server.
66
75
  class FixtureServer < Webserver
67
76
  PORT = DEFAULT_PORT + 1
68
- BASE = 'spec/fixtures/ruby-doc.org'
77
+ BASE = 'spec/sites/ruby-doc.org'
69
78
  NAMES = %w[core-1.9.3 core-2.0].freeze
70
79
 
80
+ # Initialize web server.
71
81
  def initialize(port = PORT, base = BASE, names = NAMES)
72
82
  dirs = names.map { |n| File.join(base, n) }
73
83
  super(port, dirs, quiet: true)
74
84
  end
75
85
 
86
+ # Get the before site uri.
76
87
  def before
77
88
  uris.first
78
89
  end
79
90
 
91
+ # Get the after site uri.
80
92
  def after
81
93
  uris.last
82
94
  end