feat: ReferenceUsageQuery findet Seiten, die globale Farben oder Variablen nennen (fail closed)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 14:02:48 +02:00
co-authored by Claude Sonnet 5.5
parent 68248da648
commit a4ea487174
2 changed files with 193 additions and 0 deletions
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\Container;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
defined('ABSPATH') || exit;
/**
* The posts that reference any of the global colors or variables $ids (GlobalUsages::reportFor(), ruling V8). Divi
* writes a reference as $variable({"type":"color"|"content","value":{"name":"<id>",…}})$ into block attributes, the
* quotes JSON-escaped in post_content (formats §1, §2). Fail closed (ruling P23): candidates contain an ID, a
* JSON-escaped hyphen after gcid/gvid or an escaped letter; a candidate matches when its raw content names an ID
* (also Divi 4 shortcodes), when the decoded attributes of any block do – the whole tree, also inside global modules,
* which NodeIndex does not descend into –, when a block's attributes cannot be read or when the parser rejects the
* markup.
*/
final class ReferenceUsageQuery implements UsageQuery
{
/** MySQL REGEXP for "gcid"/"gvid" followed by a JSON-escaped hyphen ("\\u002d"), which LIKE does not find. */
public const ESCAPED_HYPHEN_REGEX = 'g[cv]id\\\\u002[dD]';
/** @var list<string> */
private array $ids;
/**
* @param list<string> $ids gcid-/gvid- IDs.
* @throws InvalidArgumentException Without an ID.
*/
public function __construct(array $ids, private Parser $parser = new Parser())
{
$ids = array_values(array_unique(array_filter(
$ids,
static fn (mixed $id): bool => is_string($id) && $id !== ''
)));
if ($ids === []) {
throw new InvalidArgumentException('ReferenceUsageQuery needs at least one ID.');
}
$this->ids = $ids;
}
/**
* Whether a string inside $value (nested arrays included, keys not) contains one of $ids.
*
* @param list<string> $ids
*/
public static function mentions(mixed $value, array $ids): bool
{
if (is_string($value)) {
foreach ($ids as $id) {
if (str_contains($value, $id)) {
return true;
}
}
return false;
}
if (is_array($value)) {
foreach ($value as $inner) {
if (self::mentions($inner, $ids)) {
return true;
}
}
}
return false;
}
public function candidateCondition(\wpdb $db): string
{
$likes = array_map(static fn (string $id): string => '%' . $db->esc_like($id) . '%', $this->ids);
return (string) $db->prepare(
'(' . str_repeat('post_content LIKE %s OR ', count($likes))
. 'post_content REGEXP %s OR post_content REGEXP %s)',
...array_merge($likes, [self::ESCAPED_HYPHEN_REGEX, self::ESCAPED_LETTER_REGEX])
);
}
public function matches(string $content): bool
{
if (self::mentions($content, $this->ids)) {
return true;
}
try {
return $this->inBlocks($this->parser->parse($content));
} catch (MalformedMarkup) {
return true;
}
}
private function inBlocks(Container $container): bool
{
foreach ($container->children() as $block) {
// The parser keeps attributes that are no JSON object as [] on an opaque block: unreadable, so it counts.
$unreadable = $block->isOpaque() && $block->attrs() === [] && str_contains((string) $block->rawOpen(), '{');
if ($unreadable || self::mentions($block->attrs(), $this->ids) || $this->inBlocks($block)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ReferenceUsageQueryTest extends TestCase
{
/** A color reference as post_content holds it: quotes JSON-escaped (formats §2). */
private const COLOR_REF = '$variable({"type":"color","value":{"name":'
. '"gcid-holz000001","settings":{}}})$';
public function testCandidatesContainAnIdAnEscapedHyphenOrAnEscapedLetter(): void
{
$db = Mockery::mock(\wpdb::class);
$db->allows('esc_like')->andReturnUsing(static fn (string $text): string => $text);
$db->expects('prepare')
->with(
'(post_content LIKE %s OR post_content LIKE %s OR post_content REGEXP %s OR post_content REGEXP %s)',
'%gcid-holz000001%',
'%gvid-radius0001%',
'g[cv]id\\\\u002[dD]',
'\\\\u00[4-7][0-9a-fA-F]'
)
->andReturn('(prepared)');
$query = new ReferenceUsageQuery(['gcid-holz000001', 'gvid-radius0001', 'gcid-holz000001']);
self::assertSame('(prepared)', $query->candidateCondition($db));
}
public function testNeedsAnId(): void
{
$this->expectException(InvalidArgumentException::class);
new ReferenceUsageQuery(['']);
}
public function testFindsAReferenceInTheAttributesOfAnyBlock(): void
{
$query = new ReferenceUsageQuery(['gcid-holz000001', 'gvid-radius0001']);
self::assertTrue($query->matches('<!-- wp:divi/text {"module":{"decoration":{"background":{"desktop":'
. '{"value":{"color":"' . self::COLOR_REF . '"}}}}}} /-->'));
self::assertTrue($query->matches('<!-- wp:divi/text {"x":"gvid-radius0001"} /-->'));
self::assertFalse($query->matches('<!-- wp:divi/text {"module":{"decoration":{"background":{"desktop":'
. '{"value":{"color":"#ffffff"}}}}}} /-->'));
self::assertFalse($query->matches('<!-- wp:divi/text {"x":"gcid-anders0001"} /-->'));
self::assertFalse($query->matches('<p>Keine Verweise</p>'));
}
public function testFindsAnIdWrittenWithJsonEscapesAlsoInsideGlobalModules(): void
{
$query = new ReferenceUsageQuery(['gcid-holz000001']);
self::assertTrue($query->matches('<!-- wp:divi/text {"x":"gcid-holz000001"} /-->'));
self::assertTrue($query->matches('<!-- wp:divi/section {"globalModule":"12"} -->'
. '<!-- wp:divi/text {"x":"gcid-holz000001"} /--><!-- /wp:divi/section -->'));
self::assertFalse($query->matches('<!-- wp:divi/text {"x":"gcid-anders0001"} /-->'));
}
public function testUnreadableMarkupAndDivi4ContentCount(): void
{
$query = new ReferenceUsageQuery(['gcid-holz000001']);
self::assertTrue($query->matches('<!-- wp:divi/section --><!-- wp:divi/text {"x":1} /-->'), 'never closed');
self::assertTrue($query->matches('<!-- wp:divi/text {"x":"gcid-holz000001",} /-->'), 'attrs no JSON');
self::assertTrue($query->matches('[et_pb_text background_color="gcid-holz000001"]x[/et_pb_text]'), 'Divi 4');
}
public function testMentionsLooksIntoNestedStrings(): void
{
self::assertTrue(ReferenceUsageQuery::mentions(['a' => ['b' => 'x gvid-radius0001 y']], ['gvid-radius0001']));
self::assertFalse(ReferenceUsageQuery::mentions(['gvid-radius0001' => 1], ['gvid-radius0001']), 'keys');
self::assertFalse(ReferenceUsageQuery::mentions(12, ['gvid-radius0001']));
}
}