feat: Fundstellen eines Presets (PresetUsageMatcher, PresetUsageQuery)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 5.5
parent
454116851e
commit
bfd3840eb1
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Whether post content uses a preset (spec §6.4, formats §3 "Verweise in Blöcken"), fail closed:
|
||||
* - module preset $id: a block of the module with $id in its modulePreset stack (array or legacy string);
|
||||
* the default preset additionally reaches every block of the module without an own preset ("" / "default" /
|
||||
* "_initial" mean the default, GlobalPreset::is_preset_id_as_default);
|
||||
* - group preset $id: a block with $id in groupPreset.<attr>.presetId whose groupName is the group;
|
||||
* - markup the parser rejects counts as a use.
|
||||
* Pure PHP.
|
||||
*/
|
||||
final class PresetUsageMatcher
|
||||
{
|
||||
private const DEFAULT_IDS = ['', 'default', '_initial'];
|
||||
|
||||
public function __construct(private Parser $parser = new Parser())
|
||||
{
|
||||
}
|
||||
|
||||
public function uses(string $content, PresetScope $scope, string $id, bool $isDefault): bool
|
||||
{
|
||||
try {
|
||||
$index = NodeIndex::build($this->parser->parse($content));
|
||||
} catch (MalformedMarkup) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($index->entries() as $entry) {
|
||||
if ($this->usesIn($entry['block'], $scope, $id, $isDefault)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function usesIn(Block $block, PresetScope $scope, string $id, bool $isDefault): bool
|
||||
{
|
||||
if ($scope->kind === PresetScope::MODULE) {
|
||||
return $this->usesModulePreset($block, $scope, $id, $isDefault);
|
||||
}
|
||||
|
||||
return $this->usesGroupPreset($block->attrs(), $scope, $id);
|
||||
}
|
||||
|
||||
private function usesModulePreset(Block $block, PresetScope $scope, string $id, bool $isDefault): bool
|
||||
{
|
||||
if ($block->name() !== $scope->name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stack = NodeSummary::presetIds($block);
|
||||
if (in_array($id, $stack, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $isDefault && array_diff($stack, self::DEFAULT_IDS) === [];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $attrs */
|
||||
private function usesGroupPreset(array $attrs, PresetScope $scope, string $id): bool
|
||||
{
|
||||
$groups = is_array($attrs['groupPreset'] ?? null) ? $attrs['groupPreset'] : [];
|
||||
foreach ($groups as $group) {
|
||||
if (! is_array($group) || ($group['groupName'] ?? null) !== $scope->name) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($id, (array) ($group['presetId'] ?? []), true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The posts that use any of the presets $targets (GlobalUsages::reportFor()): candidates contain one of the IDs, a
|
||||
* JSON escape of a letter (an escaped ID or key, fail closed like ruling P23) or – for a default module preset – a
|
||||
* block of the module; PresetUsageMatcher decides per target.
|
||||
*/
|
||||
final class PresetUsageQuery implements UsageQuery
|
||||
{
|
||||
private const ESCAPED_LETTER = '\\\\u00[4-7][0-9a-fA-F]';
|
||||
|
||||
/** @var list<array{scope: PresetScope, id: string, default: bool}> */
|
||||
private array $targets;
|
||||
|
||||
/**
|
||||
* @param list<array{scope: PresetScope, id: string, default: bool}> $targets
|
||||
* @throws InvalidArgumentException Without targets.
|
||||
*/
|
||||
public function __construct(array $targets, private PresetUsageMatcher $matcher = new PresetUsageMatcher())
|
||||
{
|
||||
if ($targets === []) {
|
||||
throw new InvalidArgumentException('PresetUsageQuery needs at least one preset.');
|
||||
}
|
||||
$this->targets = array_values($targets);
|
||||
}
|
||||
|
||||
public function candidateCondition(\wpdb $db): string
|
||||
{
|
||||
$likes = [];
|
||||
foreach ($this->targets as $target) {
|
||||
$likes[] = '%' . $db->esc_like($target['id']) . '%';
|
||||
if ($target['default'] && $target['scope']->kind === PresetScope::MODULE) {
|
||||
$likes[] = '%' . $db->esc_like('wp:' . $target['scope']->name . ' ') . '%';
|
||||
}
|
||||
}
|
||||
$likes = array_values(array_unique($likes));
|
||||
|
||||
return (string) $db->prepare(
|
||||
'(' . str_repeat('post_content LIKE %s OR ', count($likes)) . 'post_content REGEXP %s)',
|
||||
...array_merge($likes, [self::ESCAPED_LETTER])
|
||||
);
|
||||
}
|
||||
|
||||
public function matches(string $content): bool
|
||||
{
|
||||
foreach ($this->targets as $target) {
|
||||
if ($this->matcher->uses($content, $target['scope'], $target['id'], $target['default'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetUsageMatcher;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PresetUsageMatcherTest extends TestCase
|
||||
{
|
||||
private const BUTTON = '<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||||
. '<!-- wp:divi/button %s /--><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->';
|
||||
|
||||
private static function page(string $buttonAttrs): string
|
||||
{
|
||||
return sprintf(self::BUTTON, $buttonAttrs);
|
||||
}
|
||||
|
||||
public function testModulePresetInTheStack(): void
|
||||
{
|
||||
$matcher = new PresetUsageMatcher();
|
||||
$button = new PresetScope('module', 'divi/button');
|
||||
|
||||
self::assertTrue($matcher->uses(self::page('{"modulePreset":["aaaaaaaaaa"]}'), $button, 'aaaaaaaaaa', false));
|
||||
self::assertTrue($matcher->uses(self::page('{"modulePreset":["x","aaaaaaaaaa"]}'), $button, 'aaaaaaaaaa', false));
|
||||
self::assertTrue($matcher->uses(self::page('{"modulePreset":"aaaaaaaaaa"}'), $button, 'aaaaaaaaaa', false));
|
||||
self::assertFalse($matcher->uses(self::page('{"modulePreset":["bbbbbbbbbb"]}'), $button, 'aaaaaaaaaa', false));
|
||||
self::assertFalse(
|
||||
$matcher->uses(self::page('{"modulePreset":["aaaaaaaaaa"]}'), new PresetScope('module', 'divi/text'), 'aaaaaaaaaa', false),
|
||||
'an ID of another module does not count'
|
||||
);
|
||||
}
|
||||
|
||||
public function testDefaultModulePresetReachesModulesWithoutOwnPreset(): void
|
||||
{
|
||||
$matcher = new PresetUsageMatcher();
|
||||
$button = new PresetScope('module', 'divi/button');
|
||||
|
||||
self::assertTrue($matcher->uses(self::page('{}'), $button, 'dddddddddd', true));
|
||||
self::assertTrue($matcher->uses(self::page('{"modulePreset":["default"]}'), $button, 'dddddddddd', true));
|
||||
self::assertFalse($matcher->uses(self::page('{"modulePreset":["aaaaaaaaaa"]}'), $button, 'dddddddddd', true));
|
||||
}
|
||||
|
||||
public function testGroupPresetById(): void
|
||||
{
|
||||
$matcher = new PresetUsageMatcher();
|
||||
$group = new PresetScope('group', 'divi/font-body');
|
||||
$attrs = '{"groupPreset":{"content.decoration.bodyFont":{"presetId":["gggggggggg"],"groupName":"divi/font-body"}}}';
|
||||
|
||||
self::assertTrue($matcher->uses(self::page($attrs), $group, 'gggggggggg', false));
|
||||
self::assertFalse($matcher->uses(self::page($attrs), new PresetScope('group', 'divi/spacing'), 'gggggggggg', false));
|
||||
}
|
||||
|
||||
public function testBrokenMarkupCountsAsUse(): void
|
||||
{
|
||||
self::assertTrue((new PresetUsageMatcher())->uses(
|
||||
'<!-- wp:divi/section {"a": --><!-- /wp:divi/row -->',
|
||||
new PresetScope('module', 'divi/button'),
|
||||
'aaaaaaaaaa',
|
||||
false
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class PresetUsageQueryTest extends TestCase
|
||||
{
|
||||
public function testCandidatesContainAnIdOrAJsonEscape(): 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)',
|
||||
'%aaaaaaaaaa%',
|
||||
'%gggggggggg%',
|
||||
'\\\\u00[4-7][0-9a-fA-F]'
|
||||
)
|
||||
->andReturn('(prepared)');
|
||||
|
||||
$query = new PresetUsageQuery([
|
||||
['scope' => new PresetScope('module', 'divi/button'), 'id' => 'aaaaaaaaaa', 'default' => false],
|
||||
['scope' => new PresetScope('group', 'divi/font-body'), 'id' => 'gggggggggg', 'default' => false],
|
||||
]);
|
||||
|
||||
self::assertSame('(prepared)', $query->candidateCondition($db));
|
||||
self::assertTrue($query->matches('<!-- wp:divi/text {"groupPreset":{"content.decoration.bodyFont":'
|
||||
. '{"presetId":["gggggggggg"],"groupName":"divi/font-body"}}} /-->'));
|
||||
self::assertFalse($query->matches('<!-- wp:divi/text {} /-->'));
|
||||
}
|
||||
|
||||
public function testADefaultModulePresetLooksForTheModule(): 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)',
|
||||
'%dddddddddd%',
|
||||
'%wp:divi/button %',
|
||||
'\\\\u00[4-7][0-9a-fA-F]'
|
||||
)
|
||||
->andReturn('(prepared)');
|
||||
|
||||
$query = new PresetUsageQuery([
|
||||
['scope' => new PresetScope('module', 'divi/button'), 'id' => 'dddddddddd', 'default' => true],
|
||||
]);
|
||||
|
||||
self::assertSame('(prepared)', $query->candidateCondition($db));
|
||||
self::assertTrue($query->matches('<!-- wp:divi/button {} /-->'));
|
||||
}
|
||||
|
||||
public function testNeedsATarget(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
new PresetUsageQuery([]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user