feat: Divi BlockTree – byte-exact parser/serializer with round-trip fixtures
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||
|
||||
/**
|
||||
* Parses block markup into a Document without WordPress (spec §4.4, §5.2). Uses the
|
||||
* block-delimiter pattern of WP_Block_Parser, but records the exact bytes of every
|
||||
* delimiter and of all text between them, so Serializer::document() reproduces the input
|
||||
* byte for byte. Unbalanced markup is refused (MalformedMarkup), never repaired.
|
||||
*/
|
||||
final class Parser
|
||||
{
|
||||
/** WP_Block_Parser::next_token() (WordPress 7.0). */
|
||||
private const TOKEN = '/<!--\s+(?P<closer>\/)?wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+'
|
||||
. '(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P<void>\/)?-->/s';
|
||||
|
||||
/** @throws MalformedMarkup */
|
||||
public function parse(string $content): Document
|
||||
{
|
||||
$document = new Document();
|
||||
/** @var list<Block> $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<int|string, array{0: string, 1: int}> $match */
|
||||
private static function name(array $match): string
|
||||
{
|
||||
$namespace = self::captured($match, 'namespace') ? $match['namespace'][0] : 'core/';
|
||||
|
||||
return $namespace . $match['name'][0];
|
||||
}
|
||||
|
||||
/** @param array<int|string, array{0: string, 1: int}> $match */
|
||||
private static function captured(array $match, string $group): bool
|
||||
{
|
||||
return isset($match[$group]) && $match[$group][1] !== -1 && $match[$group][0] !== '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user