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:
co-authored by
Claude Sonnet 5.5
parent
68248da648
commit
a4ea487174
@@ -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']));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user