diff --git a/src/Renderers/HtmlDiff.php b/src/Renderers/HtmlDiff.php index 29ed981..48937bd 100644 --- a/src/Renderers/HtmlDiff.php +++ b/src/Renderers/HtmlDiff.php @@ -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 { @@ -206,25 +207,19 @@ 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>/s', '$1') - // Wholly new : drop the whole row, not just its text. - ->replaceMatches( - '/]*)?>\s*(?:<(td|th)(\s[^>]*)?>(?:\s*]*>.*?<\/ins>)+\s*<\/\2>\s*)+<\/tr>/is', '' - ) - // Wholly new
  • /

    //: drop the element, not just its text. - ->replaceMatches( - '/<(li|p|td|th)(\s[^>]*)?>(?:\s*]*>.*?<\/ins>)+\s*<\/\1>/is', '' - ) + ->renameElements('//ins[@class="mod"]', 'del') + // Drop

  • /

    /// 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>/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 classes. - ->replaceMatches('/]*>/', '') - ->toString(); + ->removeAttribute('//del[not(@class="mod")]', 'class') + ->toHtml(); } /** @@ -232,25 +227,16 @@ protected function beforeView(string $merged): string */ protected function afterView(string $merged): string { - return Str::of($merged) + return (new HtmlFragment($merged)) + // Drop

  • /

    /// elements that only ever held removed content. + ->removeElementsEmptiedBy('//li | //p | //td | //th | //tr', 'del') // Deleted text no longer exists, so drop it. - ->replaceMatches('/]*>.*?<\/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 classes, but keep the "mod" marker. - ->replaceMatches('/]*>/', '') - ->toString(); - } - - /** - * Strip now-empty containers (e.g.

      , ) 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(); } } diff --git a/src/Renderers/Support/HtmlFragment.php b/src/Renderers/Support/HtmlFragment.php new file mode 100644 index 0000000..5c4ee9b --- /dev/null +++ b/src/Renderers/Support/HtmlFragment.php @@ -0,0 +1,222 @@ +dom = new DOMDocument; + + $previousSetting = libxml_use_internal_errors(true); + + $this->dom->loadHTML( + '
      ' . $html . '
      ', + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD + ); + + libxml_use_internal_errors($previousSetting); + + $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
        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.
      • would come back out as just
      • ); 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 / 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; + } +} diff --git a/tests/HtmlDiffRendererTest.php b/tests/HtmlDiffRendererTest.php index eb8601c..81f85ab 100644 --- a/tests/HtmlDiffRendererTest.php +++ b/tests/HtmlDiffRendererTest.php @@ -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

        , one level deeper than a bare

      • + $htmlDiff = $this->diffFor( + '
        • one

        ', + '
        • one

        • two

        ', + ); + + // When + $result = $htmlDiff->field('value'); + + // Then — the whole
      • is gone, not left behind as an empty
      • + $this->assertSame(1, preg_match_all('/]*>/', $result['before'])); + $this->assertStringNotContainsString('

        ', $result['before']); + $this->assertStringContainsString('', $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( + '
        • one

        • two

        ', + '
        • one

        ', + ); + + // When + $result = $htmlDiff->field('value'); + + // Then — the whole
      • is gone from the after view, not left behind as
      • + $this->assertSame(1, preg_match_all('/]*>/', $result['after'])); + $this->assertStringNotContainsString('

        ', $result['after']); + $this->assertStringContainsString('', $result['before']); + $this->assertStringContainsString('two', $result['before']); + } + #[Test] public function it_omits_a_wholly_new_list_from_the_before_view() {