feat: Fundstellen eines Presets (PresetUsageMatcher, PresetUsageQuery)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:38:22 +02:00
co-authored by Claude Haiku 5.5
parent 454116851e
commit bfd3840eb1
4 changed files with 280 additions and 0 deletions
@@ -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;
}
}