diff --git a/src/Integrations/Divi/BlockTree/Block.php b/src/Integrations/Divi/BlockTree/Block.php new file mode 100644 index 0000000..532f8ae --- /dev/null +++ b/src/Integrations/Divi/BlockTree/Block.php @@ -0,0 +1,137 @@ + $attrs + */ + private function __construct( + private string $name, + private array $attrs, + private ?string $rawOpen, + private ?string $rawClose, + private bool $void, + private bool $opaque + ) { + } + + /** @param array $attrs */ + public static function parsed(string $name, array $attrs, string $rawOpen, bool $void, bool $opaque): self + { + return new self($name, $attrs, $rawOpen, null, $void, $opaque); + } + + /** + * A new block (opener and closer are generated; written as a pair like Divi does). + * + * @param array $attrs + * @param list $children + */ + public static function create(string $name, array $attrs = [], array $children = []): self + { + $block = new self($name, $attrs, null, null, false, false); + foreach ($children as $child) { + $block->parts[] = $child; + } + + return $block; + } + + /** Used by the Parser for the closing delimiter. */ + public function closeWith(string $rawClose): void + { + $this->rawClose = $rawClose; + } + + /** Full block name, e.g. "divi/section"; blocks without namespace are "core/…". */ + public function name(): string + { + return $this->name; + } + + /** Name without the "divi/" namespace, e.g. "button"; other namespaces stay. */ + public function shortName(): string + { + return str_starts_with($this->name, 'divi/') ? substr($this->name, 5) : $this->name; + } + + /** @return array */ + public function attrs(): array + { + return $this->attrs; + } + + /** @param array $attrs */ + public function setAttrs(array $attrs): void + { + $this->attrs = $attrs; + $this->rawOpen = null; + } + + public function rawOpen(): ?string + { + return $this->rawOpen; + } + + public function rawClose(): ?string + { + return $this->rawClose; + } + + public function isVoid(): bool + { + return $this->void; + } + + public function isOpaque(): bool + { + return $this->opaque; + } + + public function isDivi(): bool + { + return str_starts_with($this->name, 'divi/'); + } + + /** A global module reference or global layout (edited only in Etappe 3). */ + public function isGlobal(): bool + { + return $this->name === 'divi/global-layout' || array_key_exists('globalModule', $this->attrs); + } + + /** Deep copy – unchanged copies serialize to exactly the same bytes. */ + public function copy(): self + { + return clone $this; + } + + public function __clone() + { + foreach ($this->parts as $position => $part) { + if ($part instanceof Block) { + $this->parts[$position] = clone $part; + } + } + } + + protected function touched(): void + { + if ($this->void) { + $this->void = false; + $this->rawOpen = null; + } + } +} diff --git a/src/Integrations/Divi/BlockTree/Container.php b/src/Integrations/Divi/BlockTree/Container.php new file mode 100644 index 0000000..08cd5f1 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/Container.php @@ -0,0 +1,93 @@ + */ + protected array $parts = []; + + /** @return list */ + public function parts(): array + { + return $this->parts; + } + + /** @return list */ + public function children(): array + { + return array_values(array_filter($this->parts, static fn (string|Block $part): bool => $part instanceof Block)); + } + + /** Used by the Parser: adds a part without marking the container as changed. */ + public function append(string|Block $part): void + { + $this->parts[] = $part; + } + + /** Child position of $block (0-based among the child blocks), null when it is no child. */ + public function indexOf(Block $block): ?int + { + $index = array_search($block, $this->children(), true); + + return $index === false ? null : (int) $index; + } + + /** + * Inserts $block so it becomes child number $index (0 = first; count or more = after the + * last child). Text between existing children stays where it is. + */ + public function insertChild(int $index, Block $block): void + { + $children = $this->children(); + $index = max(0, min($index, count($children))); + if ($index < count($children)) { + $position = $this->partPosition($children[$index]); + } elseif ($children === []) { + $position = count($this->parts); + } else { + $position = $this->partPosition($children[count($children) - 1]) + 1; + } + + array_splice($this->parts, $position, 0, [$block]); + $this->touched(); + } + + /** Removes the child block; the text around it stays. Returns its former child index. */ + public function removeChild(Block $block): int + { + $index = $this->indexOf($block); + if ($index === null) { + throw new InvalidArgumentException('Block is not a child of this container.'); + } + + array_splice($this->parts, $this->partPosition($block), 1); + $this->touched(); + + return $index; + } + + /** Called after the list of children changed. */ + protected function touched(): void + { + } + + private function partPosition(Block $block): int + { + $position = array_search($block, $this->parts, true); + if ($position === false) { + throw new InvalidArgumentException('Block is not a child of this container.'); + } + + return (int) $position; + } +} diff --git a/src/Integrations/Divi/BlockTree/Document.php b/src/Integrations/Divi/BlockTree/Document.php new file mode 100644 index 0000000..49849ac --- /dev/null +++ b/src/Integrations/Divi/BlockTree/Document.php @@ -0,0 +1,12 @@ +|null Null when $json is not a JSON object. */ + public static function decodeObject(string $json): ?array + { + try { + $value = json_decode($json, false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + if (! $value instanceof stdClass) { + return null; + } + + $converted = self::fromJson($value); + + return is_array($converted) ? $converted : []; + } + + /** Converts json_decode(…, false) output: objects become arrays unless that would lose their object-ness. */ + public static function fromJson(mixed $value): mixed + { + if ($value instanceof stdClass) { + $array = []; + foreach (get_object_vars($value) as $key => $item) { + $array[$key] = self::fromJson($item); + } + + return $array === [] || array_is_list($array) ? (object) $array : $array; + } + + if (is_array($value)) { + return array_map([self::class, 'fromJson'], $value); + } + + return $value; + } + + /** @throws JsonException */ + public static function encode(mixed $value): string + { + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4). + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } +} diff --git a/src/Integrations/Divi/BlockTree/MalformedMarkup.php b/src/Integrations/Divi/BlockTree/MalformedMarkup.php new file mode 100644 index 0000000..f15cc1b --- /dev/null +++ b/src/Integrations/Divi/BlockTree/MalformedMarkup.php @@ -0,0 +1,15 @@ +\/)?wp:(?P[a-z][a-z0-9_-]*\/)?(?P[a-z][a-z0-9_-]*)\s+' + . '(?P{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P\/)?-->/s'; + + /** @throws MalformedMarkup */ + public function parse(string $content): Document + { + $document = new Document(); + /** @var list $stack */ + $stack = []; + $offset = 0; + $length = strlen($content); + + while ($offset < $length) { + $found = preg_match(self::TOKEN, $content, $match, PREG_OFFSET_CAPTURE, $offset); + if ($found === false) { + throw new MalformedMarkup('Block delimiters could not be scanned (PCRE error).'); + } + if ($found === 0) { + break; + } + + [$token, $start] = $match[0]; + $container = $stack === [] ? $document : $stack[count($stack) - 1]; + if ($start > $offset) { + $container->append(substr($content, $offset, $start - $offset)); + } + + $name = self::name($match); + if (self::captured($match, 'closer')) { + $open = array_pop($stack); + if ($open === null || $open->name() !== $name) { + throw new MalformedMarkup(sprintf('Closer of %s at byte %d has no opener.', $name, $start)); + } + $open->closeWith($token); + } else { + $json = self::captured($match, 'attrs') ? $match['attrs'][0] : ''; + $attrs = $json === '' ? [] : Json::decodeObject($json); + $void = self::captured($match, 'void'); + $opaque = $attrs === null || $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs); + $block = Block::parsed($name, $attrs ?? [], $token, $void, $opaque); + $container->append($block); + if (! $void) { + $stack[] = $block; + } + } + + $offset = $start + strlen($token); + } + + if ($offset < $length) { + ($stack === [] ? $document : $stack[count($stack) - 1])->append(substr($content, $offset)); + } + + if ($stack !== []) { + throw new MalformedMarkup(sprintf('Block %s is never closed.', $stack[count($stack) - 1]->name())); + } + + return $document; + } + + /** @param array $match */ + private static function name(array $match): string + { + $namespace = self::captured($match, 'namespace') ? $match['namespace'][0] : 'core/'; + + return $namespace . $match['name'][0]; + } + + /** @param array $match */ + private static function captured(array $match, string $group): bool + { + return isset($match[$group]) && $match[$group][1] !== -1 && $match[$group][0] !== ''; + } +} diff --git a/src/Integrations/Divi/BlockTree/Serializer.php b/src/Integrations/Divi/BlockTree/Serializer.php new file mode 100644 index 0000000..b298530 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/Serializer.php @@ -0,0 +1,73 @@ + '\\u005c', + '--' => '\\u002d\\u002d', + '<' => '\\u003c', + '>' => '\\u003e', + '&' => '\\u0026', + '\\"' => '\\u0022', + ]; + + public static function document(Document $document): string + { + return self::parts($document->parts()); + } + + public static function block(Block $block): string + { + $open = $block->rawOpen() ?? self::opener($block); + if ($block->isVoid()) { + return $open; + } + + return $open . self::parts($block->parts()) . ($block->rawClose() ?? self::closer($block)); + } + + /** @param array $attrs */ + public static function attributes(array $attrs): string + { + return strtr(Json::encode($attrs), self::ESCAPES); + } + + private static function opener(Block $block): string + { + $attrs = $block->attrs() === [] ? '' : self::attributes($block->attrs()) . ' '; + + return '' : '-->'); + } + + private static function closer(Block $block): string + { + return ''; + } + + private static function serializedName(string $name): string + { + return str_starts_with($name, 'core/') ? substr($name, 5) : $name; + } + + /** @param list $parts */ + private static function parts(array $parts): string + { + $out = ''; + foreach ($parts as $part) { + $out .= $part instanceof Block ? self::block($part) : $part; + } + + return $out; + } +} diff --git a/tests/Fixtures/divi/converted-features.html b/tests/Fixtures/divi/converted-features.html new file mode 100644 index 0000000..ea4836a --- /dev/null +++ b/tests/Fixtures/divi/converted-features.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/divi/converted-landing.html b/tests/Fixtures/divi/converted-landing.html new file mode 100644 index 0000000..3bac6f6 --- /dev/null +++ b/tests/Fixtures/divi/converted-landing.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/divi/divi4-shortcodes.html b/tests/Fixtures/divi/divi4-shortcodes.html new file mode 100644 index 0000000..5c80067 --- /dev/null +++ b/tests/Fixtures/divi/divi4-shortcodes.html @@ -0,0 +1 @@ +[et_pb_section fb_built="1" _builder_version="4.27.4"][et_pb_row _builder_version="4.27.4"][et_pb_column type="4_4"][et_pb_text]

Divi 4

[/et_pb_text][/et_pb_column][/et_pb_row][/et_pb_section] \ No newline at end of file diff --git a/tests/Fixtures/divi/source/features.shortcode b/tests/Fixtures/divi/source/features.shortcode new file mode 100644 index 0000000..b3d6379 --- /dev/null +++ b/tests/Fixtures/divi/source/features.shortcode @@ -0,0 +1 @@ +[et_pb_section fb_built="1" admin_label="Details" _builder_version="4.27.4"][et_pb_row column_structure="2_3,1_3" _builder_version="4.27.4"][et_pb_column type="2_3" _builder_version="4.27.4"][et_pb_tabs _builder_version="4.27.4"][et_pb_tab title="Ablauf" _builder_version="4.27.4"]

Erst reden, dann bauen.

[/et_pb_tab][et_pb_tab title="Preise" _builder_version="4.27.4"]

Ab 99 €.

[/et_pb_tab][/et_pb_tabs][et_pb_toggle title="Garantie" open="off" _builder_version="4.27.4"]

5 Jahre.

[/et_pb_toggle][et_pb_slider _builder_version="4.27.4"][et_pb_slide heading="Neu" button_text="Mehr" button_link="/neu" _builder_version="4.27.4"]

Unsere neue Halle.

[/et_pb_slide][/et_pb_slider][et_pb_gallery gallery_ids="12,33" _builder_version="4.27.4"][/et_pb_gallery][et_pb_code _builder_version="4.27.4"]
Code
[/et_pb_code][/et_pb_column][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_team_member name="Max Meister" position="Inhaber" _builder_version="4.27.4"]

Meister seit 2001.

[/et_pb_team_member][et_pb_number_counter title="Projekte" number="480" percent_sign="off" _builder_version="4.27.4"][/et_pb_number_counter][et_pb_divider show_divider="on" _builder_version="4.27.4"][/et_pb_divider][et_pb_social_media_follow _builder_version="4.27.4"][et_pb_social_media_follow_network social_network="facebook" url="https://facebook.com/example" _builder_version="4.27.4"]facebook[/et_pb_social_media_follow_network][/et_pb_social_media_follow][et_pb_map address="Domkloster 4, 50667 Köln" address_lat="50.9413" address_lng="6.9583" _builder_version="4.27.4"][et_pb_map_pin title="Büro" pin_address="Domkloster 4, 50667 Köln" pin_address_lat="50.9413" pin_address_lng="6.9583" _builder_version="4.27.4"][/et_pb_map_pin][/et_pb_map][et_pb_video src="https://www.youtube.com/watch?v=dQw4w9WgXcQ" _builder_version="4.27.4"][/et_pb_video][/et_pb_column][/et_pb_row][/et_pb_section] \ No newline at end of file diff --git a/tests/Fixtures/divi/source/landing.shortcode b/tests/Fixtures/divi/source/landing.shortcode new file mode 100644 index 0000000..92b999d --- /dev/null +++ b/tests/Fixtures/divi/source/landing.shortcode @@ -0,0 +1 @@ +[et_pb_section fb_built="1" admin_label="Hero" _builder_version="4.27.4" background_color="#0b3d59"][et_pb_row column_structure="1_2,1_2" _builder_version="4.27.4"][et_pb_column type="1_2" _builder_version="4.27.4"][et_pb_heading title="Handwerk mit Herz" title_level="h1" _builder_version="4.27.4"][/et_pb_heading][et_pb_text _builder_version="4.27.4"]

Seit 1987 in Köln – "fair" & pünktlich.

[/et_pb_text][et_pb_button button_text="Termin buchen" button_url="/kontakt" _builder_version="4.27.4"][/et_pb_button][/et_pb_column][et_pb_column type="1_2" _builder_version="4.27.4"][et_pb_image src="https://example.test/wp-content/uploads/2026/09/werkstatt.jpg" alt="Werkstatt" title_text="Werkstatt" _builder_version="4.27.4"][/et_pb_image][/et_pb_column][/et_pb_row][/et_pb_section][et_pb_section fb_built="1" admin_label="Leistungen" _builder_version="4.27.4"][et_pb_row column_structure="1_3,1_3,1_3" _builder_version="4.27.4"][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_blurb title="Beratung" use_icon="on" font_icon="||divi||400" _builder_version="4.27.4"]

Persönlich vor Ort.

[/et_pb_blurb][/et_pb_column][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_blurb title="Planung" image="https://example.test/wp-content/uploads/2026/09/plan.png" alt="Plan" _builder_version="4.27.4"]

Mit 3D-Ansicht.

[/et_pb_blurb][/et_pb_column][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_blurb title="Umsetzung" _builder_version="4.27.4"]

Termingerecht.

[/et_pb_blurb][/et_pb_column][/et_pb_row][et_pb_row _builder_version="4.27.4"][et_pb_column type="4_4" _builder_version="4.27.4"][et_pb_cta title="Bereit?" button_text="Anfragen" button_url="/kontakt" _builder_version="4.27.4"]

Wir melden uns binnen 24 Stunden.

[/et_pb_cta][et_pb_testimonial author="Anna B." job_title="Bauherrin" company_name="Privat" _builder_version="4.27.4"]

Top Arbeit!

[/et_pb_testimonial][et_pb_accordion _builder_version="4.27.4"][et_pb_accordion_item title="Was kostet das?" open="on" _builder_version="4.27.4"]

Kommt drauf an.

[/et_pb_accordion_item][et_pb_accordion_item title="Wie schnell?" open="off" _builder_version="4.27.4"]

2 Wochen.

[/et_pb_accordion_item][/et_pb_accordion][et_pb_contact_form email="info@example.test" title="Kontakt" _builder_version="4.27.4"][et_pb_contact_field field_id="Name" field_title="Name" _builder_version="4.27.4"][/et_pb_contact_field][et_pb_contact_field field_id="Email" field_title="E-Mail" field_type="email" _builder_version="4.27.4"][/et_pb_contact_field][et_pb_contact_field field_id="Message" field_title="Nachricht" field_type="text" fullwidth_field="on" _builder_version="4.27.4"][/et_pb_contact_field][/et_pb_contact_form][/et_pb_column][/et_pb_row][/et_pb_section] \ No newline at end of file diff --git a/tests/Fixtures/divi/synthetic-basic.html b/tests/Fixtures/divi/synthetic-basic.html new file mode 100644 index 0000000..623ea83 --- /dev/null +++ b/tests/Fixtures/divi/synthetic-basic.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/divi/synthetic-mixed.html b/tests/Fixtures/divi/synthetic-mixed.html new file mode 100644 index 0000000..18341e1 --- /dev/null +++ b/tests/Fixtures/divi/synthetic-mixed.html @@ -0,0 +1,12 @@ + +

Einleitung aus dem Block-Editor mit Link.

+ + + + + + + + + +Freier Text am Ende [et_pb_legacy] diff --git a/tests/Integration/divi-convert-fixture.php b/tests/Integration/divi-convert-fixture.php new file mode 100644 index 0000000..02d8565 --- /dev/null +++ b/tests/Integration/divi-convert-fixture.php @@ -0,0 +1,35 @@ + + * Paths are relative to the plugin's tests/Fixtures/divi directory. + */ + +use ET\Builder\Packages\Conversion\Conversion; + +$args = $args ?? []; +if (count($args) !== 2) { + WP_CLI::error('Usage: divi-convert-fixture.php '); +} +if (! class_exists(Conversion::class)) { + WP_CLI::error('Divi 5 is not active.'); +} + +$dir = dirname(__DIR__) . '/Fixtures/divi/'; +$source = file_get_contents($dir . $args[0]); +if ($source === false) { + WP_CLI::error('Cannot read ' . $dir . $args[0]); +} + +$converted = Conversion::maybeConvertContent(trim($source)); +if (! str_contains($converted, '' . $converted . ''; +if (file_put_contents($dir . $args[1], $markup) === false) { + WP_CLI::error('Cannot write ' . $dir . $args[1]); +} +WP_CLI::success(sprintf('%s: %d bytes', $args[1], strlen($markup))); diff --git a/tests/Integration/divi-export-page.php b/tests/Integration/divi-export-page.php new file mode 100644 index 0000000..acb8e98 --- /dev/null +++ b/tests/Integration/divi-export-page.php @@ -0,0 +1,45 @@ +.html. Every file there is part of the parse → serialize + * round-trip test (tests/Unit/Integrations/Divi/BlockTree/ParserTest.php). + * Usage (DDEV, WordPress root): + * wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-page.php + * : lowercase letters, digits and dashes, e.g. "startseite-mit-global". + * Only use pages without personal data – fixtures are committed. + * No `declare(strict_types=1);` – wp eval-file runs the file through eval(). + */ + +defined('WP_CLI') && WP_CLI || exit; + +$args = $args ?? []; +$postId = (int) ($args[0] ?? 0); +$name = (string) ($args[1] ?? ''); +if ($postId <= 0 || preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $name) !== 1) { + WP_CLI::error('Usage: divi-export-page.php '); +} + +$post = get_post($postId); +if (! $post instanceof WP_Post) { + WP_CLI::error('No post ' . $postId); +} + +$content = (string) $post->post_content; +if (! str_contains($content, '\n

x

\n\n\ntext"); + + self::assertSame('core/paragraph', $document->children()[0]->name()); + self::assertSame(["\n

x

\n"], $document->children()[0]->parts()); + self::assertSame("\n\ntext", $document->parts()[1]); + } + + public function testMarksGlobalAndUndecodableBlocksOpaque(): void + { + $document = (new Parser())->parse($this->fixture('synthetic-mixed.html')); + $placeholder = $document->children()[1]; + $second = $placeholder->children()[1]; + [$globalLayout, $globalRow, $globalWithChild] = $second->children(); + + self::assertTrue($globalLayout->isOpaque()); + self::assertTrue($globalLayout->isGlobal()); + self::assertTrue($globalRow->isOpaque()); + self::assertTrue($globalRow->isGlobal()); + self::assertTrue($globalWithChild->isOpaque()); + self::assertFalse($second->isOpaque()); + + $broken = (new Parser())->parse(''); + self::assertTrue($broken->children()[0]->isOpaque()); + self::assertSame([], $broken->children()[0]->attrs()); + } + + public function testKeepsEmptyAndListLikeObjectsAsObjects(): void + { + $document = (new Parser())->parse($this->fixture('synthetic-mixed.html')); + $attrs = $document->children()[1]->children()[1]->attrs(); + + self::assertInstanceOf(stdClass::class, $attrs['module']['decoration']['spacing']['desktop']['value']); + self::assertSame([], $attrs['module']['decoration']['background']['desktop']['value']); + self::assertInstanceOf(stdClass::class, $attrs['localAttrs']); + } + + public function testDivi4ShortcodesAreOneTextPart(): void + { + $content = $this->fixture('divi4-shortcodes.html'); + $document = (new Parser())->parse($content); + + self::assertSame([], $document->children()); + self::assertSame([$content], $document->parts()); + } + + /** @return array */ + public static function malformed(): array + { + return [ + 'closer without opener' => [''], + 'unclosed block' => [''], + 'crossed blocks' => [''], + ]; + } + + /** @dataProvider malformed */ + public function testRefusesUnbalancedMarkup(string $content): void + { + $this->expectException(MalformedMarkup::class); + + (new Parser())->parse($content); + } + + private function fixture(string $name): string + { + return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name); + } +} diff --git a/tests/Unit/Integrations/Divi/BlockTree/SerializerTest.php b/tests/Unit/Integrations/Divi/BlockTree/SerializerTest.php new file mode 100644 index 0000000..3cee656 --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/SerializerTest.php @@ -0,0 +1,103 @@ + $variable]) + ); + self::assertSame( + '{"t":"\u003cp\u003eA \u0026 B\u003c/p\u003e \u002d\u002d \u005c/ ä","e":{},"l":[]}', + Serializer::attributes(['t' => '

A & B

-- \\/ ä', 'e' => new stdClass(), 'l' => []]) + ); + } + + public function testRegeneratesOnlyTheChangedOpener(): void + { + $content = ''; + $document = (new Parser())->parse($content); + $text = $document->children()[0]->children()[0]; + + $text->setAttrs(['x' => 'z']); + + self::assertSame( + '', + Serializer::document($document) + ); + } + + public function testNewBlocksAreWrittenAsPairs(): void + { + $block = Block::create('divi/row', ['builderVersion' => '5.2.1'], [Block::create('divi/column')]); + + self::assertSame( + '' + . '', + Serializer::block($block) + ); + self::assertSame('', Serializer::block(Block::create('core/paragraph'))); + } + + public function testVoidBlockGettingAChildBecomesAPair(): void + { + $document = (new Parser())->parse(''); + $column = $document->children()[0]; + + $column->insertChild(0, Block::create('divi/text')); + + self::assertSame( + '', + Serializer::document($document) + ); + } + + public function testInsertAndRemoveKeepSurroundingText(): void + { + $document = (new Parser())->parse("A\nBC"); + [$text, $code] = $document->children(); + $new = Block::create('divi/button'); + + $document->insertChild(1, $new); + self::assertSame( + "A\nBC", + Serializer::document($document) + ); + self::assertSame(2, $document->removeChild($code)); + self::assertSame(0, $document->indexOf($text)); + $document->insertChild(99, $code); + self::assertSame(2, $document->indexOf($code)); + + $empty = new Document(); + $empty->append("\n"); + $empty->insertChild(0, Block::create('divi/section')); + self::assertSame("\n", Serializer::document($empty)); + } + + public function testCopyIsDeepAndSerializesIdentically(): void + { + $content = ''; + $row = (new Parser())->parse($content)->children()[0]; + $copy = $row->copy(); + + $copy->children()[0]->setAttrs(['b' => 2]); + + self::assertSame($content, Serializer::block($row)); + self::assertSame('', Serializer::block($copy)); + } +}