Final fix wave for stage 2a, load-bearing for 2b's writers:
- Block::setAttrs()/Container::insertChild()/removeChild() refuse on an opaque
block (undecodable attrs or a global module) instead of silently corrupting it.
- Container::append() is now @internal (Parser only) and refuses on a void
block, so a child can no longer be silently dropped from the serialized output.
- Block::create() validates the block name against namespace/block-name.
- Json::decodeObject() returns null (-> opaque) for a top-level object whose
keys are a plain list ("0","1",...) rather than returning [] and letting the
Serializer re-encode it as a corrupting JSON array.
- AttrMap::set() rejects empty path segments and keeps an existing stdClass
value an object when rewrapping it (no silent object->list flip); AttrMap::get()
is updated in step so it can still read back through such an object.
- Block::copy() deep-clones stdClass attribute values instead of sharing them.
- Json::encode()'s existing JsonException-on-invalid-UTF-8 behaviour is documented.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
75 lines
2.2 KiB
PHP
75 lines
2.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
|
|
|
/**
|
|
* Turns a Document back into block markup. Unchanged blocks reuse their original
|
|
* delimiters; changed or new blocks get delimiters generated exactly like WordPress'
|
|
* get_comment_delimited_block_content() / serialize_block_attributes() do, which also
|
|
* yields the escaping Divi expects inside $variable(…)$ payloads: json_encode()'s own
|
|
* \" (an escaped quote inside a JSON string) is replaced by ", see ESCAPES below.
|
|
*/
|
|
final class Serializer
|
|
{
|
|
/** serialize_block_attributes() replacements (WordPress 7.0). */
|
|
private const ESCAPES = [
|
|
'\\\\' => '\\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<string, mixed> $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 '<!-- wp:' . self::serializedName($block->name()) . ' ' . $attrs . ($block->isVoid() ? '/-->' : '-->');
|
|
}
|
|
|
|
private static function closer(Block $block): string
|
|
{
|
|
return '<!-- /wp:' . self::serializedName($block->name()) . ' -->';
|
|
}
|
|
|
|
private static function serializedName(string $name): string
|
|
{
|
|
return str_starts_with($name, 'core/') ? substr($name, 5) : $name;
|
|
}
|
|
|
|
/** @param list<string|Block> $parts */
|
|
private static function parts(array $parts): string
|
|
{
|
|
$out = '';
|
|
foreach ($parts as $part) {
|
|
$out .= $part instanceof Block ? self::block($part) : $part;
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
}
|