nokodiff 0.1.0 → 0.3.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 +4 -4
- data/README.md +160 -2
- data/app/assets/stylesheets/nokodiff.scss +75 -0
- data/lib/nokodiff/changes_in_fragments.rb +67 -0
- data/lib/nokodiff/differ.rb +103 -0
- data/lib/nokodiff/engine.rb +7 -0
- data/lib/nokodiff/formatting_helpers.rb +11 -0
- data/lib/nokodiff/html_fragment.rb +40 -0
- data/lib/nokodiff/text_node_diffs.rb +56 -0
- data/lib/nokodiff/version.rb +1 -1
- data/lib/nokodiff.rb +45 -2
- metadata +53 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e12bce0257af1ebff2c6de595dce5d68cefc73feb5ae4a419a98a81d9dbac212
|
|
4
|
+
data.tar.gz: 9479aa3c4ae7f3481a0a96baf002735fb02a4848530a9fc38565c72118e4a2eb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 864f165ec8332dbcc0a61cffda45f6649ed00d5483e969ad7d55dc9af2e4acdbe202f54686424ff387f6f36ecd160b3834e89f4d1633a8bf311534b6c09eb0b3
|
|
7
|
+
data.tar.gz: f182809b76cf85e8ce23002575fb5c2708e527cd3659628d0ef248fdce79e1581ed6a551f99ca41a16dff7bacf9d67db87579185761052136e3f491d227f8bf6
|
data/README.md
CHANGED
|
@@ -1,7 +1,165 @@
|
|
|
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.
|
|
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
|
+

|
|
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><strong>S</strong>e<strong>cond</strong> paragraph</p></del>
|
|
143
|
+
</div>
|
|
144
|
+
<div class="diff">
|
|
145
|
+
<ins aria-label="added content"><p><strong>N</strong>e<strong>w</strong> paragraph</p></ins>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+

|
|
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!
|
|
4
162
|
|
|
5
163
|
## Licence
|
|
6
164
|
|
|
7
|
-
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)
|
|
@@ -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, after)
|
|
4
|
+
@before = before
|
|
5
|
+
@after = after
|
|
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,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,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
|
|
@@ -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
|
data/lib/nokodiff/version.rb
CHANGED
data/lib/nokodiff.rb
CHANGED
|
@@ -1,8 +1,51 @@
|
|
|
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"
|
|
12
|
+
require_relative "nokodiff/html_fragment"
|
|
4
13
|
|
|
5
14
|
module Nokodiff
|
|
6
|
-
|
|
7
|
-
|
|
15
|
+
def self.diff(before_html, after_html)
|
|
16
|
+
before = Nokodiff::HTMLFragment.new(before_html)
|
|
17
|
+
after = Nokodiff::HTMLFragment.new(after_html)
|
|
18
|
+
|
|
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
|
|
23
|
+
safe_html(html)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.safe_html(html)
|
|
27
|
+
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private_class_method def self.nodes(before, after)
|
|
31
|
+
[
|
|
32
|
+
fetch_diff_nodes(before),
|
|
33
|
+
fetch_diff_nodes(after),
|
|
34
|
+
]
|
|
35
|
+
end
|
|
36
|
+
|
|
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
|
|
40
|
+
|
|
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
|
|
48
|
+
end
|
|
49
|
+
after.to_html
|
|
50
|
+
end
|
|
8
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.
|
|
4
|
+
version: 0.3.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
|
|
@@ -57,14 +71,14 @@ dependencies:
|
|
|
57
71
|
requirements:
|
|
58
72
|
- - '='
|
|
59
73
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: 5.
|
|
74
|
+
version: 5.2.0
|
|
61
75
|
type: :development
|
|
62
76
|
prerelease: false
|
|
63
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
78
|
requirements:
|
|
65
79
|
- - '='
|
|
66
80
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: 5.
|
|
81
|
+
version: 5.2.0
|
|
68
82
|
- !ruby/object:Gem::Dependency
|
|
69
83
|
name: simplecov
|
|
70
84
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,14 @@ 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/html_fragment.rb
|
|
213
|
+
- lib/nokodiff/text_node_diffs.rb
|
|
173
214
|
- lib/nokodiff/version.rb
|
|
174
215
|
homepage: https://github.com/alphagov/nokodiff
|
|
175
216
|
licenses:
|
|
@@ -189,7 +230,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
189
230
|
- !ruby/object:Gem::Version
|
|
190
231
|
version: '0'
|
|
191
232
|
requirements: []
|
|
192
|
-
rubygems_version: 4.0.
|
|
233
|
+
rubygems_version: 4.0.7
|
|
193
234
|
specification_version: 4
|
|
194
235
|
summary: A Ruby Gem to highlight additions, deletions and character level changes
|
|
195
236
|
while preserving original HTML
|