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(
+ '',
+ '',
+ );
+
+ // 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(
+ '',
+ '',
+ );
+
+ // 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()
{