Files
WP-LIT-MCP/src/Integrations/Divi/BlockTree/Parser.php
T
janniclas.loosenandClaude Opus 5.5 b24eb6c3e9 fix(security): no direct HTTP access to plugin PHP files (audit L2, cheap part)
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
2026-09-26 19:32:12 +02:00

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] !== '';
}
}