diff --git a/src/Integrations/Divi/BlockTree/NodeSummary.php b/src/Integrations/Divi/BlockTree/NodeSummary.php index e503f6f..d8fbdaf 100644 --- a/src/Integrations/Divi/BlockTree/NodeSummary.php +++ b/src/Integrations/Divi/BlockTree/NodeSummary.php @@ -23,6 +23,12 @@ final class NodeSummary /** The admin label is a one-line title in the layers panel, never an unbounded copy. */ private const LABEL_LIMIT = 80; + /** "font-body" for the group name "divi/font-body" – the key "group_presets" of the write tools takes. */ + public static function groupShortName(string $groupName): string + { + return str_starts_with($groupName, 'divi/') ? substr($groupName, 5) : $groupName; + } + public static function label(Block $block): string { $attrs = $block->name() === 'divi/global-layout' && is_array($block->attrs()['localAttrs'] ?? null) diff --git a/src/Integrations/Divi/BlockTree/Outline.php b/src/Integrations/Divi/BlockTree/Outline.php index bec980d..2ba5c7c 100644 --- a/src/Integrations/Divi/BlockTree/Outline.php +++ b/src/Integrations/Divi/BlockTree/Outline.php @@ -174,8 +174,7 @@ final class Outline { $lines = []; foreach (NodeSummary::groupPresets($block) as $group) { - $name = $group['groupName']; - $short = str_starts_with($name, 'divi/') ? substr($name, 5) : $name; + $short = NodeSummary::groupShortName($group['groupName']); $lines[] = $short . ': ' . implode(', ', $this->namesOf($group['presetIds'])); } diff --git a/src/Integrations/Divi/Design/ModuleUsageQuery.php b/src/Integrations/Divi/Design/ModuleUsageQuery.php index fb22dde..c7ebc3b 100644 --- a/src/Integrations/Divi/Design/ModuleUsageQuery.php +++ b/src/Integrations/Divi/Design/ModuleUsageQuery.php @@ -23,8 +23,6 @@ final class ModuleUsageQuery implements UsageQuery { private const NAME = '/^([a-z][a-z0-9_-]*)\/([a-z][a-z0-9_-]*)$/D'; - private const ESCAPED_LETTER = '\\\\u00[4-7][0-9a-fA-F]'; - /** @throws InvalidArgumentException When $module is no full block name such as "divi/button". */ public function __construct(private string $module) { @@ -39,7 +37,7 @@ final class ModuleUsageQuery implements UsageQuery '(post_content LIKE %s OR post_content REGEXP %s OR post_content REGEXP %s)', '%' . $db->esc_like($this->module) . '%', $this->escapedSlash(), - self::ESCAPED_LETTER + self::ESCAPED_LETTER_REGEX ); } diff --git a/src/Integrations/Divi/Design/PresetAssignment.php b/src/Integrations/Divi/Design/PresetAssignment.php index ae32fc2..22c77e6 100644 --- a/src/Integrations/Divi/Design/PresetAssignment.php +++ b/src/Integrations/Divi/Design/PresetAssignment.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Design; use Closure; use InvalidArgumentException; +use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; defined('ABSPATH') || exit; @@ -280,7 +281,7 @@ final class PresetAssignment private static function shortNames(array $groups): string { $names = array_unique(array_map( - static fn (string $name): string => str_starts_with($name, 'divi/') ? substr($name, 5) : $name, + NodeSummary::groupShortName(...), array_values($groups) )); diff --git a/src/Integrations/Divi/Design/PresetUsageQuery.php b/src/Integrations/Divi/Design/PresetUsageQuery.php index 6619bc1..ba827b6 100644 --- a/src/Integrations/Divi/Design/PresetUsageQuery.php +++ b/src/Integrations/Divi/Design/PresetUsageQuery.php @@ -18,8 +18,6 @@ defined('ABSPATH') || exit; */ final class PresetUsageQuery implements UsageQuery { - private const ESCAPED_LETTER = '\\\\u00[4-7][0-9a-fA-F]'; - /** @var list */ private array $targets; @@ -61,7 +59,7 @@ final class PresetUsageQuery implements UsageQuery return (string) $db->prepare( '(' . str_repeat('post_content LIKE %s OR ', count($likes)) . 'post_content REGEXP %s)', - ...array_merge($likes, [self::ESCAPED_LETTER]) + ...array_merge($likes, [self::ESCAPED_LETTER_REGEX]) ); } diff --git a/src/Integrations/Divi/Library/LibraryUsageQuery.php b/src/Integrations/Divi/Library/LibraryUsageQuery.php index 9a34d84..d78c352 100644 --- a/src/Integrations/Divi/Library/LibraryUsageQuery.php +++ b/src/Integrations/Divi/Library/LibraryUsageQuery.php @@ -23,7 +23,7 @@ final class LibraryUsageQuery implements UsageQuery '(LOWER(post_content) LIKE %s OR LOWER(post_content) LIKE %s OR post_content REGEXP %s)', '%' . $db->esc_like('globalmodule') . '%', '%' . $db->esc_like('global_module') . '%', - str_repeat(chr(92), 2) . 'u00[4-7][0-9a-fA-F]' + self::ESCAPED_LETTER_REGEX ); } diff --git a/src/Integrations/Divi/Library/UsageQuery.php b/src/Integrations/Divi/Library/UsageQuery.php index d208777..d7d4c07 100644 --- a/src/Integrations/Divi/Library/UsageQuery.php +++ b/src/Integrations/Divi/Library/UsageQuery.php @@ -13,6 +13,12 @@ defined('ABSPATH') || exit; */ interface UsageQuery { + /** + * MySQL REGEXP for a JSON-escaped letter ("\\u004x" … "\\u007x"): a name or ID written that way is not found by + * LIKE, so candidate conditions keep such posts (fail closed, ruling P23). + */ + public const ESCAPED_LETTER_REGEX = '\\\\u00[4-7][0-9a-fA-F]'; + /** A condition for "WHERE (…)", already passed through $db->prepare(). */ public function candidateCondition(\wpdb $db): string; diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php index 9a2a31d..e09145c 100644 --- a/src/Integrations/Divi/Pages/DiviComposition.php +++ b/src/Integrations/Divi/Pages/DiviComposition.php @@ -115,9 +115,8 @@ class DiviComposition /** * The one PresetAssignment of this composition (D7, rulings R2, R3): the book comes lazily from Divi's stored - * presets, ids must pass the choke point unchanged. - * - * @internal Public for tests; code uses composer() / warnings(). + * presets, ids must pass the choke point unchanged. Public: DiviIntegration::presetAssignment() hands it to + * the site-wide tools (lit/divi-presets-assign), which need the same instance as the write tools. */ public function presets(): PresetAssignment { diff --git a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php index 719e0ec..ee713c1 100644 --- a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php +++ b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php @@ -202,10 +202,9 @@ final class DiviNodeGetAbility implements Ability { $groups = []; foreach (NodeSummary::groupPresets($block) as $groupId => $group) { - $name = $group['groupName']; $groups[] = [ 'group_id' => $groupId, - 'group' => str_starts_with($name, 'divi/') ? substr($name, 5) : $name, + 'group' => NodeSummary::groupShortName($group['groupName']), 'presets' => array_map( static fn (string $presetId): array => ['id' => $presetId, 'name' => $names[$presetId] ?? ''], $group['presetIds'] diff --git a/src/Integrations/Divi/Pages/DiviUpdateAbility.php b/src/Integrations/Divi/Pages/DiviUpdateAbility.php index 0bfd77f..1d1d231 100644 --- a/src/Integrations/Divi/Pages/DiviUpdateAbility.php +++ b/src/Integrations/Divi/Pages/DiviUpdateAbility.php @@ -38,7 +38,8 @@ final class DiviUpdateAbility extends DiviWriteAbility return 'Ändert vorhandene Divi-Knoten: changes = Liste von {id, set: {Kurzform-Felder}, attrs: {Attributpfad: ' . '{Breakpoint: Wert}}, preset, group_presets, clear_overrides, label}. null entfernt einen Wert. ' . 'preset = Name/ID oder Liste (Stapel, bis ' . PresetAssignment::MAX_STACK . '; bei gleicher Priorität ' - . 'gewinnt das letzte Preset im Stapel); group_presets = {Gruppe: Preset}, z. B. {"font-body": "Fließtext"}. Überdecken lokale Werte ' + . 'gewinnt das letzte Preset im Stapel); group_presets = {Gruppe: Preset}, z. B. ' + . '{"font-body": "Fließtext"}. Überdecken lokale Werte ' . 'ein Preset, meldet die Antwort das; clear_overrides: true entfernt sie (nach set/attrs). Nicht ' . 'genannte Einstellungen bleiben unverändert. Jeder Knoten nur einmal pro Aufruf; denselben Pfad nicht ' . 'zugleich über set und attrs. tree_rev aus lit-divi-outline ist Pflicht; alles oder nichts.'; diff --git a/tests/Unit/Integrations/Divi/BlockTree/NodeSummaryTest.php b/tests/Unit/Integrations/Divi/BlockTree/NodeSummaryTest.php index f7b0015..f5d9ef7 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/NodeSummaryTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/NodeSummaryTest.php @@ -64,4 +64,11 @@ final class NodeSummaryTest extends TestCase self::assertSame([], NodeSummary::groupPresets(Block::create('divi/text', ['groupPreset' => 'x']))); self::assertSame([], NodeSummary::groupPresets(Block::create('divi/text', ['groupPreset' => []]))); } + + public function testGroupShortNameDropsTheDiviPrefixOnly(): void + { + self::assertSame('font-body', NodeSummary::groupShortName('divi/font-body')); + self::assertSame('custom/x', NodeSummary::groupShortName('custom/x')); + self::assertSame('spacing', NodeSummary::groupShortName('spacing')); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/WarningsTest.php b/tests/Unit/Integrations/Divi/Compose/WarningsTest.php index 5989476..ac72e5f 100644 --- a/tests/Unit/Integrations/Divi/Compose/WarningsTest.php +++ b/tests/Unit/Integrations/Divi/Compose/WarningsTest.php @@ -132,6 +132,35 @@ final class WarningsTest extends TestCase self::assertStringEndsWith('clear_overrides: true entfernt sie.', $covering[0]); } + /** A group preset only triggered loading before: its values are overridden by local ones like a module preset's. */ + public function testWarnsWhenLocalValuesCoverAGroupPreset(): void + { + $path = 'button.decoration.font.font'; + $book = PresetBook::fromDivi(['group' => ['divi/font' => ['default' => '', 'items' => [ + 'g-1' => [ + 'type' => 'group', 'id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font', + 'groupId' => $path, 'moduleName' => 'divi/button', + 'attrs' => ['button' => ['decoration' => ['font' => ['font' => [ + 'desktop' => ['value' => ['color' => '#ffffff']], + ]]]]], + ], + ]]]]); + $presets = ComposeFixture::presetAssignment(null, null, null, null, static fn (): PresetBook => $book); + $button = Block::create('divi/button', [ + 'groupPreset' => [$path => ['presetId' => ['g-1'], 'groupName' => 'divi/font']], + 'button' => ['decoration' => ['font' => ['font' => ['desktop' => ['value' => ['color' => '#000000']]]]]], + ]); + + [$covering, $index] = self::collect([$button], [$button], $presets); + + self::assertCount(1, $covering); + self::assertStringStartsWith( + $index->idOf($button) . ': lokale Werte überdecken „Fließtext groß“: ', + $covering[0] + ); + self::assertStringContainsString($path, $covering[0]); + } + public function testEqualValuesAndUntouchedBlocksDoNotWarn(): void { $loads = 0; diff --git a/tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php b/tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php index fe0103e..6ca49d7 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php @@ -4,12 +4,23 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library; +use LIT\MCP\Integrations\Divi\Design\ModuleUsageQuery; +use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery; use LIT\MCP\Integrations\Divi\Library\LibraryUsageQuery; +use LIT\MCP\Integrations\Divi\Library\UsageQuery; use LIT\MCP\Tests\Unit\TestCase; use Mockery; final class LibraryUsageQueryTest extends TestCase { + /** The one fail-closed pattern for JSON-escaped letters (two backslashes, "u00", a letter range). */ + public function testEscapedLetterPatternIsPinned(): void + { + self::assertSame(str_repeat(chr(92), 2) . 'u00[4-7][0-9a-fA-F]', UsageQuery::ESCAPED_LETTER_REGEX); + self::assertSame(UsageQuery::ESCAPED_LETTER_REGEX, ModuleUsageQuery::ESCAPED_LETTER_REGEX); + self::assertSame(UsageQuery::ESCAPED_LETTER_REGEX, PresetUsageQuery::ESCAPED_LETTER_REGEX); + } + public function testConditionIsTheFormerCandidateFilter(): void { $db = Mockery::mock(\wpdb::class);