sitediff 0.0.1
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/bin/sitediff +10 -0
- data/lib/sitediff.rb +130 -0
- data/lib/sitediff/cli.rb +90 -0
- data/lib/sitediff/config.rb +154 -0
- data/lib/sitediff/diff.rb +37 -0
- data/lib/sitediff/files/diff.html.erb +11 -0
- data/lib/sitediff/files/html_report.html.erb +47 -0
- data/lib/sitediff/files/pretty_print.xsl +9 -0
- data/lib/sitediff/files/sitediff.css +42 -0
- data/lib/sitediff/result.rb +74 -0
- data/lib/sitediff/sanitize.rb +193 -0
- data/lib/sitediff/uriwrapper.rb +118 -0
- data/lib/sitediff/util/cache.rb +32 -0
- data/lib/sitediff/util/webserver.rb +77 -0
- metadata +131 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 1dc3a624b91cd4b7ef1c926116630cd795532024
|
4
|
+
data.tar.gz: e49f227ae303f574b704ffe3a226f79a120ae30f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 90ca5508b834d32ac7c96aa6a94a6aa8488921e978e76890e142b1249da20bc620ddcfa237f3defc1e6928d83dd0a22583c9dded150855c320f94140e1bffdf1
|
7
|
+
data.tar.gz: 24bf7969b6f17c269bb407d1ff1684f6556318d0cfa7c6c92a8327ddd0d86ee4f153778affa4d1ef115e47a3b69b31b4dac5f01ed8b7f464a05fd98f9f98212b
|
data/bin/sitediff
ADDED
data/lib/sitediff.rb
ADDED
@@ -0,0 +1,130 @@
|
|
1
|
+
#!/bin/env ruby
|
2
|
+
require 'sitediff/cli.rb'
|
3
|
+
require 'sitediff/config.rb'
|
4
|
+
require 'sitediff/result.rb'
|
5
|
+
require 'sitediff/uriwrapper'
|
6
|
+
require 'sitediff/util/cache'
|
7
|
+
require 'typhoeus'
|
8
|
+
require 'rainbow'
|
9
|
+
|
10
|
+
class SiteDiff
|
11
|
+
# path to misc. static files (e.g. erb, css files)
|
12
|
+
FILES_DIR = File.join(File.dirname(__FILE__), 'sitediff', 'files')
|
13
|
+
|
14
|
+
# subdirectory containing all failing diffs
|
15
|
+
DIFFS_DIR = 'diffs'
|
16
|
+
|
17
|
+
# label will be colorized and str will not be.
|
18
|
+
# type dictates the color: can be :success, :error, or :failure
|
19
|
+
def self.log(str, type=nil, label=nil)
|
20
|
+
label = label ? "[sitediff] #{label}" : '[sitediff]'
|
21
|
+
bg = fg = nil
|
22
|
+
case type
|
23
|
+
when :success
|
24
|
+
bg = :green
|
25
|
+
fg = :black
|
26
|
+
when :failure
|
27
|
+
bg = :red
|
28
|
+
when :error
|
29
|
+
bg = :yellow
|
30
|
+
fg = :black
|
31
|
+
end
|
32
|
+
label = Rainbow(label)
|
33
|
+
label = label.bg(bg) if bg
|
34
|
+
label = label.fg(fg) if fg
|
35
|
+
puts label + ' ' + str
|
36
|
+
end
|
37
|
+
|
38
|
+
attr_reader :config, :results
|
39
|
+
def before
|
40
|
+
@config.before['url']
|
41
|
+
end
|
42
|
+
def after
|
43
|
+
@config.after['url']
|
44
|
+
end
|
45
|
+
|
46
|
+
def cache=(file)
|
47
|
+
# FIXME: Non-global cache would be nice
|
48
|
+
return unless file
|
49
|
+
if Gem::Version.new(Typhoeus::VERSION) >= Gem::Version.new('0.6.4')
|
50
|
+
Typhoeus::Config.cache = SiteDiff::Util::Cache.new(file)
|
51
|
+
else
|
52
|
+
# Bug, see: https://github.com/typhoeus/typhoeus/pull/296
|
53
|
+
SiteDiff::log("Cache unsupported on Typhoeus version < 0.6.4", :failure)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def initialize(config, cache)
|
58
|
+
config.validate
|
59
|
+
@config = config
|
60
|
+
self.cache = cache
|
61
|
+
end
|
62
|
+
|
63
|
+
# Sanitize an HTML string based on configuration for either before or after
|
64
|
+
def sanitize(html, pos)
|
65
|
+
Sanitize::sanitize(html, @config.send(pos))
|
66
|
+
end
|
67
|
+
|
68
|
+
# Queues fetching before and after URLs with a Typhoeus::Hydra instance
|
69
|
+
#
|
70
|
+
# Upon completion of both before and after, prints and saves the diff to
|
71
|
+
# @results.
|
72
|
+
def queue_read(hydra, path)
|
73
|
+
# ( :before | after ) => ReadResult object
|
74
|
+
reads = {}
|
75
|
+
[:before, :after].each do |pos|
|
76
|
+
uri = UriWrapper.new(send(pos) + path)
|
77
|
+
|
78
|
+
uri.queue(hydra) do |res|
|
79
|
+
reads[pos] = res
|
80
|
+
next unless reads.size == 2
|
81
|
+
|
82
|
+
# we have read both before and after; calculate diff
|
83
|
+
if error = reads[:before].error || reads[:after].error
|
84
|
+
diff = Result.new(path, nil, nil, error)
|
85
|
+
else
|
86
|
+
diff = Result.new(path, sanitize(reads[:before].content, :before),
|
87
|
+
sanitize(reads[:after].content,:after), nil)
|
88
|
+
end
|
89
|
+
diff.log
|
90
|
+
@results[path] = diff
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Perform the comparison
|
96
|
+
def run
|
97
|
+
# Map of path -> Result object, queue_read sets callbacks to populate this
|
98
|
+
@results = {}
|
99
|
+
|
100
|
+
hydra = Typhoeus::Hydra.new(max_concurrency: 3)
|
101
|
+
@config.paths.each { |path| queue_read(hydra, path) }
|
102
|
+
hydra.run
|
103
|
+
|
104
|
+
# Order by original path order
|
105
|
+
@results = @config.paths.map { |p| @results[p] }
|
106
|
+
end
|
107
|
+
|
108
|
+
# Dump results to disk
|
109
|
+
def dump(dir, report_before, report_after, failing_paths)
|
110
|
+
report_before ||= before
|
111
|
+
report_after ||= after
|
112
|
+
FileUtils.mkdir_p(dir)
|
113
|
+
|
114
|
+
# store diffs of each failing case, first wipe out existing diffs
|
115
|
+
diff_dir = File.join(dir, DIFFS_DIR)
|
116
|
+
FileUtils.rm_rf(diff_dir)
|
117
|
+
results.each { |r| r.dump(dir) if r.status == Result::STATUS_FAILURE }
|
118
|
+
SiteDiff::log "All diff files were dumped inside #{dir}"
|
119
|
+
|
120
|
+
# store failing paths
|
121
|
+
SiteDiff::log "Writing failures to #{failing_paths}"
|
122
|
+
File.open(failing_paths, 'w') do |f|
|
123
|
+
results.each { |r| f.puts r.path unless r.success? }
|
124
|
+
end
|
125
|
+
|
126
|
+
# create report of results
|
127
|
+
report = Diff::generate_html_report(results, report_before, report_after)
|
128
|
+
File.open(File.join(dir, "/report.html") , 'w') { |f| f.write(report) }
|
129
|
+
end
|
130
|
+
end
|
data/lib/sitediff/cli.rb
ADDED
@@ -0,0 +1,90 @@
|
|
1
|
+
require 'thor'
|
2
|
+
require 'sitediff/diff'
|
3
|
+
require 'sitediff/sanitize'
|
4
|
+
require 'sitediff/util/webserver'
|
5
|
+
require 'open-uri'
|
6
|
+
require 'uri'
|
7
|
+
|
8
|
+
class SiteDiff
|
9
|
+
class Cli < Thor
|
10
|
+
# Thor, by default, exits with 0 no matter what!
|
11
|
+
def self.exit_on_failure?
|
12
|
+
true
|
13
|
+
end
|
14
|
+
|
15
|
+
# Thor, by default, does not raise an error for use of unknown options.
|
16
|
+
def self.check_unknown_options?(config)
|
17
|
+
true
|
18
|
+
end
|
19
|
+
|
20
|
+
option 'dump-dir',
|
21
|
+
:type => :string,
|
22
|
+
:default => File.join('.', 'output'),
|
23
|
+
:desc => "Location to write the output to."
|
24
|
+
option 'paths',
|
25
|
+
:type => :string,
|
26
|
+
:desc => 'Paths are read (one at a line) from PATHS: ' +
|
27
|
+
'useful for iterating over sanitization rules',
|
28
|
+
:aliases => '--paths-from-file'
|
29
|
+
option 'before',
|
30
|
+
:type => :string,
|
31
|
+
:desc => "URL used to fetch the before HTML. Acts as a prefix to specified paths",
|
32
|
+
:aliases => '--before-url'
|
33
|
+
option 'after',
|
34
|
+
:type => :string,
|
35
|
+
:desc => "URL used to fetch the after HTML. Acts as a prefix to specified paths.",
|
36
|
+
:aliases => '--after-url'
|
37
|
+
option 'before-report',
|
38
|
+
:type => :string,
|
39
|
+
:desc => "Before URL to use for reporting purposes. Useful if port forwarding.",
|
40
|
+
:aliases => '--before-url-report'
|
41
|
+
option 'after-report',
|
42
|
+
:type => :string,
|
43
|
+
:desc => "After URL to use for reporting purposes. Useful if port forwarding.",
|
44
|
+
:aliases => '--after-url-report'
|
45
|
+
option 'cache',
|
46
|
+
:type => :string,
|
47
|
+
:desc => "Filename to use for caching requests.",
|
48
|
+
:lazy_default => 'cache.db'
|
49
|
+
desc "diff [OPTIONS] [CONFIGFILES]", "Perform systematic diff on given URLs"
|
50
|
+
def diff(*config_files)
|
51
|
+
config = SiteDiff::Config.new(config_files)
|
52
|
+
|
53
|
+
# override config based on options
|
54
|
+
if paths_file = options['paths']
|
55
|
+
unless File.exists? paths_file
|
56
|
+
raise Config::InvalidConfig,
|
57
|
+
"Paths file '#{paths_file}' not found!"
|
58
|
+
end
|
59
|
+
SiteDiff::log "Reading paths from: #{paths_file}"
|
60
|
+
config.paths = File.readlines(paths_file)
|
61
|
+
end
|
62
|
+
config.before['url'] = options['before'] if options['before']
|
63
|
+
config.after['url'] = options['after'] if options['after']
|
64
|
+
|
65
|
+
sitediff = SiteDiff.new(config, options['cache'])
|
66
|
+
sitediff.run
|
67
|
+
|
68
|
+
failing_paths = File.join(options['dump-dir'], 'failures.txt')
|
69
|
+
sitediff.dump(options['dump-dir'], options['before-report'],
|
70
|
+
options['after-report'], failing_paths)
|
71
|
+
rescue Config::InvalidConfig => e
|
72
|
+
SiteDiff.log "Invalid configuration: #{e.message}", :failure
|
73
|
+
end
|
74
|
+
|
75
|
+
option :port,
|
76
|
+
:type => :numeric,
|
77
|
+
:default => SiteDiff::Util::Webserver::DEFAULT_PORT,
|
78
|
+
:desc => 'The port to serve on'
|
79
|
+
option :directory,
|
80
|
+
:type => :string,
|
81
|
+
:default => 'output',
|
82
|
+
:desc => 'The directory to serve',
|
83
|
+
:aliases => '--dump-dir'
|
84
|
+
desc "serve [OPTIONS]", "Serve the sitediff output directory over HTTP"
|
85
|
+
def serve
|
86
|
+
SiteDiff::Util::Webserver.serve(options[:port], options[:directory],
|
87
|
+
:announce => true).wait
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
class SiteDiff
|
4
|
+
class Config
|
5
|
+
|
6
|
+
# keys allowed in configuration files
|
7
|
+
CONF_KEYS = Sanitize::TOOLS.values.flatten(1) +
|
8
|
+
%w[paths before after before_url after_url includes]
|
9
|
+
|
10
|
+
class InvalidConfig < Exception; end
|
11
|
+
|
12
|
+
# Takes a Hash and normalizes it to the following form by merging globals
|
13
|
+
# into before and after. A normalized config Hash looks like this:
|
14
|
+
#
|
15
|
+
# paths:
|
16
|
+
# - /about
|
17
|
+
#
|
18
|
+
# before:
|
19
|
+
# url: http://before
|
20
|
+
# selector: body
|
21
|
+
# dom_transform:
|
22
|
+
# - type: remove
|
23
|
+
# selector: script
|
24
|
+
#
|
25
|
+
# after:
|
26
|
+
# url: http://after
|
27
|
+
# selector: body
|
28
|
+
#
|
29
|
+
def self.normalize(conf)
|
30
|
+
tools = Sanitize::TOOLS
|
31
|
+
|
32
|
+
# merge globals
|
33
|
+
%w[before after].each do |pos|
|
34
|
+
conf[pos] ||= {}
|
35
|
+
tools[:array].each do |key|
|
36
|
+
conf[pos][key] ||= []
|
37
|
+
conf[pos][key] += conf[key] if conf[key]
|
38
|
+
end
|
39
|
+
tools[:scalar].each {|key| conf[pos][key] ||= conf[key]}
|
40
|
+
conf[pos]['url'] ||= conf[pos + '_url']
|
41
|
+
end
|
42
|
+
# normalize paths
|
43
|
+
conf['paths'] = Config::normalize_paths(conf['paths'])
|
44
|
+
|
45
|
+
conf.select {|k,v| %w[before after paths].include? k}
|
46
|
+
end
|
47
|
+
|
48
|
+
# Merges two normalized Hashes according to the following rules:
|
49
|
+
# 1 paths are merged as arrays.
|
50
|
+
# 2 before and after: for each subhash H (e.g. ['before']['dom_transform']):
|
51
|
+
# a) if first[H] and second[H] are expected to be arrays, their values
|
52
|
+
# are merged as such,
|
53
|
+
# b) if first[H] and second[H] are expected to be scalars, the value for
|
54
|
+
# second[H] is kept if and only if first[H] is nil.
|
55
|
+
#
|
56
|
+
# For example, merge(h1, h2) results in h3:
|
57
|
+
#
|
58
|
+
# (h1) before: {selector: foo, sanitization: [pattern: foo]}
|
59
|
+
# (h2) before: {selector: bar, sanitization: [pattern: bar]}
|
60
|
+
# (h3) before: {selector: foo, sanitization: [pattern: foo, pattern: bar]}
|
61
|
+
def self.merge(first, second)
|
62
|
+
result = { 'paths' => {}, 'before' => {}, 'after' => {} }
|
63
|
+
result['paths'] = (first['paths'] || []) + (second['paths'] || []) # rule 1
|
64
|
+
%w[before after].each do |pos|
|
65
|
+
unless first[pos]
|
66
|
+
result[pos] = second[pos] || {}
|
67
|
+
next
|
68
|
+
end
|
69
|
+
result[pos] = first[pos].merge!(second[pos]) do |key, a, b|
|
70
|
+
if Sanitize::TOOLS[:array].include? key # rule 2a
|
71
|
+
result[pos][key] = (a || []) + (b|| [])
|
72
|
+
else
|
73
|
+
result[pos][key] = a || b # rule 2b
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
result
|
78
|
+
end
|
79
|
+
|
80
|
+
def initialize(files)
|
81
|
+
@config = {'paths' => [], 'before' => {}, 'after' => {} }
|
82
|
+
files.each do |file|
|
83
|
+
@config = Config::merge(@config, Config::load_conf(file))
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def before
|
88
|
+
@config['before']
|
89
|
+
end
|
90
|
+
def after
|
91
|
+
@config['after']
|
92
|
+
end
|
93
|
+
|
94
|
+
def paths
|
95
|
+
@config['paths']
|
96
|
+
end
|
97
|
+
def paths=(paths)
|
98
|
+
@config['paths'] = Config::normalize_paths(paths)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Checks if the configuration is usable for diff-ing.
|
102
|
+
def validate
|
103
|
+
raise InvalidConfig, "Undefined 'before' base URL." unless before['url']
|
104
|
+
raise InvalidConfig, "Undefined 'after' base URL." unless after['url']
|
105
|
+
raise InvalidConfig, "Undefined 'paths'." unless (paths and !paths.empty?)
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def self.normalize_paths(paths)
|
111
|
+
paths ||= []
|
112
|
+
return paths.map { |p| (p[0] == '/' ? p : "/#{p}").chomp }
|
113
|
+
end
|
114
|
+
|
115
|
+
# reads a YAML file and raises an InvalidConfig if the file is not valid.
|
116
|
+
def self.load_raw_yaml(file)
|
117
|
+
SiteDiff::log "Reading config file: #{file}"
|
118
|
+
conf = YAML.load_file(file) || {}
|
119
|
+
unless conf.is_a? Hash
|
120
|
+
raise InvalidConfig, "Invalid configuration file: '#{file}'"
|
121
|
+
end
|
122
|
+
conf.each do |k,v|
|
123
|
+
unless CONF_KEYS.include? k
|
124
|
+
raise InvalidConfig, "Unknown configuration key (#{file}): '#{k}'"
|
125
|
+
end
|
126
|
+
end
|
127
|
+
conf
|
128
|
+
end
|
129
|
+
|
130
|
+
# loads a single YAML configuration file, merges all its 'included' files
|
131
|
+
# and returns a normalized Hash.
|
132
|
+
def self.load_conf(file, visited=[])
|
133
|
+
# don't get fooled by a/../a/ or symlinks
|
134
|
+
file = File.realpath(file)
|
135
|
+
if visited.include? file
|
136
|
+
raise InvalidConfig, "Circular dependency: #{file}"
|
137
|
+
end
|
138
|
+
|
139
|
+
conf = load_raw_yaml(file) # not normalized yet
|
140
|
+
visited << file
|
141
|
+
|
142
|
+
# normalize and merge includes
|
143
|
+
includes = conf['includes'] || []
|
144
|
+
conf = Config::normalize(conf)
|
145
|
+
includes.each do |dep|
|
146
|
+
# include paths are relative to the including file.
|
147
|
+
dep = File.join(File.dirname(file), dep)
|
148
|
+
conf = Config::merge(conf, load_conf(dep, visited))
|
149
|
+
end
|
150
|
+
conf
|
151
|
+
end
|
152
|
+
|
153
|
+
end
|
154
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'diffy'
|
2
|
+
require 'erb'
|
3
|
+
require 'rainbow'
|
4
|
+
|
5
|
+
class SiteDiff
|
6
|
+
module Diff
|
7
|
+
module_function
|
8
|
+
|
9
|
+
def html_diffy(before_html, after_html)
|
10
|
+
diff = Diffy::Diff.new(before_html, after_html)
|
11
|
+
diff.first ? # Is it non-empty?
|
12
|
+
diff.to_s(:html) : nil
|
13
|
+
end
|
14
|
+
|
15
|
+
def terminal_diffy(before_html, after_html)
|
16
|
+
args = []
|
17
|
+
args << :color if Rainbow.enabled
|
18
|
+
return Diffy::Diff.new(before_html, after_html, :context => 3).
|
19
|
+
to_s(*args)
|
20
|
+
end
|
21
|
+
|
22
|
+
def generate_html_report(results, before, after)
|
23
|
+
erb_path = File.join(SiteDiff::FILES_DIR, 'html_report.html.erb')
|
24
|
+
report_html = ERB.new(File.read(erb_path)).result(binding)
|
25
|
+
return report_html
|
26
|
+
end
|
27
|
+
|
28
|
+
def generate_diff_output(result)
|
29
|
+
erb_path = File.join(SiteDiff::FILES_DIR, 'diff.html.erb')
|
30
|
+
return ERB.new(File.read(erb_path)).result(binding)
|
31
|
+
end
|
32
|
+
|
33
|
+
def css
|
34
|
+
File.read(File.join(SiteDiff::FILES_DIR, 'sitediff.css'))
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<!-- important: otherwise chrome will choke on non-ascii characters -->
|
5
|
+
<meta charset="utf-8" />
|
6
|
+
<style>
|
7
|
+
<%= SiteDiff::Diff.css %>
|
8
|
+
</style>
|
9
|
+
<title> SiteDiff Report </title>
|
10
|
+
</head>
|
11
|
+
<body>
|
12
|
+
<div class="sitediff">
|
13
|
+
<div class="legend">
|
14
|
+
<strong>before</strong> (base url): <a href="<%=before%>"><%=before%></a> |
|
15
|
+
<strong>after </strong> (base url): <a href="<%=after%>" ><%=after %></a>
|
16
|
+
</div>
|
17
|
+
<table class="results">
|
18
|
+
|
19
|
+
<colgroup>
|
20
|
+
<col class="before-col">
|
21
|
+
<col class="after-col">
|
22
|
+
<col class="path-col">
|
23
|
+
<col class="diff-stat-col">
|
24
|
+
</colgroup>
|
25
|
+
|
26
|
+
<thead>
|
27
|
+
<tr>
|
28
|
+
<th> Before </th>
|
29
|
+
<th> After </th>
|
30
|
+
<th> Path </th>
|
31
|
+
<th> Status </th>
|
32
|
+
</tr>
|
33
|
+
</thead>
|
34
|
+
|
35
|
+
<% results.each do |result| %>
|
36
|
+
<tr class="<%= result.status_text %>">
|
37
|
+
<td class="before"><a href="<%= result.url(before) %>">[before]</a></td>
|
38
|
+
<td class="after"><a href="<%= result.url(after) %>">[after]</a></td>
|
39
|
+
<td class="path"><%= result.path %></td>
|
40
|
+
<td class="status"><%= result.link %></td>
|
41
|
+
</tr>
|
42
|
+
<% end %>
|
43
|
+
|
44
|
+
</table>
|
45
|
+
</div>
|
46
|
+
</body>
|
47
|
+
</html>
|
@@ -0,0 +1,9 @@
|
|
1
|
+
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
|
2
|
+
<xsl:output method="xml" encoding="UTF-8" indent="yes"/>
|
3
|
+
<xsl:param name="indent-increment" select="' '"/>
|
4
|
+
<xsl:strip-space elements="*"/>
|
5
|
+
|
6
|
+
<xsl:template match="/">
|
7
|
+
<xsl:copy-of select="."/>
|
8
|
+
</xsl:template>
|
9
|
+
</xsl:stylesheet>
|
@@ -0,0 +1,42 @@
|
|
1
|
+
.sitediff {
|
2
|
+
font-family: monospace;
|
3
|
+
font-size: 1.2em;
|
4
|
+
}
|
5
|
+
.sitediff .legend {
|
6
|
+
width: 95%;
|
7
|
+
margin: 1em auto;
|
8
|
+
text-align: center;
|
9
|
+
}
|
10
|
+
.sitediff .results thead {
|
11
|
+
background: black;
|
12
|
+
color: white;
|
13
|
+
}
|
14
|
+
.sitediff .results td {
|
15
|
+
text-align: center;
|
16
|
+
}
|
17
|
+
.sitediff .results td.path {
|
18
|
+
text-align: left;
|
19
|
+
padding-left: 1em;
|
20
|
+
}
|
21
|
+
.sitediff .results {
|
22
|
+
padding: 1em;
|
23
|
+
width: 95%;
|
24
|
+
margin: 1em auto;
|
25
|
+
font-size: 1em;
|
26
|
+
}
|
27
|
+
.sitediff tr.error > td.status,
|
28
|
+
.sitediff tr.error > td.path {
|
29
|
+
background-color: khaki;
|
30
|
+
}
|
31
|
+
.sitediff tr.failure > td.status,
|
32
|
+
.sitediff tr.failure > td.path {
|
33
|
+
background-color: salmon;
|
34
|
+
}
|
35
|
+
.sitediff .before-col,
|
36
|
+
.sitediff .after-col,
|
37
|
+
.sitediff .diff-stat-col {
|
38
|
+
width: 10%;
|
39
|
+
}
|
40
|
+
.sitediff .path-col {
|
41
|
+
width: 55%;
|
42
|
+
}
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'digest/sha1'
|
3
|
+
|
4
|
+
class SiteDiff
|
5
|
+
class Result < Struct.new(:path, :before, :after, :error)
|
6
|
+
STATUS_SUCCESS = 0 # Identical before and after
|
7
|
+
STATUS_FAILURE = 1 # Different before and after
|
8
|
+
STATUS_ERROR = 2 # Couldn't fetch page
|
9
|
+
STATUS_TEXT = %w[success failure error]
|
10
|
+
|
11
|
+
attr_reader :status, :diff
|
12
|
+
|
13
|
+
def initialize(*args)
|
14
|
+
super
|
15
|
+
if error
|
16
|
+
@status = STATUS_ERROR
|
17
|
+
else
|
18
|
+
@diff = Diff::html_diffy(before, after)
|
19
|
+
@status = @diff ? STATUS_FAILURE : STATUS_SUCCESS
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def success?
|
24
|
+
status == STATUS_SUCCESS
|
25
|
+
end
|
26
|
+
|
27
|
+
# Textual representation of the status
|
28
|
+
def status_text
|
29
|
+
return STATUS_TEXT[status]
|
30
|
+
end
|
31
|
+
|
32
|
+
# Printable URL
|
33
|
+
def url(prefix)
|
34
|
+
prefix.to_s + path
|
35
|
+
end
|
36
|
+
|
37
|
+
# Filename to store diff
|
38
|
+
def filename
|
39
|
+
File.join(SiteDiff::DIFFS_DIR, Digest::SHA1.hexdigest(self.path) + '.html')
|
40
|
+
end
|
41
|
+
|
42
|
+
# Text of the link in the HTML report
|
43
|
+
def link
|
44
|
+
case status
|
45
|
+
when STATUS_ERROR then error
|
46
|
+
when STATUS_SUCCESS then status_text
|
47
|
+
when STATUS_FAILURE then "<a href='#{filename}'>DIFF</a>"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Log the result to the terminal
|
52
|
+
def log
|
53
|
+
case status
|
54
|
+
when STATUS_SUCCESS then
|
55
|
+
SiteDiff::log path, :success, 'SUCCESS'
|
56
|
+
when STATUS_ERROR then
|
57
|
+
SiteDiff::log path, :error, "ERROR (#{error})"
|
58
|
+
when STATUS_FAILURE then
|
59
|
+
SiteDiff::log path, :failure, "FAILURE"
|
60
|
+
puts Diff::terminal_diffy(before, after)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Dump the result to a file
|
65
|
+
def dump(dir)
|
66
|
+
dump_path = File.join(dir, filename)
|
67
|
+
base = File.dirname(dump_path)
|
68
|
+
FileUtils::mkdir_p(base) unless File.exists?(base)
|
69
|
+
File.open(dump_path, 'w') do |f|
|
70
|
+
f.write(Diff::generate_diff_output(self))
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,193 @@
|
|
1
|
+
require 'nokogiri'
|
2
|
+
require 'set'
|
3
|
+
|
4
|
+
class SiteDiff
|
5
|
+
module Sanitize
|
6
|
+
class InvalidSanitization < Exception; end
|
7
|
+
|
8
|
+
TOOLS = {
|
9
|
+
:array => %w[dom_transform sanitization],
|
10
|
+
:scalar => %w[selector remove_spacing],
|
11
|
+
}
|
12
|
+
DOM_TRANSFORMS = Set.new(%w[remove unwrap_root unwrap remove_class])
|
13
|
+
|
14
|
+
module_function
|
15
|
+
|
16
|
+
# Performs dom transformations.
|
17
|
+
#
|
18
|
+
# Currently supported transforms:
|
19
|
+
#
|
20
|
+
# * { :type => "unwrap_root" }
|
21
|
+
# * { :type => "unwrap", :selector => "div.field-item" }
|
22
|
+
# * { :type => "remove", :selector => "div.extra-stuff" }
|
23
|
+
#
|
24
|
+
# @arg node - Nokogiri document or Node
|
25
|
+
# @arg rules - array of dom_transform rules
|
26
|
+
# @return - transformed Nokogiri document node
|
27
|
+
def perform_dom_transforms(node, rules)
|
28
|
+
rules.each do |rule|
|
29
|
+
type = rule['type'] or
|
30
|
+
raise InvalidSanitization, "DOM transform needs a type"
|
31
|
+
DOM_TRANSFORMS.include?(type) or
|
32
|
+
raise InvalidSanitization, "No DOM transform named #{type}"
|
33
|
+
|
34
|
+
meth = 'transform_' + type
|
35
|
+
|
36
|
+
if sels = rule['selector']
|
37
|
+
sels = [sels].flatten # Either array or scalar is fine
|
38
|
+
# Call method for each node the selectors find
|
39
|
+
sels.each do |sel|
|
40
|
+
node.css(sel).each { |e| send(meth, rule, e) }
|
41
|
+
end
|
42
|
+
else
|
43
|
+
send(meth, rule, node)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def transform_remove(rule, el)
|
49
|
+
el.remove
|
50
|
+
end
|
51
|
+
def transform_unwrap(rule, el)
|
52
|
+
el.add_next_sibling(el.children)
|
53
|
+
el.remove
|
54
|
+
end
|
55
|
+
def transform_remove_class(rule, el)
|
56
|
+
# Must call remove_class on a NodeSet!
|
57
|
+
ns = Nokogiri::XML::NodeSet.new(el.document, [el])
|
58
|
+
[rule['class']].flatten.each do |class_name|
|
59
|
+
ns.remove_class(class_name)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
def transform_unwrap_root(rule, node)
|
63
|
+
node.children.size == 1 or
|
64
|
+
raise InvalidSanitization, "Multiple root elements in unwrap_root"
|
65
|
+
node.children = node.children[0].children
|
66
|
+
end
|
67
|
+
|
68
|
+
def parse(str, force_doc = false, log_errors = false)
|
69
|
+
if force_doc || /<!DOCTYPE/.match(str[0, 512])
|
70
|
+
doc = Nokogiri::HTML(str)
|
71
|
+
doc
|
72
|
+
else
|
73
|
+
doc = Nokogiri::HTML.fragment(str)
|
74
|
+
end
|
75
|
+
if log_errors
|
76
|
+
doc.errors.each do |e|
|
77
|
+
SiteDiff::log "Error in parsing HTML document: #{e}", :error
|
78
|
+
end
|
79
|
+
end
|
80
|
+
doc
|
81
|
+
end
|
82
|
+
|
83
|
+
# Force this object to be a document, so we can apply a stylesheet
|
84
|
+
def to_document(obj)
|
85
|
+
if Nokogiri::XML::Document === obj
|
86
|
+
return obj
|
87
|
+
elsif Nokogiri::XML::Node === obj # or fragment
|
88
|
+
return parse(obj.to_s, true)
|
89
|
+
|
90
|
+
# This ought to work, and would be faster,
|
91
|
+
# but seems to segfault Nokogiri
|
92
|
+
# doc = Nokogiri::HTML('<html><body>')
|
93
|
+
# doc.at('body').children = obj.children
|
94
|
+
# return doc
|
95
|
+
else
|
96
|
+
return to_document(parse(obj))
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Pretty-print the HTML
|
101
|
+
def prettify(obj)
|
102
|
+
@stylesheet ||= begin
|
103
|
+
stylesheet_path = File.join(SiteDiff::FILES_DIR, 'pretty_print.xsl')
|
104
|
+
Nokogiri::XSLT(File.read(stylesheet_path))
|
105
|
+
end
|
106
|
+
|
107
|
+
# Pull out the html element's children
|
108
|
+
# The obvious way to do this is to iterate over pretty.css('html'),
|
109
|
+
# but that tends to segfault Nokogiri
|
110
|
+
str = @stylesheet.apply_to(to_document(obj))
|
111
|
+
|
112
|
+
# Remove xml declaration and <html> tags
|
113
|
+
str.sub!(/\A<\?xml.*$\n/, '')
|
114
|
+
str.sub!(/\A^<html>$\n/, '')
|
115
|
+
str.sub!(%r[</html>\n\Z], '')
|
116
|
+
|
117
|
+
# Remove top-level indentation
|
118
|
+
indent = /\A(\s*)/.match(str)[1].size
|
119
|
+
str.gsub!(/^\s{,#{indent}}/, '')
|
120
|
+
|
121
|
+
# Remove blank lines
|
122
|
+
str.gsub!(/^\s*$\n/, '')
|
123
|
+
|
124
|
+
return str
|
125
|
+
end
|
126
|
+
|
127
|
+
def remove_spacing(doc)
|
128
|
+
# remove double spacing, but only inside text nodes (eg not attributes)
|
129
|
+
doc.xpath('//text()').each do |node|
|
130
|
+
node.content = node.content.gsub(/ +/, ' ')
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Do one regexp transformation on a string
|
135
|
+
def substitute(str, rule)
|
136
|
+
#FIXME escape forward slashes, right now we are escaping them in YAML!
|
137
|
+
str.gsub!(/#{rule['pattern']}/, rule['substitute'] || '' )
|
138
|
+
str
|
139
|
+
end
|
140
|
+
|
141
|
+
# Do all regexp sanitization rules
|
142
|
+
def perform_regexps(node, rules)
|
143
|
+
rules ||= []
|
144
|
+
|
145
|
+
# First do rules with a selector
|
146
|
+
rules.each do |rule|
|
147
|
+
if sel = rule['selector']
|
148
|
+
node.css(sel).each do |e|
|
149
|
+
e.replace(substitute(e.to_html, rule))
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# If needed, do rules without a selector. We'd rather not convert to
|
155
|
+
# a string unless necessary.
|
156
|
+
global_rules = rules.reject { |r| r['selector'] }
|
157
|
+
return node if global_rules.empty?
|
158
|
+
|
159
|
+
str = node.to_html # Convert to string
|
160
|
+
global_rules.each { |r| substitute(str, r) }
|
161
|
+
return str
|
162
|
+
end
|
163
|
+
|
164
|
+
def select_root(node, sel)
|
165
|
+
return node unless sel
|
166
|
+
|
167
|
+
# When we choose a new root, we always become a DocumentFragment,
|
168
|
+
# and lose any DOCTYPE and such.
|
169
|
+
ns = node.css(sel)
|
170
|
+
unless node.fragment?
|
171
|
+
node = Nokogiri::HTML.fragment('')
|
172
|
+
end
|
173
|
+
node.children = ns
|
174
|
+
return node
|
175
|
+
end
|
176
|
+
|
177
|
+
def sanitize(str, config)
|
178
|
+
return '' if str == ''
|
179
|
+
|
180
|
+
node = parse(str)
|
181
|
+
|
182
|
+
remove_spacing(node) if config['remove_spacing']
|
183
|
+
node = select_root(node, config['selector'])
|
184
|
+
if transform = config['dom_transform']
|
185
|
+
perform_dom_transforms(node, transform)
|
186
|
+
end
|
187
|
+
|
188
|
+
obj = perform_regexps(node, config['sanitization'])
|
189
|
+
|
190
|
+
return prettify(obj)
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
require 'typhoeus'
|
2
|
+
|
3
|
+
class SiteDiff
|
4
|
+
class SiteDiffReadFailure < Exception; end
|
5
|
+
|
6
|
+
class UriWrapper
|
7
|
+
# This lets us treat errors or content as one object
|
8
|
+
class ReadResult < Struct.new(:content, :error)
|
9
|
+
def initialize(cont, err = nil)
|
10
|
+
super(cont, err)
|
11
|
+
end
|
12
|
+
def self.error(err); new(nil, err); end
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(uri)
|
16
|
+
@uri = uri.respond_to?(:scheme) ? uri : URI.parse(uri)
|
17
|
+
# remove trailing '/'s from local URIs
|
18
|
+
@uri.path.gsub!(/\/*$/, '') if local?
|
19
|
+
end
|
20
|
+
|
21
|
+
def user
|
22
|
+
@uri.user
|
23
|
+
end
|
24
|
+
|
25
|
+
def password
|
26
|
+
@uri.password
|
27
|
+
end
|
28
|
+
|
29
|
+
def to_s
|
30
|
+
uri = @uri.dup
|
31
|
+
uri.user = nil
|
32
|
+
uri.password = nil
|
33
|
+
return uri.to_s
|
34
|
+
end
|
35
|
+
|
36
|
+
# Is this a local filesystem path?
|
37
|
+
def local?
|
38
|
+
@uri.scheme == nil
|
39
|
+
end
|
40
|
+
|
41
|
+
# FIXME this is not used anymore
|
42
|
+
def +(path)
|
43
|
+
# 'path' for SiteDiff includes (parts of) path, query, and fragment.
|
44
|
+
sep = ''
|
45
|
+
if local? || @uri.path.empty?
|
46
|
+
sep = '/'
|
47
|
+
end
|
48
|
+
self.class.new(@uri.to_s + sep + path)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Reads a file and yields to the completion handler, see .queue()
|
52
|
+
def read_file(&handler)
|
53
|
+
File.open(@uri.to_s, 'r:UTF-8') { |f| yield ReadResult.new(f.read) }
|
54
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, Errno::EISDIR => e
|
55
|
+
yield ReadResult.error(e.message)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Returns the encoding of an HTTP response from headers , nil if not
|
59
|
+
# specified.
|
60
|
+
def http_encoding(http_headers)
|
61
|
+
if content_type = http_headers['Content-Type']
|
62
|
+
if md = /;\s*charset=([-\w]*)/.match(content_type)
|
63
|
+
return md[1]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Returns a Typhoeus::Request to fetch @uri
|
69
|
+
#
|
70
|
+
# Completion callbacks of the request wrap the given handler which is
|
71
|
+
# assumed to accept a single ReadResult argument.
|
72
|
+
def typhoeus_request(&handler)
|
73
|
+
params = {
|
74
|
+
:connecttimeout => 3, # Don't hang on servers that don't exist
|
75
|
+
:followlocation => true, # Follow HTTP redirects (code 301 and 302)
|
76
|
+
:headers => {
|
77
|
+
"User-Agent" => "Sitediff - https://github.com/evolvingweb/sitediff"
|
78
|
+
}
|
79
|
+
}
|
80
|
+
# Allow basic auth
|
81
|
+
params[:userpwd] = @uri.user + ':' + @uri.password if @uri.user
|
82
|
+
|
83
|
+
req = Typhoeus::Request.new(self.to_s, params)
|
84
|
+
|
85
|
+
req.on_success do |resp|
|
86
|
+
body = resp.body
|
87
|
+
# Typhoeus does not respect HTTP headers when setting the encoding
|
88
|
+
# resp.body; coerce if possible.
|
89
|
+
if encoding = http_encoding(resp.headers)
|
90
|
+
body.force_encoding(encoding)
|
91
|
+
end
|
92
|
+
yield ReadResult.new(body)
|
93
|
+
end
|
94
|
+
|
95
|
+
req.on_failure do |resp|
|
96
|
+
msg = 'Unknown Error'
|
97
|
+
msg = resp.status_message if resp and resp.status_message
|
98
|
+
yield ReadResult.error("HTTP error #{@uri}: #{msg}")
|
99
|
+
end
|
100
|
+
|
101
|
+
req
|
102
|
+
end
|
103
|
+
|
104
|
+
# Queue reading this URL, with a completion handler to run after.
|
105
|
+
#
|
106
|
+
# The handler should be callable as handler[ReadResult].
|
107
|
+
#
|
108
|
+
# This method may choose not to queue the request at all, but simply
|
109
|
+
# execute right away.
|
110
|
+
def queue(hydra, &handler)
|
111
|
+
if local?
|
112
|
+
read_file(&handler)
|
113
|
+
else
|
114
|
+
hydra.queue(typhoeus_request(&handler))
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
class SiteDiff
|
2
|
+
module Util
|
3
|
+
# A typhoeus cache, backed by DBM
|
4
|
+
class Cache
|
5
|
+
def initialize(file)
|
6
|
+
# Default to GDBM, if we have it, we don't want pag/dir files
|
7
|
+
begin
|
8
|
+
require 'gdbm'
|
9
|
+
@dbm = GDBM.new(file)
|
10
|
+
rescue LoadError
|
11
|
+
require 'dbm'
|
12
|
+
@dbm = DBM.new(file)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
# Older Typhoeus doesn't have cache_key
|
17
|
+
def cache_key(req)
|
18
|
+
return req.cache_key if req.respond_to?(:cache_key)
|
19
|
+
return Marshal.dump([req.base_url, req.options])
|
20
|
+
end
|
21
|
+
|
22
|
+
def get(req)
|
23
|
+
resp = @dbm[cache_key(req)] or return nil
|
24
|
+
Marshal.load(resp)
|
25
|
+
end
|
26
|
+
|
27
|
+
def set(req, resp)
|
28
|
+
@dbm[cache_key(req)] = Marshal.dump(resp)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'webrick'
|
2
|
+
|
3
|
+
class SiteDiff
|
4
|
+
module Util
|
5
|
+
# Simple webserver for testing purposes
|
6
|
+
class Webserver
|
7
|
+
DEFAULT_PORT = 13080
|
8
|
+
|
9
|
+
attr_accessor :ports
|
10
|
+
|
11
|
+
# Serve a list of directories
|
12
|
+
def initialize(start_port, dirs, params = {})
|
13
|
+
start_port ||= DEFAULT_PORT
|
14
|
+
@ports = (start_port...(start_port + dirs.size)).to_a
|
15
|
+
|
16
|
+
if params[:announce]
|
17
|
+
puts "Serving at #{uris.join(", ")}"
|
18
|
+
end
|
19
|
+
|
20
|
+
opts = {}
|
21
|
+
if params[:quiet]
|
22
|
+
opts[:Logger] = WEBrick::Log.new(IO::NULL)
|
23
|
+
opts[:AccessLog] = []
|
24
|
+
end
|
25
|
+
|
26
|
+
@threads = []
|
27
|
+
dirs.each_with_index do |dir, idx|
|
28
|
+
opts[:Port] = @ports[idx]
|
29
|
+
opts[:DocumentRoot] = dir
|
30
|
+
server = WEBrick::HTTPServer.new(opts)
|
31
|
+
@threads << Thread.new { server.start }
|
32
|
+
end
|
33
|
+
|
34
|
+
if block_given?
|
35
|
+
yield self
|
36
|
+
kill
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def kill
|
41
|
+
@threads.each { |t| t.kill }
|
42
|
+
end
|
43
|
+
|
44
|
+
def wait
|
45
|
+
@threads.each { |t| t.join }
|
46
|
+
end
|
47
|
+
|
48
|
+
def uris
|
49
|
+
ports.map { |p| "http://localhost:#{p}" }
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
# Helper to serve one dir
|
54
|
+
def self.serve(port, dir, params = {})
|
55
|
+
new(port, [dir], params)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
class FixtureServer < Webserver
|
60
|
+
PORT = DEFAULT_PORT + 1
|
61
|
+
BASE = 'spec/fixtures/ruby-doc.org'
|
62
|
+
NAMES = %w[core-1.9.3 core-2.0]
|
63
|
+
|
64
|
+
def initialize(port = PORT, base = BASE, names = NAMES)
|
65
|
+
dirs = names.map { |n| File.join(base, n) }
|
66
|
+
super(port, dirs, :quiet => true)
|
67
|
+
end
|
68
|
+
|
69
|
+
def before
|
70
|
+
uris.first
|
71
|
+
end
|
72
|
+
def after
|
73
|
+
uris.last
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
metadata
ADDED
@@ -0,0 +1,131 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sitediff
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Alex Dergachev
|
8
|
+
- Amir Kadivar
|
9
|
+
- Dave Vasilevsky
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2015-04-21 00:00:00.000000000 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: thor
|
17
|
+
requirement: !ruby/object:Gem::Requirement
|
18
|
+
requirements:
|
19
|
+
- - '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
requirements:
|
26
|
+
- - '>='
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
version: '0'
|
29
|
+
- !ruby/object:Gem::Dependency
|
30
|
+
name: nokogiri
|
31
|
+
requirement: !ruby/object:Gem::Requirement
|
32
|
+
requirements:
|
33
|
+
- - '>='
|
34
|
+
- !ruby/object:Gem::Version
|
35
|
+
version: '0'
|
36
|
+
type: :runtime
|
37
|
+
prerelease: false
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - '>='
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: '0'
|
43
|
+
- !ruby/object:Gem::Dependency
|
44
|
+
name: diffy
|
45
|
+
requirement: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - '>='
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: '0'
|
50
|
+
type: :runtime
|
51
|
+
prerelease: false
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - '>='
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '0'
|
57
|
+
- !ruby/object:Gem::Dependency
|
58
|
+
name: typhoeus
|
59
|
+
requirement: !ruby/object:Gem::Requirement
|
60
|
+
requirements:
|
61
|
+
- - '>='
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: '0'
|
64
|
+
type: :runtime
|
65
|
+
prerelease: false
|
66
|
+
version_requirements: !ruby/object:Gem::Requirement
|
67
|
+
requirements:
|
68
|
+
- - '>='
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: '0'
|
71
|
+
- !ruby/object:Gem::Dependency
|
72
|
+
name: rainbow
|
73
|
+
requirement: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
type: :runtime
|
79
|
+
prerelease: false
|
80
|
+
version_requirements: !ruby/object:Gem::Requirement
|
81
|
+
requirements:
|
82
|
+
- - '>='
|
83
|
+
- !ruby/object:Gem::Version
|
84
|
+
version: '0'
|
85
|
+
description: |
|
86
|
+
SiteDiff makes it easy to see differences between two versions of a website. It accepts a set of paths to compare two versions of the site together with potential normalization/sanitization rules. From the provided paths and configuration SiteDiff generates an HTML report of all the status of HTML comparison between the given paths together with a readable diff-like HTML for each specified path containing the differences between the two versions of the site. It is useful tool for QAing re-deployments, site upgrades, etc.
|
87
|
+
email: alex@evolvingweb.ca
|
88
|
+
executables:
|
89
|
+
- sitediff
|
90
|
+
extensions: []
|
91
|
+
extra_rdoc_files: []
|
92
|
+
files:
|
93
|
+
- lib/sitediff/cli.rb
|
94
|
+
- lib/sitediff/config.rb
|
95
|
+
- lib/sitediff/diff.rb
|
96
|
+
- lib/sitediff/result.rb
|
97
|
+
- lib/sitediff/sanitize.rb
|
98
|
+
- lib/sitediff/uriwrapper.rb
|
99
|
+
- lib/sitediff/util/cache.rb
|
100
|
+
- lib/sitediff/util/webserver.rb
|
101
|
+
- lib/sitediff.rb
|
102
|
+
- lib/sitediff/files/diff.html.erb
|
103
|
+
- lib/sitediff/files/html_report.html.erb
|
104
|
+
- lib/sitediff/files/pretty_print.xsl
|
105
|
+
- lib/sitediff/files/sitediff.css
|
106
|
+
- bin/sitediff
|
107
|
+
homepage: https://github.com/evolvingweb/sitediff/
|
108
|
+
licenses:
|
109
|
+
- GPL-2
|
110
|
+
metadata: {}
|
111
|
+
post_install_message:
|
112
|
+
rdoc_options: []
|
113
|
+
require_paths:
|
114
|
+
- lib
|
115
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
116
|
+
requirements:
|
117
|
+
- - '>='
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
version: 1.9.3
|
120
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - '>='
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
requirements: []
|
126
|
+
rubyforge_project:
|
127
|
+
rubygems_version: 2.0.14
|
128
|
+
signing_key:
|
129
|
+
specification_version: 4
|
130
|
+
summary: Compare two versions of a site with ease!
|
131
|
+
test_files: []
|