Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 18 additions & 32 deletions src/Renderers/HtmlDiff.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Jfcherng\Diff\DiffHelper;
use Ssddanbrown\HtmlDiff\Diff as HtmlDiffer;
use TestMonitor\Revisable\Diff;
use TestMonitor\Revisable\Renderers\Support\HtmlFragment;

class HtmlDiff
{
Expand Down Expand Up @@ -206,51 +207,36 @@ protected function diffHtmlValue(string $before, string $after): array
*/
protected function beforeView(string $merged): string
{
return Str::of($merged)
return (new HtmlFragment($merged))
// Formatting-only change: show the old formatting instead of dropping the text.
->replaceMatches('/<ins class="mod">(.*?)<\/ins>/s', '<del class="mod">$1</del>')
// Wholly new <tr>: drop the whole row, not just its text.
->replaceMatches(
'/<tr(\s[^>]*)?>\s*(?:<(td|th)(\s[^>]*)?>(?:\s*<ins[^>]*>.*?<\/ins>)+\s*<\/\2>\s*)+<\/tr>/is', ''
)
// Wholly new <li>/<p>/<td>/<th>: drop the element, not just its text.
->replaceMatches(
'/<(li|p|td|th)(\s[^>]*)?>(?:\s*<ins[^>]*>.*?<\/ins>)+\s*<\/\1>/is', ''
)
->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.
->replaceMatches('/<ins[^>]*>.*?<\/ins>/s', '')
->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.
->pipe(fn ($html) => $this->stripEmptyContainers((string) $html))
->removeEmptyElements('//ul | //ol | //table | //tbody | //thead')
// Normalise the differ's diff-specific <del> classes.
->replaceMatches('/<del(?! class="mod")[^>]*>/', '<del>')
->toString();
->removeAttribute('//del[not(@class="mod")]', 'class')
->toHtml();
}

/**
* Extract the after view: remove deleted content, normalise <ins> tags.
*/
protected function afterView(string $merged): string
{
return Str::of($merged)
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.
->replaceMatches('/<del[^>]*>.*?<\/del>/s', '')
->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.
->replaceMatches('/<ins(?! class="mod")[^>]*>/', '<ins>')
->toString();
}

/**
* Strip now-empty containers (e.g. <ul>, <table>) left behind by removed insertions.
*/
protected function stripEmptyContainers(string $html): string
{
$pattern = '/<(ul|ol|table|tbody|thead)(?:\s[^>]*)?>\s*<\/\1>/is';

do {
$html = preg_replace($pattern, '', $html, -1, $count);
} while ($count > 0);

return $html;
->removeAttribute('//ins[not(@class="mod")]', 'class')
->toHtml();
}
}
222 changes: 222 additions & 0 deletions src/Renderers/Support/HtmlFragment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
<?php

namespace TestMonitor\Revisable\Renderers\Support;

use DOMDocument;
use DOMElement;
use DOMNode;
use DOMText;
use DOMXPath;

/**
* A small, fluent wrapper around DOMDocument for editing an HTML snippet in place.
*
* Parses the snippet once, exposes a handful of purpose-built mutations, and
* serializes it back out — keeping DOM/libxml mechanics out of the caller.
*/
class HtmlFragment
{
protected DOMDocument $dom;

protected DOMElement $root;

protected DOMXPath $xpath;

public function __construct(string $html)
{
$this->dom = new DOMDocument;

$previousSetting = libxml_use_internal_errors(true);

$this->dom->loadHTML(
'<?xml encoding="utf-8"?><div id="__root__">' . $html . '</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);

libxml_use_internal_errors($previousSetting);
Comment on lines +29 to +36

$this->xpath = new DOMXPath($this->dom);
$this->root = $this->xpath->query('//*[@id="__root__"]')->item(0);
}

/**
* Rename every element matched by $query to $tagName, preserving its attributes and children.
*/
public function renameElements(string $query, string $tagName): static
{
foreach (iterator_to_array($this->xpath->query($query)) as $element) {
$replacement = $this->dom->createElement($tagName);

foreach (iterator_to_array($element->attributes) as $attribute) {
$replacement->setAttribute($attribute->name, $attribute->value);
}

while ($element->firstChild) {
$replacement->appendChild($element->firstChild);
}

$element->parentNode->replaceChild($replacement, $element);
}

return $this;
}

/**
* Remove every element matched by $query, along with its content.
*/
public function removeElements(string $query): static
{
foreach (iterator_to_array($this->xpath->query($query)) as $element) {
$element->parentNode->removeChild($element);
}

return $this;
}

/**
* Drop an attribute from every element matched by $query.
*/
public function removeAttribute(string $query, string $attribute): static
{
foreach ($this->xpath->query($query) as $element) {
$element->removeAttribute($attribute);
}

return $this;
}

/**
* Repeatedly remove elements matched by $selector once they're left with no visible
* content — e.g. a <ul> whose items were all removed. Repeats since emptying one
* element can in turn leave its own parent empty too.
*/
public function removeEmptyElements(string $selector): static
{
do {
$removed = 0;

foreach (iterator_to_array($this->xpath->query($selector)) as $element) {
if (! $this->isBlank($element)) {
continue;
}

$element->parentNode->removeChild($element);
$removed++;
}
} while ($removed > 0);

return $this;
}

/**
* Remove elements matched by $selector that are left with no visible content once their
* $descendantTag descendants are disregarded — regardless of how deeply nested they are.
* Elements that already had no visible content before this call are left untouched.
*/
public function removeElementsEmptiedBy(string $selector, string $descendantTag): static
{
$candidates = iterator_to_array($this->xpath->query($selector));

$preexistingEmpty = [];

foreach ($candidates as $element) {
if ($this->isBlank($element)) {
$preexistingEmpty[spl_object_id($element)] = true;
}
}

// Deepest elements first, so clearing an inner element can empty out its ancestor too.
foreach (array_reverse($candidates) as $element) {
if (isset($preexistingEmpty[spl_object_id($element)])) {
continue;
}

// A nested candidate that held all of this element's content was just removed.
if ($this->isBlank($element)) {
$element->parentNode->removeChild($element);

continue;
}

if ($this->xpath->query(".//{$descendantTag}", $element)->length === 0) {
continue;
}

if (trim($this->textOutside($element, $descendantTag)) === '') {
$element->parentNode->removeChild($element);
}
}

return $this;
}

/**
* Serialize the fragment back into an HTML string.
*/
public function toHtml(): string
{
// Childless elements would otherwise have their closing tag silently dropped by
// libxml's HTML serializer (e.g. <li></li> would come back out as just <li>); a
// placeholder text node forces it to always emit both tags.
foreach (iterator_to_array($this->xpath->query('.//*[not(node())]', $this->root)) as $element) {
$element->appendChild($this->dom->createTextNode(''));
}

$html = '';

foreach ($this->root->childNodes as $child) {
$html .= $this->dom->saveHTML($child);
}

return $html;
}

/**
* Concatenate the visible text directly inside $element, excluding any text nested
* inside a descendant $tagName element (e.g. the <ins>/<del> being disregarded).
*/
protected function textOutside(DOMNode $element, string $tagName): string
{
$text = '';

foreach ($this->xpath->query('.//text()', $element) as $textNode) {
if (! $this->hasAncestorNamed($textNode, $tagName, $element)) {
$text .= $textNode->textContent;
}
}

return $text;
}

/**
* Determine whether $node has an ancestor named $tagName before reaching $boundary.
*/
protected function hasAncestorNamed(DOMNode $node, string $tagName, DOMNode $boundary): bool
{
$ancestor = $node->parentNode;

while ($ancestor !== null && $ancestor !== $boundary) {
if ($ancestor->nodeName === $tagName) {
return true;
}

$ancestor = $ancestor->parentNode;
}

return false;
}

/**
* Determine whether $element has no content beyond whitespace-only text.
*/
protected function isBlank(DOMNode $element): bool
{
foreach ($element->childNodes as $child) {
if (! ($child instanceof DOMText) || trim($child->wholeText) !== '') {
return false;
}
}

return true;
}
}
38 changes: 38 additions & 0 deletions tests/HtmlDiffRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,44 @@ public function it_omits_a_newly_added_table_row_from_the_before_view()
$this->assertStringContainsString('two', $result['after']);
}

#[Test]
public function it_omits_a_newly_added_nested_list_item_from_the_before_view()
{
// Given — the new bullet's text sits inside a <p>, one level deeper than a bare <li>
$htmlDiff = $this->diffFor(
'<ul><li><p>one</p></li></ul>',
'<ul><li><p>one</p></li><li><p>two</p></li></ul>',
);

// When
$result = $htmlDiff->field('value');

// Then — the whole <li> is gone, not left behind as an empty <li><p></p></li>
$this->assertSame(1, preg_match_all('/<li[^>]*>/', $result['before']));
$this->assertStringNotContainsString('<p></p>', $result['before']);
$this->assertStringContainsString('<ins>', $result['after']);
$this->assertStringContainsString('two', $result['after']);
}

#[Test]
public function it_omits_a_wholly_deleted_nested_list_item_from_the_after_view()
{
// Given — the second bullet's text was entirely cleared, tag structure left intact
$htmlDiff = $this->diffFor(
'<ul><li><p>one</p></li><li><p>two</p></li></ul>',
'<ul><li><p>one</p></li><li><p></p></li></ul>',
);

// When
$result = $htmlDiff->field('value');

// Then — the whole <li> is gone from the after view, not left behind as <li><p></p></li>
$this->assertSame(1, preg_match_all('/<li[^>]*>/', $result['after']));
$this->assertStringNotContainsString('<p></p>', $result['after']);
$this->assertStringContainsString('<del>', $result['before']);
$this->assertStringContainsString('two', $result['before']);
}

#[Test]
public function it_omits_a_wholly_new_list_from_the_before_view()
{
Expand Down