Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
92 lines
3.4 KiB
PHP
92 lines
3.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
/**
|
|
* 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] !== '';
|
|
}
|
|
}
|