nokodiff 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd5f28a6d69fffa081eb6dd4ade65be6aa8b2e309c7be553ab6820ffb7ab480e
4
- data.tar.gz: 9b54a7aa1d6437c41ae47c5b3a6869ce97544426a9fae655940c227de34f5867
3
+ metadata.gz: 4804027f53e38322927f18d80b25a0c9cdfa659154e56090ba61e895dbde68f8
4
+ data.tar.gz: 855959815a1284325a50326d962127ff44feee8d16adfd74fd0e7af300b99ab9
5
5
  SHA512:
6
- metadata.gz: 9f6088d65f6e4e007a39a4b30c07d6cc99080714952c68dd3ecc34cac4a10881910f3ce9d99826db4ecbd45917e8161e4f0f6b134c54d1d2ca31217b857b336e
7
- data.tar.gz: af6c8bcaf758d33001c07f23d94d6897a83e3425c3093d342e05dde16fc6a6a0f59c6cfe2d46f1356da75c7c51babaf496947d1f01f4cb921d58908fa8c2be2d
6
+ metadata.gz: e9484c5b5682e6d8299d1c8d7ff9b38d6a9c53c85ffc15cbef6bdfd234d3bd81df05ba90423924511db20381e44fb70c078cbbef15fdfea631ad5d583647aede
7
+ data.tar.gz: 3563899d45760342ac2a70ad7977b35a8e7bfc5aeb058aba2a69ad567865a6a8de628a4240f687439e2f48e5ef3ddba1d82e0916d72910d89e2b149897fbe659
data/README.md CHANGED
@@ -4,7 +4,7 @@ A Ruby Gem to highlight additions, deletions and character level changes while p
4
4
 
5
5
  It includes functionality to:
6
6
  * Compare two HTML fragments and output diffs with semantic HTML
7
- * Inline character differences highlighting using `<strong>` tagging
7
+ * Inline character differences highlighting using `<span class="diff-marker">` tagging
8
8
  * Blocks of added or removed content wrapped in aria labelled `<ins>` and `<del>` tags
9
9
  * Optional CSS for styling the visual differences
10
10
 
@@ -48,8 +48,118 @@ In your application.scss file include:
48
48
  @import "nokodiff";
49
49
  ```
50
50
 
51
- This will include the styling for `<del>`, `<ins>` and `<strong>` tags to allow colour coding, highlighting and underlining of changes.
51
+ This will include the styling for `<del>`, `<ins>` and `<span>` tags to allow colour coding, highlighting and underlining of changes.
52
+
53
+ ### More complex diffing with `data-diff-key`
54
+
55
+ For more complex HTML structures, a standard diff can produce surprising or misleading results when elements are added, removed, or reordered. To get around this, add a `data-diff-key` attribute to elements you want compared in isolation.
56
+
57
+ Each element with a unique `data-diff-key` value is diffed independently against its counterpart in the other HTML fragment. This prevents unrelated changes from affecting each other and produces more accurate, contextually meaningful output.
58
+
59
+ The key value can be any unique string — it just needs to match between your `before` and `after` HTML for the elements you want paired together.
60
+
61
+ #### Adding a new element
62
+
63
+ When a `data-diff-key` element is present in `after` but not in `before`, its content is wrapped in an `<ins>` tag:
64
+
65
+ ```ruby
66
+ before = <<~HTML
67
+ <div>
68
+ <div data-diff-key="ixn4">
69
+ <p>First paragraph</p>
70
+ </div>
71
+ </div>
72
+ HTML
73
+
74
+ after = <<~HTML
75
+ <div>
76
+ <div data-diff-key="ixn4">
77
+ <p>First paragraph</p>
78
+ </div>
79
+ <div data-diff-key="zm7q">
80
+ <p>Second paragraph</p>
81
+ </div>
82
+ </div>
83
+ HTML
84
+
85
+ Nokodiff.diff(before, after)
86
+ ```
87
+
88
+ Output:
89
+
90
+ ```html
91
+ <div>
92
+ <div data-diff-key="ixn4">
93
+ <p>First paragraph</p>
94
+ </div>
95
+ <div data-diff-key="zm7q">
96
+ <div class="diff">
97
+ <ins aria-label="added content"><p>Second paragraph</p></ins>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ ```
102
+
103
+ ![Adding a new element](docs/adding-a-new-element.png)
104
+
105
+ #### Modifying an existing element
106
+
107
+ When a `data-diff-key` element exists in both fragments but its content has changed, the element is diffed in isolation. Character-level changes are highlighted within `<del>` and `<ins>` tags:
108
+
109
+ ```ruby
110
+ before = <<~HTML
111
+ <div>
112
+ <div data-diff-key="ixn4">
113
+ <p>First paragraph</p>
114
+ </div>
115
+ <div data-diff-key="zm7q">
116
+ <p>Second paragraph</p>
117
+ </div>
118
+ </div>
119
+ HTML
120
+
121
+ after = <<~HTML
122
+ <div>
123
+ <div data-diff-key="ixn4">
124
+ <p>First paragraph</p>
125
+ </div>
126
+ <div data-diff-key="zm7q">
127
+ <p>New paragraph</p>
128
+ </div>
129
+ </div>
130
+ HTML
131
+
132
+ Nokodiff.diff(before, after)
133
+ ```
134
+
135
+ Output:
136
+
137
+ ```html
138
+ <div>
139
+ <div data-diff-key="ixn4"><p>First paragraph</p></div>
140
+ <div data-diff-key="zm7q">
141
+ <div class="diff">
142
+ <del aria-label="removed content"><p><span class="diff-marker">S</span>e<span class="diff-marker">cond</span> paragraph</p></del>
143
+ </div>
144
+ <div class="diff">
145
+ <ins aria-label="added content"><p><span class="diff-marker">N</span>e<span class="diff-marker">w</span> paragraph</p></ins>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ ```
150
+
151
+ ![Modifying an existing element](docs/modifying-an-existing-element.png)
152
+
153
+ #### Unchanged elements
154
+
155
+ If a `data-diff-key` element's content is identical in both fragments, no diff markup is added and the element is returned as-is.
156
+
157
+ #### Limitations
158
+
159
+ Currently, the gem does not support highlighting removed `data-diff-key` elements. If a keyed element is present in `before` but absent from `after`, it will not be marked as deleted in the output.
160
+
161
+ Thanks to [HTML Diff](https://html-diff.lix.dev/index.html) for inspiring this approach!
52
162
 
53
163
  ## Licence
54
164
 
55
- The gem is available as open source under the terms of the [MIT License.](https://opensource.org/license/MIT)
165
+ The gem is available as open source under the terms of the [MIT License.](https://opensource.org/license/MIT)
@@ -1,9 +1,9 @@
1
1
  // stylelint-disable selector-no-qualifying-type, max-nesting-depth
2
2
  // Diff of two editions
3
3
  $added-color: #e6fff3;
4
- $strong-added-color: #99ffcf;
4
+ $diff-marker-added-color: #99ffcf;
5
5
  $removed-color: #fadede;
6
- $strong-removed-color: #f3aeac;
6
+ $diff-marker-removed-color: #f3aeac;
7
7
  $text-colour: #0b0c0c;
8
8
  $light-grey: #f3f2f1;
9
9
  $black: #0b0c0c;
@@ -33,9 +33,9 @@ $black: #0b0c0c;
33
33
  background-color: $removed-color;
34
34
  padding-bottom: 2px;
35
35
 
36
- strong {
36
+ span.diff-marker {
37
37
  font-weight: normal;
38
- background-color: $strong-removed-color;
38
+ background-color: $diff-marker-removed-color;
39
39
  border-bottom: 2px dashed $black;
40
40
  }
41
41
  }
@@ -44,9 +44,9 @@ $black: #0b0c0c;
44
44
  background-color: $added-color;
45
45
  padding-bottom: 2px;
46
46
 
47
- strong {
47
+ span.diff-marker {
48
48
  font-weight: normal;
49
- background-color: $strong-added-color;
49
+ background-color: $diff-marker-added-color;
50
50
  border-bottom: 2px dashed $black;
51
51
  }
52
52
  }
@@ -63,13 +63,13 @@ $black: #0b0c0c;
63
63
 
64
64
  del::before {
65
65
  color: $text-colour;
66
- background-color: $strong-removed-color;
66
+ background-color: $diff-marker-removed-color;
67
67
  content: "−";
68
68
  }
69
69
 
70
70
  ins::before {
71
71
  color: $text-colour;
72
- background-color: $strong-added-color;
72
+ background-color: $diff-marker-added-color;
73
73
  content: "+";
74
74
  }
75
- }
75
+ }
@@ -43,18 +43,18 @@ module Nokodiff
43
43
  append_accumulated_text(before_fragment, accumulated_before_text)
44
44
  append_accumulated_text(after_fragment, accumulated_after_text)
45
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))
46
+ before_fragment.add_child(highlight_changes(change.old_element, before_fragment))
47
+ after_fragment.add_child(highlight_changes(change.new_element, after_fragment))
48
48
  end
49
49
 
50
50
  def emphasise_deletion(change)
51
51
  append_accumulated_text(before_fragment, accumulated_before_text)
52
- before_fragment.add_child(wrap_in_strong(change.old_element, before_fragment))
52
+ before_fragment.add_child(highlight_changes(change.old_element, before_fragment))
53
53
  end
54
54
 
55
55
  def emphasise_addition(change)
56
56
  append_accumulated_text(after_fragment, accumulated_after_text)
57
- after_fragment.add_child(wrap_in_strong(change.new_element, after_fragment))
57
+ after_fragment.add_child(highlight_changes(change.new_element, after_fragment))
58
58
  end
59
59
 
60
60
  def append_accumulated_text(fragment, accumulated_text)
@@ -1,8 +1,8 @@
1
1
  module Nokodiff
2
2
  class Differ
3
- def initialize(before_html, after_html)
4
- @before = Nokogiri::HTML.fragment(before_html)
5
- @after = Nokogiri::HTML.fragment(after_html)
3
+ def initialize(before, after)
4
+ @before = before
5
+ @after = after
6
6
  end
7
7
 
8
8
  def to_html
@@ -42,8 +42,8 @@ module Nokodiff
42
42
 
43
43
  before_fragment, after_fragment = Nokodiff::TextNodeDiffs.new(before_dup, after_dup).call
44
44
 
45
- merge_adjacent_strong_tags(before_fragment)
46
- merge_adjacent_strong_tags(after_fragment)
45
+ merge_adjacent_highlighted_changes(before_fragment)
46
+ merge_adjacent_highlighted_changes(after_fragment)
47
47
 
48
48
  [before_fragment.to_html, after_fragment.to_html]
49
49
  end
@@ -62,24 +62,28 @@ module Nokodiff
62
62
  end
63
63
  end
64
64
 
65
- def merge_adjacent_strong_tags(node)
65
+ def merge_adjacent_highlighted_changes(node)
66
66
  return unless node.element?
67
67
 
68
68
  node.children.each do |child|
69
- merge_adjacent_strong_tags(child) if child.element?
69
+ merge_adjacent_highlighted_changes(child) if child.element?
70
70
  end
71
71
 
72
72
  node.children.each_cons(2) do |left, right|
73
- next unless left.name == "strong" && right.name == "strong"
73
+ next unless node_is_a_change?(left) && node_is_a_change?(right)
74
74
 
75
75
  left.content = left.content + right.content
76
76
  right.remove
77
77
 
78
- merge_adjacent_strong_tags(node)
78
+ merge_adjacent_highlighted_changes(node)
79
79
  break
80
80
  end
81
81
  end
82
82
 
83
+ def node_is_a_change?(node)
84
+ node.name == "span" && node["class"] == "diff-marker"
85
+ end
86
+
83
87
  def unchanged_block(html)
84
88
  html.to_s
85
89
  end
@@ -1,11 +1,14 @@
1
1
  module Nokodiff
2
2
  module FormattingHelpers
3
- def wrap_in_strong(char, fragment)
4
- Nokogiri::XML::Node.new("strong", fragment.document).tap { |n| n.content = char }
3
+ def highlight_changes(char, fragment)
4
+ Nokogiri::XML::Node.new("span", fragment.document).tap do |n|
5
+ n.content = char
6
+ n["class"] = "diff-marker"
7
+ end
5
8
  end
6
9
 
7
- def strong(text_node)
8
- text_node.replace(wrap_in_strong(text_node.to_html, text_node.parent))
10
+ def highlighted_change(text_node)
11
+ text_node.replace(highlight_changes(text_node.to_html, text_node.parent))
9
12
  end
10
13
  end
11
14
  end
@@ -0,0 +1,40 @@
1
+ require "forwardable"
2
+
3
+ module Nokodiff
4
+ class HTMLFragment
5
+ extend Forwardable
6
+
7
+ class InvalidHTMLError < StandardError; end
8
+
9
+ def initialize(html)
10
+ @fragment = Nokogiri::HTML.fragment(html)
11
+ validate!
12
+ remove_blank_nodes!
13
+ remove_comments!
14
+ end
15
+
16
+ def_delegators :@fragment, :children, :css, :at, :to_html
17
+
18
+ private
19
+
20
+ def validate!
21
+ invalid_text_nodes = @fragment.children.reject do |node|
22
+ node.element? || node.comment? || (node.text? && node.text.strip.empty?)
23
+ end
24
+
25
+ unless invalid_text_nodes.empty?
26
+ raise InvalidHTMLError, "Invalid HTML input: #{@fragment.to_html}"
27
+ end
28
+ end
29
+
30
+ def remove_blank_nodes!
31
+ @fragment.traverse do |node|
32
+ node.remove if node.blank?
33
+ end
34
+ end
35
+
36
+ def remove_comments!
37
+ @fragment.css("comment()").remove
38
+ end
39
+ end
40
+ end
@@ -31,8 +31,8 @@ module Nokodiff
31
31
  end
32
32
 
33
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)
34
+ return highlighted_change(before_text_node) if text_removed?(before_text_node, after_text_node)
35
+ return highlighted_change(after_text_node) if text_added?(before_text_node, after_text_node)
36
36
 
37
37
  before_chars = before_text_node.text.chars
38
38
  after_chars = after_text_node.text.chars
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nokodiff
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/nokodiff.rb CHANGED
@@ -9,13 +9,17 @@ require_relative "nokodiff/differ"
9
9
  require_relative "nokodiff/engine"
10
10
  require_relative "nokodiff/text_node_diffs"
11
11
  require_relative "nokodiff/changes_in_fragments"
12
+ require_relative "nokodiff/html_fragment"
12
13
 
13
14
  module Nokodiff
14
15
  def self.diff(before_html, after_html)
15
- HTMLFragmentValidator.validate_html!(before_html)
16
- HTMLFragmentValidator.validate_html!(after_html)
16
+ before = Nokodiff::HTMLFragment.new(before_html)
17
+ after = Nokodiff::HTMLFragment.new(after_html)
17
18
 
18
- html = Differ.new(before_html, after_html).to_html
19
+ before_nodes, after_nodes = nodes(before, after)
20
+ keys = (before_nodes.keys + after_nodes.keys).uniq
21
+
22
+ html = keys.any? ? diff_by_keys(after, keys, before_nodes, after_nodes) : Differ.new(before, after).to_html
19
23
  safe_html(html)
20
24
  end
21
25
 
@@ -23,31 +27,25 @@ module Nokodiff
23
27
  html.respond_to?(:html_safe) ? html.html_safe : html
24
28
  end
25
29
 
26
- class Error < StandardError; end
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
30
+ private_class_method def self.nodes(before, after)
31
+ [
32
+ fetch_diff_nodes(before),
33
+ fetch_diff_nodes(after),
34
+ ]
35
+ end
45
36
 
46
- unless invalid_text_nodes.empty?
47
- raise ArgumentError, "Invalid HTML input"
48
- end
37
+ private_class_method def self.fetch_diff_nodes(fragment)
38
+ fragment.css("[data-diff-key]").map { |node| [node["data-diff-key"], node] }.to_h
39
+ end
49
40
 
50
- document
41
+ private_class_method def self.diff_by_keys(after, keys, before_nodes, after_nodes)
42
+ keys.each do |key|
43
+ diff = Differ.new(
44
+ before_nodes.fetch(key, Nokogiri::HTML.fragment("")),
45
+ after_nodes.fetch(key, Nokogiri::HTML.fragment("")),
46
+ ).to_html
47
+ after.at("[data-diff-key='#{key}']")&.inner_html = diff
51
48
  end
49
+ after.to_html
52
50
  end
53
51
  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.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - GOV.UK Dev
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - '='
73
73
  - !ruby/object:Gem::Version
74
- version: 5.1.20
74
+ version: 5.2.0
75
75
  type: :development
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - '='
80
80
  - !ruby/object:Gem::Version
81
- version: 5.1.20
81
+ version: 5.2.0
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: simplecov
84
84
  requirement: !ruby/object:Gem::Requirement
@@ -209,6 +209,7 @@ files:
209
209
  - lib/nokodiff/differ.rb
210
210
  - lib/nokodiff/engine.rb
211
211
  - lib/nokodiff/formatting_helpers.rb
212
+ - lib/nokodiff/html_fragment.rb
212
213
  - lib/nokodiff/text_node_diffs.rb
213
214
  - lib/nokodiff/version.rb
214
215
  homepage: https://github.com/alphagov/nokodiff
@@ -229,7 +230,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
229
230
  - !ruby/object:Gem::Version
230
231
  version: '0'
231
232
  requirements: []
232
- rubygems_version: 4.0.3
233
+ rubygems_version: 4.0.7
233
234
  specification_version: 4
234
235
  summary: A Ruby Gem to highlight additions, deletions and character level changes
235
236
  while preserving original HTML