feat: global instance in the builder's format, deglobalize, library content rules
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -141,6 +141,30 @@ final class Block extends Container
|
||||
return clone $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A deep copy of this block under another name and attributes: opener and closer are
|
||||
* regenerated, children and the text between them are kept, the void flag too. Opaque exactly
|
||||
* when the Parser would make it so. GlobalInstance uses it to turn a library root into an
|
||||
* instance and back.
|
||||
*
|
||||
* @param array<string, mixed> $attrs
|
||||
*/
|
||||
public function copyAs(string $name, array $attrs): self
|
||||
{
|
||||
if (preg_match(self::NAME_PATTERN, $name) !== 1) {
|
||||
throw new InvalidArgumentException(sprintf('"%s" is not a valid block name.', $name));
|
||||
}
|
||||
|
||||
$copy = clone $this;
|
||||
$copy->name = $name;
|
||||
$copy->attrs = self::deepCloneAttrs($attrs);
|
||||
$copy->rawOpen = null;
|
||||
$copy->rawClose = null;
|
||||
$copy->opaque = $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs);
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
foreach ($this->parts as $position => $part) {
|
||||
|
||||
@@ -93,12 +93,41 @@ abstract class Container
|
||||
return $index;
|
||||
}
|
||||
|
||||
/** Puts $new where the child $old is; the text around it stays. */
|
||||
public function replaceChild(Block $old, Block $new): void
|
||||
{
|
||||
$this->guardMutable();
|
||||
$this->parts[$this->partPosition($old)] = $new;
|
||||
$this->touched();
|
||||
}
|
||||
|
||||
/**
|
||||
* "\r\n" → "\n" in whitespace-only text between blocks, at every depth. Library entries saved
|
||||
* through Divi's "add to library" form carry CRLF (browsers normalise form fields, fixture
|
||||
* finding 9); the Visual Builder writes "\n". Text inside blocks' own content stays untouched,
|
||||
* and so do opaque blocks (global instances keep Divi's bytes).
|
||||
*/
|
||||
public function unifyLineBreaks(): void
|
||||
{
|
||||
if ($this instanceof Block && $this->isOpaque()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->parts as $position => $part) {
|
||||
if ($part instanceof Block) {
|
||||
$part->unifyLineBreaks();
|
||||
} elseif (trim($part) === '' && str_contains($part, "\r\n")) {
|
||||
$this->parts[$position] = str_replace("\r\n", "\n", $part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called after the list of children changed. */
|
||||
protected function touched(): void
|
||||
{
|
||||
}
|
||||
|
||||
/** insertChild()/removeChild() are the tools' structural edits – never on an opaque block. */
|
||||
/** insertChild()/removeChild()/replaceChild() are the tools' structural edits – never on an opaque block. */
|
||||
private function guardMutable(): void
|
||||
{
|
||||
if ($this instanceof Block && $this->isOpaque()) {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Global modules the way Divi 5 stores them (F6, F10, fixtures stage3): an instance on a page is a
|
||||
* divi/global-layout block whose content Divi takes from the library entry at render time (F7).
|
||||
* Instances stay opaque for the page tools; edits go to the library entry. Pure PHP.
|
||||
*/
|
||||
final class GlobalInstance
|
||||
{
|
||||
public const BLOCK = 'divi/global-layout';
|
||||
|
||||
/** Block name the node counts as for structure rules: an instance counts as its root type. */
|
||||
public static function effectiveName(Block $block): string
|
||||
{
|
||||
if ($block->name() !== self::BLOCK) {
|
||||
return $block->name();
|
||||
}
|
||||
|
||||
$name = $block->attrs()['blockName'] ?? '';
|
||||
|
||||
return is_string($name) ? $name : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance the Visual Builder writes for library entry $libraryId whose content root is
|
||||
* $root (serialized-post.js, filter divi.serializedPost.parseFlatModuleObjectIntoBlockArray.nested;
|
||||
* fixture page--globale-instanzen.html): divi/global-layout with globalModule, blockName and
|
||||
* localAttrs – the root's attributes, top-level keys sorted like the builder's store, left out
|
||||
* when empty –, the root's children as snapshot with "globalParent": "<id>" appended to every
|
||||
* block below. A void root gives a void instance. $root is not changed.
|
||||
*/
|
||||
public static function of(Block $root, int $libraryId): Block
|
||||
{
|
||||
$id = (string) $libraryId;
|
||||
$local = $root->attrs();
|
||||
ksort($local);
|
||||
$attrs = ['globalModule' => $id, 'blockName' => $root->name()];
|
||||
if ($local !== []) {
|
||||
$attrs['localAttrs'] = $local;
|
||||
}
|
||||
|
||||
$instance = $root->copyAs(self::BLOCK, $attrs);
|
||||
foreach ($instance->children() as $child) {
|
||||
self::markParent($child, $id);
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divi's PortabilityPost::maybe_deglobalize_nested_global_modules() on a tree (Divi 5.2.1
|
||||
* PortabilityPost.php:3518, used by DiviLibraryController::load for global entries): every
|
||||
* divi/global-layout at or below $block becomes its blockName with localAttrs as attributes,
|
||||
* and globalParent disappears from every block. Other opaque blocks stay, and so does an instance
|
||||
* without a usable blockName. Changes $block in place (pass a copy) and returns the result – a
|
||||
* new block when $block itself was an instance.
|
||||
*/
|
||||
public static function deglobalize(Block $block): Block
|
||||
{
|
||||
if ($block->name() === self::BLOCK) {
|
||||
$name = self::effectiveName($block);
|
||||
if ($name === '') {
|
||||
return $block;
|
||||
}
|
||||
$local = $block->attrs()['localAttrs'] ?? [];
|
||||
try {
|
||||
$block = $block->copyAs($name, is_array($local) ? $local : []);
|
||||
} catch (InvalidArgumentException) {
|
||||
return $block; // Stored blockName is no block name: the instance stays opaque.
|
||||
}
|
||||
}
|
||||
|
||||
if ($block->isOpaque()) {
|
||||
return $block;
|
||||
}
|
||||
|
||||
if (array_key_exists('globalParent', $block->attrs())) {
|
||||
$attrs = $block->attrs();
|
||||
unset($attrs['globalParent']);
|
||||
$block->setAttrs($attrs);
|
||||
}
|
||||
|
||||
foreach ($block->children() as $child) {
|
||||
$plain = self::deglobalize($child);
|
||||
if ($plain !== $child) {
|
||||
$block->replaceChild($child, $plain);
|
||||
}
|
||||
}
|
||||
|
||||
return $block;
|
||||
}
|
||||
|
||||
private static function markParent(Block $block, string $id): void
|
||||
{
|
||||
if ($block->isOpaque()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attrs = $block->attrs();
|
||||
unset($attrs['globalParent']);
|
||||
$attrs['globalParent'] = $id;
|
||||
$block->setAttrs($attrs);
|
||||
foreach ($block->children() as $child) {
|
||||
self::markParent($child, $id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* post_content of Divi Library entries (F4, fixtures stage3): the nodes sit in a divi/placeholder
|
||||
* frame; a section/row/module entry has exactly one root, a layout entry sections. Pure PHP.
|
||||
*/
|
||||
final class LibraryContent
|
||||
{
|
||||
public const WRAPPER = 'divi/placeholder';
|
||||
|
||||
/**
|
||||
* Parses library content for copying and unifies its line breaks (Container::unifyLineBreaks()).
|
||||
* Never used for the content a tool edits in place – that keeps its bytes.
|
||||
*
|
||||
* @throws MalformedMarkup
|
||||
*/
|
||||
public static function load(Parser $parser, string $content): Document
|
||||
{
|
||||
$document = $parser->parse($content);
|
||||
$document->unifyLineBreaks();
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/** post_content of a library entry holding $node, framed like the builder's "add to library". */
|
||||
public static function wrap(Block $node): string
|
||||
{
|
||||
return '<!-- wp:' . self::WRAPPER . ' -->' . Serializer::block($node) . '<!-- /wp:' . self::WRAPPER . ' -->';
|
||||
}
|
||||
|
||||
/** @return list<Block> Top-level nodes: the children of the first divi/placeholder, else of the document. */
|
||||
public static function roots(Document $document): array
|
||||
{
|
||||
foreach ($document->children() as $block) {
|
||||
if ($block->isPlaceholder()) {
|
||||
return $block->children();
|
||||
}
|
||||
}
|
||||
|
||||
return $document->children();
|
||||
}
|
||||
|
||||
/** Whether $block is or contains a global module. */
|
||||
public static function containsGlobal(Block $block): bool
|
||||
{
|
||||
if ($block->isGlobal()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($block->children() as $child) {
|
||||
if (self::containsGlobal($child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the roots $after may not replace $before, or null. Instances on pages expect their
|
||||
* entry's root type (blockName), so a section/row/module entry keeps exactly one root of the
|
||||
* same type; a layout entry (or one with several roots) holds sections only. A global entry
|
||||
* contains no global modules (F10: Divi forbids nesting).
|
||||
*
|
||||
* @param list<Block> $before
|
||||
* @param list<Block> $after
|
||||
*/
|
||||
public static function shapeViolation(array $before, array $after, ?string $layoutType, bool $global): ?string
|
||||
{
|
||||
if ($after === []) {
|
||||
return 'Der Bibliothekseintrag darf nicht leer werden.';
|
||||
}
|
||||
|
||||
if ($layoutType === 'layout' || count($before) !== 1) {
|
||||
foreach ($after as $root) {
|
||||
if (GlobalInstance::effectiveName($root) !== 'divi/section') {
|
||||
return 'Ein Layout-Eintrag der Bibliothek enthält nur Abschnitte.';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$expected = GlobalInstance::effectiveName($before[0]);
|
||||
if (count($after) !== 1 || GlobalInstance::effectiveName($after[0]) !== $expected) {
|
||||
return sprintf(
|
||||
'Der Bibliothekseintrag muss genau ein %s bleiben (Instanzen auf den Seiten erwarten diesen Typ).',
|
||||
$expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($global) {
|
||||
foreach ($after as $root) {
|
||||
if (self::containsGlobal($root)) {
|
||||
return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine '
|
||||
. 'Verschachtelung).';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Container;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
@@ -14,7 +15,8 @@ defined('ABSPATH') || exit;
|
||||
/**
|
||||
* Structural changes on a parsed page (spec §5.7): where to insert (parent / before / after,
|
||||
* optional index), move, remove, duplicate – always checked against StructureRules. Works on
|
||||
* the tree only; saving is DiviWriter's job. Pure PHP.
|
||||
* the tree only; saving is DiviWriter's job. Global instances count as their root type
|
||||
* (blockName, GlobalInstance::effectiveName()). Pure PHP.
|
||||
*/
|
||||
final class TreeEditor
|
||||
{
|
||||
@@ -79,7 +81,7 @@ final class TreeEditor
|
||||
public function insert(array $position, array $blocks): ?string
|
||||
{
|
||||
foreach ($blocks as $block) {
|
||||
$violation = $this->rules->violation($position['parent'], $block->name());
|
||||
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block));
|
||||
if ($violation !== null) {
|
||||
return $violation;
|
||||
}
|
||||
@@ -104,7 +106,7 @@ final class TreeEditor
|
||||
}
|
||||
}
|
||||
|
||||
$violation = $this->rules->violation($position['parent'], $node->name());
|
||||
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($node));
|
||||
if ($violation !== null) {
|
||||
return $violation;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user