-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathHtmlDiff.php
More file actions
242 lines (212 loc) · 8.59 KB
/
Copy pathHtmlDiff.php
File metadata and controls
242 lines (212 loc) · 8.59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
<?php
namespace TestMonitor\Revisable\Renderers;
use Illuminate\Support\Str;
use Jfcherng\Diff\DiffHelper;
use Ssddanbrown\HtmlDiff\Diff as HtmlDiffer;
use TestMonitor\Revisable\Diff;
use TestMonitor\Revisable\Renderers\Support\HtmlFragment;
class HtmlDiff
{
/**
* @param string $detailLevel Granularity of inline highlighting: 'none'|'line'|'word'|'char'
* For HTML fields, only 'none' is mapped; all other levels resolve to word-level
* because the underlying HTML differ does not support finer granularity.
* @param string $lineSeparator String placed between cells when a multi-line value is joined
*/
public function __construct(
protected Diff $diff,
protected string $detailLevel = 'word',
protected string $lineSeparator = '<br>',
) {}
/**
* Render an HTML diff for a tracked field, returning separate before and after views.
*
* Returns null when the field is not tracked in the diff.
*
* @return array{before: string|array, after: string|array}|null
*/
public function field(string $field): ?array
{
$value = $this->diff->get($field);
if ($value === null) {
return null;
}
// Normalize the before/after values, then diff them.
$before = $this->normalize($value['before'] ?? '');
$after = $this->normalize($value['after'] ?? '');
// No prior value at all: don't pad the before side with a blank line per new item.
if (is_array($after) && ($value['before'] ?? null) === null) {
return $this->diffNewArray($after);
}
// No value anymore: don't pad the after side with a blank line per removed item.
if (is_array($before) && ($value['after'] ?? null) === null) {
return $this->diffRemovedArray($before);
}
if (is_array($before) || is_array($after)) {
return $this->diffArray((array) $before, (array) $after);
}
return $this->diffValue($before, $after);
}
/**
* Normalize a value: JSON strings are decoded, all other values pass through.
*/
protected function normalize(mixed $value): mixed
{
return Str::isJson($value) ? json_decode($value, true) : $value;
}
/**
* Build an HTML diff for a single before/after string pair.
*
* @return array{before: string, after: string}
*/
protected function diffValue(mixed $before, mixed $after): array
{
$before = (string) $before;
$after = (string) $after;
return $this->containsHtml($before) || $this->containsHtml($after)
? $this->diffHtmlValue($before, $after)
: $this->diffPlainValue($before, $after);
}
/**
* Build HTML diffs for each pair in parallel before/after arrays.
*
* @return array{before: list<string>, after: list<string>}
*/
protected function diffArray(array $before, array $after): array
{
$diffs = collect(array_map(null, $before, $after))
->map(fn (array $pair) => $this->diffValue((string) ($pair[0] ?? ''), (string) ($pair[1] ?? '')))
->reject(fn (array $pair) => blank(strip_tags($pair['before'])) && blank(strip_tags($pair['after'])))
->values();
return [
'before' => $diffs->pluck('before')->all(),
'after' => $diffs->pluck('after')->all(),
];
}
/**
* Build the after view for an array field that had no prior value at all.
*
* @return array{before: list<string>, after: list<string>}
*/
protected function diffNewArray(array $after): array
{
$after = collect($after)
->map(fn (mixed $item) => $this->diffValue('', (string) $item)['after'])
->reject(fn (string $item) => blank(strip_tags($item)))
->values();
return ['before' => [], 'after' => $after->all()];
}
/**
* Build the before view for an array field that no longer has any value.
*
* @return array{before: list<string>, after: list<string>}
*/
protected function diffRemovedArray(array $before): array
{
$before = collect($before)
->map(fn (mixed $item) => $this->diffValue((string) $item, '')['before'])
->reject(fn (string $item) => blank(strip_tags($item)))
->values();
return ['before' => $before->all(), 'after' => []];
}
/**
* Build a plain-text diff, escaping identical values and delegating changes to jfcherng/php-diff.
*
* @return array{before: string, after: string}
*/
protected function diffPlainValue(string $before, string $after): array
{
if ($before === $after) {
return ['before' => $this->escape($before), 'after' => $this->escape($after)];
}
$diff = DiffHelper::calculate(
old: $before,
new: $after,
renderer: 'SideBySide',
differOptions: [],
rendererOptions: ['showHeader' => false, 'lineNumbers' => false, 'detailLevel' => $this->detailLevel],
);
return [
'before' => $this->extractCells($diff, 'old'),
'after' => $this->extractCells($diff, 'new'),
];
}
/**
* HTML-encode a string, consistent with jfcherng's own encoding of diffed values.
*/
protected function escape(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* Extract the inner HTML of all <td class="$side"> cells from SideBySide output.
*/
protected function extractCells(string $diff, string $side): string
{
return Str::of($diff)
->matchAll('/<td class="' . $side . '">(.*?)<\/td>/s')
->implode($this->lineSeparator);
}
/**
* Return true when the string contains at least one HTML tag.
*/
protected function containsHtml(string $value): bool
{
return Str::of($value)->test('/<\s*\/?\s*[a-zA-Z][^>]*>/');
}
/**
* Build an HTML diff for values where at least one contains HTML markup.
*
* Delegates to ssddanbrown/htmldiff for DOM-aware diffing, then splits the
* merged result into separate before (del-marked) and after (ins-marked) views.
*
* @return array{before: string, after: string}
*/
protected function diffHtmlValue(string $before, string $after): array
{
if ($this->detailLevel === 'none') {
return ['before' => $before, 'after' => $after];
}
$merged = (new HtmlDiffer($before, $after))->build();
return [
'before' => $this->beforeView($merged),
'after' => $this->afterView($merged),
];
}
/**
* Extract the before view: remove inserted content, normalise <del> tags.
*/
protected function beforeView(string $merged): string
{
return (new HtmlFragment($merged))
// Formatting-only change: show the old formatting instead of dropping the text.
->renameElements('//ins[@class="mod"]', 'del')
// Drop <li>/<p>/<td>/<th>/<tr> elements that only ever held new content.
->removeElementsEmptiedBy('//li | //p | //td | //th | //tr', 'ins')
// Any other inserted text didn't exist yet, so drop it.
->removeElements('//ins')
// A wholly new list/table leaves an empty wrapper behind once its rows/items are
// gone; drop those too, repeating since removing one can empty its own parent.
->removeEmptyElements('//ul | //ol | //table | //tbody | //thead')
// Normalise the differ's diff-specific <del> classes.
->removeAttribute('//del[not(@class="mod")]', 'class')
->toHtml();
}
/**
* Extract the after view: remove deleted content, normalise <ins> tags.
*/
protected function afterView(string $merged): string
{
return (new HtmlFragment($merged))
// Drop <li>/<p>/<td>/<th>/<tr> elements that only ever held removed content.
->removeElementsEmptiedBy('//li | //p | //td | //th | //tr', 'del')
// Deleted text no longer exists, so drop it.
->removeElements('//del')
// A wholly deleted list/table leaves an empty wrapper behind once its rows/items
// are gone; drop those too.
->removeEmptyElements('//ul | //ol | //table | //tbody | //thead')
// Normalise diff-specific <ins> classes, but keep the "mod" marker.
->removeAttribute('//ins[not(@class="mod")]', 'class')
->toHtml();
}
}