nokodiff 0.1.0 → 0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec76ca57f4fd914bc778242d825a9a074dd39bff32b8467e4729592f31bfb0ab
4
- data.tar.gz: 67de4318dc9d455b212a8199c2039acfbf63b2b078c90e06e26e0ac00a3f17ef
3
+ metadata.gz: bd5f28a6d69fffa081eb6dd4ade65be6aa8b2e309c7be553ab6820ffb7ab480e
4
+ data.tar.gz: 9b54a7aa1d6437c41ae47c5b3a6869ce97544426a9fae655940c227de34f5867
5
5
  SHA512:
6
- metadata.gz: 7995f2fd1718a72f5288112d3dddc51c8305ba319b5ab383712ea484095b52083d6c40ee3738f2964456919fa634f2ea10119cae814665c947d24d28d2a7a638
7
- data.tar.gz: a301b0efba48e0ce790b3743e8477dfaaca41e6c9e242f60cbd0d474acb51619eb44ec0f68bb7da00e8c5a0d73582080602d2823ab81bd4f7c686a51d6291f84
6
+ metadata.gz: 9f6088d65f6e4e007a39a4b30c07d6cc99080714952c68dd3ecc34cac4a10881910f3ce9d99826db4ecbd45917e8161e4f0f6b134c54d1d2ca31217b857b336e
7
+ data.tar.gz: af6c8bcaf758d33001c07f23d94d6897a83e3425c3093d342e05dde16fc6a6a0f59c6cfe2d46f1356da75c7c51babaf496947d1f01f4cb921d58908fa8c2be2d
data/README.md CHANGED
@@ -1,6 +1,54 @@
1
1
  # Nokodiff
2
2
 
3
- A Ruby Gem to highlight additions, deletions and character level changes while preserving original HTML
3
+ A Ruby Gem to highlight additions, deletions and character level changes while preserving original HTML.
4
+
5
+ It includes functionality to:
6
+ * Compare two HTML fragments and output diffs with semantic HTML
7
+ * Inline character differences highlighting using `<strong>` tagging
8
+ * Blocks of added or removed content wrapped in aria labelled `<ins>` and `<del>` tags
9
+ * Optional CSS for styling the visual differences
10
+
11
+ ## Installation
12
+
13
+ Install the gem:
14
+
15
+ ```
16
+ gem install nokodiff
17
+ ```
18
+
19
+ or add it to your Gemfile
20
+
21
+ ```
22
+ gem "nokodiff"
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ In the controller:
28
+ ```ruby
29
+ require 'nokodiff'
30
+
31
+ before_html = < YOUR HTML >
32
+ after_html = < YOUR HTML >
33
+
34
+ @differ = Nokodiff.diff(before_html, after_html)
35
+ ```
36
+
37
+ In the erb file:
38
+ ```erb
39
+ <div>
40
+ <%= @differ %>
41
+ </div>
42
+ ```
43
+
44
+ ### Including the CSS
45
+
46
+ In your application.scss file include:
47
+ ```scss
48
+ @import "nokodiff";
49
+ ```
50
+
51
+ This will include the styling for `<del>`, `<ins>` and `<strong>` tags to allow colour coding, highlighting and underlining of changes.
4
52
 
5
53
  ## Licence
6
54
 
@@ -0,0 +1,75 @@
1
+ // stylelint-disable selector-no-qualifying-type, max-nesting-depth
2
+ // Diff of two editions
3
+ $added-color: #e6fff3;
4
+ $strong-added-color: #99ffcf;
5
+ $removed-color: #fadede;
6
+ $strong-removed-color: #f3aeac;
7
+ $text-colour: #0b0c0c;
8
+ $light-grey: #f3f2f1;
9
+ $black: #0b0c0c;
10
+
11
+ .compare-editions {
12
+ border: 1px solid $light-grey;
13
+ border-left: 40px solid $light-grey;
14
+ padding: 15px;
15
+
16
+ .diff {
17
+ margin: 0 -15px;
18
+ padding: 0 15px;
19
+ word-wrap: break-word;
20
+ margin-bottom: 10px;
21
+ position: relative;
22
+
23
+ del,
24
+ ins {
25
+ text-decoration: none;
26
+ display: block;
27
+ padding: 2px 4px;
28
+ border-radius: 3px;
29
+ }
30
+ }
31
+
32
+ del {
33
+ background-color: $removed-color;
34
+ padding-bottom: 2px;
35
+
36
+ strong {
37
+ font-weight: normal;
38
+ background-color: $strong-removed-color;
39
+ border-bottom: 2px dashed $black;
40
+ }
41
+ }
42
+
43
+ ins {
44
+ background-color: $added-color;
45
+ padding-bottom: 2px;
46
+
47
+ strong {
48
+ font-weight: normal;
49
+ background-color: $strong-added-color;
50
+ border-bottom: 2px dashed $black;
51
+ }
52
+ }
53
+
54
+ del::before,
55
+ ins::before {
56
+ position: absolute;
57
+ margin-left: -59px;
58
+ width: 40px;
59
+ text-align: center;
60
+ top: 0;
61
+ bottom: 0;
62
+ }
63
+
64
+ del::before {
65
+ color: $text-colour;
66
+ background-color: $strong-removed-color;
67
+ content: "−";
68
+ }
69
+
70
+ ins::before {
71
+ color: $text-colour;
72
+ background-color: $strong-added-color;
73
+ content: "+";
74
+ }
75
+ }
@@ -0,0 +1,67 @@
1
+ module Nokodiff
2
+ class ChangesInFragments
3
+ include FormattingHelpers
4
+ def initialize(diff)
5
+ @diff = diff
6
+ @before_fragment = Nokogiri::HTML::DocumentFragment.parse("")
7
+ @after_fragment = Nokogiri::HTML::DocumentFragment.parse("")
8
+
9
+ @accumulated_before_text = ""
10
+ @accumulated_after_text = ""
11
+ end
12
+
13
+ def call
14
+ @diff.each do |change|
15
+ case change.action
16
+ when "="
17
+ no_change_emphasis(change)
18
+ when "!"
19
+ emphasise_change(change)
20
+ when "-"
21
+ emphasise_deletion(change)
22
+ when "+"
23
+ emphasise_addition(change)
24
+ end
25
+ end
26
+
27
+ append_accumulated_text(before_fragment, accumulated_before_text)
28
+ append_accumulated_text(after_fragment, accumulated_after_text)
29
+
30
+ [before_fragment, after_fragment]
31
+ end
32
+
33
+ private
34
+
35
+ attr_accessor :before_fragment, :after_fragment, :accumulated_before_text, :accumulated_after_text
36
+
37
+ def no_change_emphasis(change)
38
+ accumulated_before_text << change.old_element
39
+ accumulated_after_text << change.new_element
40
+ end
41
+
42
+ def emphasise_change(change)
43
+ append_accumulated_text(before_fragment, accumulated_before_text)
44
+ append_accumulated_text(after_fragment, accumulated_after_text)
45
+
46
+ before_fragment.add_child(wrap_in_strong(change.old_element, before_fragment))
47
+ after_fragment.add_child(wrap_in_strong(change.new_element, after_fragment))
48
+ end
49
+
50
+ def emphasise_deletion(change)
51
+ append_accumulated_text(before_fragment, accumulated_before_text)
52
+ before_fragment.add_child(wrap_in_strong(change.old_element, before_fragment))
53
+ end
54
+
55
+ def emphasise_addition(change)
56
+ append_accumulated_text(after_fragment, accumulated_after_text)
57
+ after_fragment.add_child(wrap_in_strong(change.new_element, after_fragment))
58
+ end
59
+
60
+ def append_accumulated_text(fragment, accumulated_text)
61
+ return if accumulated_text.empty?
62
+
63
+ fragment.add_child(Nokogiri::XML::Text.new(accumulated_text, fragment))
64
+ accumulated_text.clear
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,103 @@
1
+ module Nokodiff
2
+ class Differ
3
+ def initialize(before_html, after_html)
4
+ @before = Nokogiri::HTML.fragment(before_html)
5
+ @after = Nokogiri::HTML.fragment(after_html)
6
+ end
7
+
8
+ def to_html
9
+ compared_blocks.map { |diff|
10
+ case diff[:status]
11
+ when :unchanged
12
+ unchanged_block(diff[:before])
13
+ when :changed
14
+ deleted_block(char_diff_html(diff[:before], diff[:after]).first) + added_block(char_diff_html(diff[:before], diff[:after]).last)
15
+ when :deleted
16
+ deleted_block(diff[:before])
17
+ when :added
18
+ added_block(diff[:after])
19
+ end
20
+ }.join("\n")
21
+ end
22
+
23
+ private
24
+
25
+ def compared_blocks
26
+ before_nodes = @before.children.to_a
27
+ after_nodes = @after.children.to_a
28
+
29
+ max = [before_nodes.length, after_nodes.length].max
30
+
31
+ max.times.map do |i|
32
+ before_node = before_nodes[i]
33
+ after_node = after_nodes[i]
34
+
35
+ set_change_status(before_node, after_node)
36
+ end
37
+ end
38
+
39
+ def char_diff_html(before_html, after_html)
40
+ before_dup = before_html.dup
41
+ after_dup = after_html.dup
42
+
43
+ before_fragment, after_fragment = Nokodiff::TextNodeDiffs.new(before_dup, after_dup).call
44
+
45
+ merge_adjacent_strong_tags(before_fragment)
46
+ merge_adjacent_strong_tags(after_fragment)
47
+
48
+ [before_fragment.to_html, after_fragment.to_html]
49
+ end
50
+
51
+ def set_change_status(before_node, after_node)
52
+ if before_node && after_node
53
+ if before_node.to_html.strip == after_node.to_html.strip
54
+ { status: :unchanged, before: before_node, after: after_node }
55
+ else
56
+ { status: :changed, before: before_node, after: after_node }
57
+ end
58
+ elsif before_node
59
+ { status: :deleted, before: before_node, after: nil }
60
+ elsif after_node
61
+ { status: :added, before: nil, after: after_node }
62
+ end
63
+ end
64
+
65
+ def merge_adjacent_strong_tags(node)
66
+ return unless node.element?
67
+
68
+ node.children.each do |child|
69
+ merge_adjacent_strong_tags(child) if child.element?
70
+ end
71
+
72
+ node.children.each_cons(2) do |left, right|
73
+ next unless left.name == "strong" && right.name == "strong"
74
+
75
+ left.content = left.content + right.content
76
+ right.remove
77
+
78
+ merge_adjacent_strong_tags(node)
79
+ break
80
+ end
81
+ end
82
+
83
+ def unchanged_block(html)
84
+ html.to_s
85
+ end
86
+
87
+ def deleted_block(html)
88
+ %(
89
+ <div class="diff">
90
+ <del aria-label="removed content">#{html}</del>
91
+ </div>
92
+ )
93
+ end
94
+
95
+ def added_block(html)
96
+ %(
97
+ <div class="diff">
98
+ <ins aria-label="added content">#{html}</ins>
99
+ </div>
100
+ )
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,7 @@
1
+ return unless defined?(Rails)
2
+
3
+ module Nokodiff
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Nokodiff
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ module Nokodiff
2
+ module FormattingHelpers
3
+ def wrap_in_strong(char, fragment)
4
+ Nokogiri::XML::Node.new("strong", fragment.document).tap { |n| n.content = char }
5
+ end
6
+
7
+ def strong(text_node)
8
+ text_node.replace(wrap_in_strong(text_node.to_html, text_node.parent))
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,56 @@
1
+ module Nokodiff
2
+ class TextNodeDiffs
3
+ include FormattingHelpers
4
+ def initialize(before_fragment, after_fragment)
5
+ @before_fragment = before_fragment
6
+ @after_fragment = after_fragment
7
+ end
8
+
9
+ def call
10
+ diff_text_nodes(before_fragment, after_fragment)
11
+ [before_fragment, after_fragment]
12
+ end
13
+
14
+ private
15
+
16
+ attr_accessor :before_fragment, :after_fragment
17
+
18
+ def diff_text_nodes(before_node, after_node)
19
+ if before_node&.text? || after_node&.text?
20
+ diff_text_node_content(before_node, after_node)
21
+ elsif before_node&.element? || after_node&.element?
22
+ before_children = before_node ? before_node.children.to_a : []
23
+ after_children = after_node ? after_node.children.to_a : []
24
+
25
+ max_child_count = [before_children.length, after_children.length].max
26
+
27
+ (0..max_child_count).each do |i|
28
+ diff_text_nodes(before_children[i], after_children[i])
29
+ end
30
+ end
31
+ end
32
+
33
+ def diff_text_node_content(before_text_node, after_text_node)
34
+ return strong(before_text_node) if text_removed?(before_text_node, after_text_node)
35
+ return strong(after_text_node) if text_added?(before_text_node, after_text_node)
36
+
37
+ before_chars = before_text_node.text.chars
38
+ after_chars = after_text_node.text.chars
39
+
40
+ diff = Diff::LCS.sdiff(before_chars, after_chars)
41
+
42
+ before_fragment, after_fragment = Nokodiff::ChangesInFragments.new(diff).call
43
+
44
+ before_text_node.replace(before_fragment)
45
+ after_text_node.replace(after_fragment)
46
+ end
47
+
48
+ def text_removed?(before_node, after_node)
49
+ before_node && after_node.nil?
50
+ end
51
+
52
+ def text_added?(before_node, after_node)
53
+ before_node.nil? && after_node
54
+ end
55
+ end
56
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nokodiff
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/nokodiff.rb CHANGED
@@ -1,8 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "nokogiri"
4
+ require "diff-lcs"
5
+
6
+ require_relative "nokodiff/formatting_helpers"
3
7
  require_relative "nokodiff/version"
8
+ require_relative "nokodiff/differ"
9
+ require_relative "nokodiff/engine"
10
+ require_relative "nokodiff/text_node_diffs"
11
+ require_relative "nokodiff/changes_in_fragments"
4
12
 
5
13
  module Nokodiff
14
+ def self.diff(before_html, after_html)
15
+ HTMLFragmentValidator.validate_html!(before_html)
16
+ HTMLFragmentValidator.validate_html!(after_html)
17
+
18
+ html = Differ.new(before_html, after_html).to_html
19
+ safe_html(html)
20
+ end
21
+
22
+ def self.safe_html(html)
23
+ html.respond_to?(:html_safe) ? html.html_safe : html
24
+ end
25
+
6
26
  class Error < StandardError; end
7
- # Your code goes here...
27
+
28
+ module HTMLFragmentValidator
29
+ module_function
30
+
31
+ def validate_html!(html)
32
+ document = Nokogiri::HTML::DocumentFragment.parse(html)
33
+
34
+ invalid_text_nodes = document.children.select do |node|
35
+ if node.element?
36
+ false
37
+ elsif node.comment?
38
+ false
39
+ elsif node.text?
40
+ !node.text.strip.empty?
41
+ else
42
+ true
43
+ end
44
+ end
45
+
46
+ unless invalid_text_nodes.empty?
47
+ raise ArgumentError, "Invalid HTML input"
48
+ end
49
+
50
+ document
51
+ end
52
+ end
8
53
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nokodiff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GOV.UK Dev
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: rake
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -88,7 +102,7 @@ dependencies:
88
102
  version: '6'
89
103
  - - "<"
90
104
  - !ruby/object:Gem::Version
91
- version: 8.1.2
105
+ version: 8.1.3
92
106
  type: :runtime
93
107
  prerelease: false
94
108
  version_requirements: !ruby/object:Gem::Requirement
@@ -98,21 +112,41 @@ dependencies:
98
112
  version: '6'
99
113
  - - "<"
100
114
  - !ruby/object:Gem::Version
101
- version: 8.1.2
115
+ version: 8.1.3
116
+ - !ruby/object:Gem::Dependency
117
+ name: diff-lcs
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ type: :runtime
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '0'
102
130
  - !ruby/object:Gem::Dependency
103
131
  name: gds-api-adapters
104
132
  requirement: !ruby/object:Gem::Requirement
105
133
  requirements:
106
- - - "~>"
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '101.0'
137
+ - - "<"
107
138
  - !ruby/object:Gem::Version
108
- version: 101.0.0
139
+ version: '101.3'
109
140
  type: :runtime
110
141
  prerelease: false
111
142
  version_requirements: !ruby/object:Gem::Requirement
112
143
  requirements:
113
- - - "~>"
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '101.0'
147
+ - - "<"
114
148
  - !ruby/object:Gem::Version
115
- version: 101.0.0
149
+ version: '101.3'
116
150
  - !ruby/object:Gem::Dependency
117
151
  name: govspeak
118
152
  requirement: !ruby/object:Gem::Requirement
@@ -136,7 +170,7 @@ dependencies:
136
170
  version: '6'
137
171
  - - "<"
138
172
  - !ruby/object:Gem::Version
139
- version: 8.1.2
173
+ version: 8.1.3
140
174
  type: :runtime
141
175
  prerelease: false
142
176
  version_requirements: !ruby/object:Gem::Requirement
@@ -146,7 +180,7 @@ dependencies:
146
180
  version: '6'
147
181
  - - "<"
148
182
  - !ruby/object:Gem::Version
149
- version: 8.1.2
183
+ version: 8.1.3
150
184
  - !ruby/object:Gem::Dependency
151
185
  name: view_component
152
186
  requirement: !ruby/object:Gem::Requirement
@@ -169,7 +203,13 @@ extra_rdoc_files: []
169
203
  files:
170
204
  - LICENCE.txt
171
205
  - README.md
206
+ - app/assets/stylesheets/nokodiff.scss
172
207
  - lib/nokodiff.rb
208
+ - lib/nokodiff/changes_in_fragments.rb
209
+ - lib/nokodiff/differ.rb
210
+ - lib/nokodiff/engine.rb
211
+ - lib/nokodiff/formatting_helpers.rb
212
+ - lib/nokodiff/text_node_diffs.rb
173
213
  - lib/nokodiff/version.rb
174
214
  homepage: https://github.com/alphagov/nokodiff
175
215
  licenses: