diff --git a/src/Integrations/Divi/Design/PresetOverrides.php b/src/Integrations/Divi/Design/PresetOverrides.php new file mode 100644 index 0000000..66ccaac --- /dev/null +++ b/src/Integrations/Divi/Design/PresetOverrides.php @@ -0,0 +1,305 @@ +|||" (the sub path is empty for a scalar state value, e.g. the text of + * "title.innerContent"). A local padding.top covers a preset's padding.top, never its padding.bottom. + * + * Counted are the presets of the block's modulePreset stack, the entries of its groupPreset, and the groupPresets + * nested in the assigned module presets – not the default presets Divi applies implicitly. A group preset stores its + * attributes below its own groupId; they are moved to the key the block (or the nesting module preset) uses for it. + * Two values are the same when their scalars are the same text (Divi's builder writes "700", an API client may write + * 700), lists and objects compare by content. Pure PHP; the preset data enters through the closure. + */ +final class PresetOverrides +{ + private const MAX_PATHS = 5; + + /** @param Closure(string $id): ?array $item Stored preset item (module or group) or null. */ + public function __construct(private Closure $item) + { + } + + /** + * @param array $attrs + * @return array "path|breakpoint|state|subpath" => value; AttrMap::META_KEYS are left out. + */ + public static function leaves(array $attrs): array + { + $leaves = []; + foreach (AttrMap::flatten($attrs) as $path => $map) { + if (! AttrMap::isBreakpointMap($map)) { + continue; + } + + foreach ($map as $breakpoint => $states) { + $states = self::plain($states); + foreach (is_array($states) ? $states : [] as $state => $value) { + self::collect($path . '|' . $breakpoint . '|' . $state, $value, '', $leaves); + } + } + } + + return $leaves; + } + + /** + * @param array $attrs Attributes of a block. + * @return array> Preset ID => local leaves with another value than the preset's. + */ + public function find(array $attrs): array + { + $local = self::leaves($attrs); + $found = []; + foreach ($this->assigned($attrs) as $id => $leaves) { + foreach ($leaves as $key => $value) { + if (array_key_exists($key, $local) && self::canonical($local[$key]) !== self::canonical($value)) { + $found[$id][] = $key; + } + } + } + + return $found; + } + + /** + * Removes every local leaf that lies on a leaf of an assigned preset – also one with the same value, which + * changes nothing – and the parents that become empty. + * + * @param array $attrs + * @return array + */ + public function clear(array $attrs): array + { + $covered = []; + foreach ($this->assigned($attrs) as $leaves) { + $covered += $leaves; + } + + foreach (array_keys(self::leaves($attrs)) as $key) { + if (array_key_exists($key, $covered)) { + $attrs = AttrMap::remove($attrs, self::pathOf($key)); + } + } + + return $attrs; + } + + /** + * One warning per overridden preset (ruling R5). + * + * @param array $attrs + * @param array $names Preset ID => name. + * @return list + */ + public function describe(string $nodeId, array $attrs, array $names): array + { + $lines = []; + foreach ($this->find($attrs) as $id => $keys) { + $paths = array_values(array_unique(array_map([self::class, 'label'], $keys))); + $shown = implode(', ', array_slice($paths, 0, self::MAX_PATHS)); + $more = count($paths) - self::MAX_PATHS; + $lines[] = sprintf( + '%s: lokale Werte überdecken „%s“: %s%s – clear_overrides: true entfernt sie.', + $nodeId, + $names[$id] ?? $id, + $shown, + $more > 0 ? sprintf(' und %d weitere', $more) : '' + ); + } + + return $lines; + } + + /** + * Leaves of the explicitly assigned presets, in assignment order: each module preset of the stack followed by the + * group presets nested in it, then the block's own group presets. + * + * @param array $attrs + * @return array> Preset ID => leaves (keys as in leaves()). + */ + private function assigned(array $attrs): array + { + $assigned = []; + foreach (self::idList($attrs['modulePreset'] ?? null) as $id) { + $item = $this->lookup($id); + if ($item === null) { + continue; + } + + $this->add($assigned, $id, $item, null); + foreach (self::groupEntries($item['groupPresets'] ?? null) as $target => $ids) { + $this->addGroupPresets($assigned, $target, $ids); + } + } + + foreach (self::groupEntries($attrs['groupPreset'] ?? null) as $target => $ids) { + $this->addGroupPresets($assigned, $target, $ids); + } + + return $assigned; + } + + /** + * @param array> $assigned + * @param list $ids + */ + private function addGroupPresets(array &$assigned, string $target, array $ids): void + { + foreach ($ids as $id) { + $item = $this->lookup($id); + if ($item !== null) { + $this->add($assigned, $id, $item, $target); + } + } + } + + /** + * @param array> $assigned + * @param array $item + */ + private function add(array &$assigned, string $id, array $item, ?string $target): void + { + $attrs = is_array($item['attrs'] ?? null) ? $item['attrs'] : []; + $leaves = self::leaves($attrs); + $from = $item['groupId'] ?? null; + if ($target !== null && is_string($from) && $from !== $target) { + $leaves = self::moved($leaves, $from, $target); + } + + $assigned[$id] = ($assigned[$id] ?? []) + $leaves; + } + + /** @return array|null */ + private function lookup(string $id): ?array + { + $item = ($this->item)($id); + + return is_array($item) ? $item : null; + } + + /** + * Leaves below the attribute path $from, moved below $to; leaves outside $from do not belong to the group. + * + * @param array $leaves + * @return array + */ + private static function moved(array $leaves, string $from, string $to): array + { + $moved = []; + foreach ($leaves as $key => $value) { + [$path, $rest] = explode('|', $key, 2); + if ($path === $from) { + $moved[$to . '|' . $rest] = $value; + } elseif (str_starts_with($path, $from . '.')) { + $moved[$to . substr($path, strlen($from)) . '|' . $rest] = $value; + } + } + + return $moved; + } + + /** + * @param array $leaves + */ + private static function collect(string $head, mixed $value, string $sub, array &$leaves): void + { + $value = self::plain($value); + if (is_array($value) && ! array_is_list($value)) { + foreach ($value as $key => $inner) { + self::collect($head, $inner, $sub === '' ? (string) $key : $sub . '.' . $key, $leaves); + } + + return; + } + + if ($value !== []) { + $leaves[$head . '|' . $sub] = $value; + } + } + + private static function pathOf(string $key): string + { + [$path, $breakpoint, $state, $sub] = explode('|', $key, 4); + + return $path . '.' . $breakpoint . '.' . $state . ($sub === '' ? '' : '.' . $sub); + } + + /** "" for desktop/value, else " ([, ])" (ruling R5). */ + private static function label(string $key): string + { + [$path, $breakpoint, $state] = explode('|', $key, 4); + if ($breakpoint === 'desktop' && $state === 'value') { + return $path; + } + + return $path . ' (' . $breakpoint . ($state === 'value' ? '' : ', ' . $state) . ')'; + } + + /** @return list */ + private static function idList(mixed $ids): array + { + if (is_string($ids)) { + $ids = [$ids]; + } + + return is_array($ids) + ? array_values(array_filter($ids, static fn (mixed $id): bool => is_string($id) && $id !== '')) + : []; + } + + /** + * @return array> Attribute path of the group => preset IDs. + */ + private static function groupEntries(mixed $entries): array + { + $entries = self::plain($entries); + $result = []; + foreach (is_array($entries) ? $entries : [] as $groupId => $entry) { + $ids = is_array($entry) ? self::idList($entry['presetId'] ?? null) : []; + if ($ids !== []) { + $result[(string) $groupId] = $ids; + } + } + + return $result; + } + + private static function plain(mixed $value): mixed + { + return $value instanceof stdClass ? get_object_vars($value) : $value; + } + + /** A form two equal values share: scalars as text, objects by sorted keys, lists in order. */ + private static function canonical(mixed $value): string + { + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4). + return (string) json_encode(self::normalized($value), JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); + } + + private static function normalized(mixed $value): mixed + { + $value = self::plain($value); + if (! is_array($value)) { + return is_scalar($value) ? (string) $value : ''; + } + + $value = array_map([self::class, 'normalized'], $value); + if (! array_is_list($value)) { + ksort($value); + } + + return $value; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/PresetOverridesTest.php b/tests/Unit/Integrations/Divi/Design/PresetOverridesTest.php new file mode 100644 index 0000000..86eb0a6 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/PresetOverridesTest.php @@ -0,0 +1,267 @@ + ['value' => $value]]; + } + + /** Presets in Divi's stored format (formats §3): a module preset nesting a group preset, a group preset. */ + private static function overrides(): PresetOverrides + { + $items = [ + 'mmmmmmmmmm' => [ + 'type' => 'module', 'id' => 'mmmmmmmmmm', 'name' => 'Blurb Leistung', 'moduleName' => 'divi/blurb', + 'attrs' => [ + 'module' => ['decoration' => ['spacing' => self::desktop([ + 'padding' => ['top' => '20px', 'bottom' => '10px'], + ])]], + 'title' => ['decoration' => ['font' => ['font' => self::desktop(['weight' => '700'])]]], + ], + 'groupPresets' => [ + 'title.decoration.bodyFont' => ['presetId' => ['ffffffffff'], 'groupName' => 'divi/font-body'], + ], + ], + 'ssssssssss' => [ + 'type' => 'group', 'id' => 'ssssssssss', 'name' => 'Abstand Karte', 'groupName' => 'divi/spacing', + 'groupId' => self::SPACING, 'moduleName' => 'divi/blurb', + 'attrs' => ['module' => ['decoration' => ['spacing' => self::desktop([ + 'margin' => ['bottom' => '24px'], + ])]]], + ], + 'ffffffffff' => [ + 'type' => 'group', 'id' => 'ffffffffff', 'name' => 'Fließtext', 'groupName' => 'divi/font-body', + 'groupId' => self::BODY_FONT, 'moduleName' => 'divi/text', + 'attrs' => ['content' => ['decoration' => ['bodyFont' => ['desktop' => [ + 'value' => ['size' => '18px'], + 'hover' => ['size' => '20px'], + ], 'phone' => ['value' => ['size' => '16px']]]]]], + ], + ]; + + return new PresetOverrides(static fn (string $id): ?array => $items[$id] ?? null); + } + + public function testLeavesFlattenBreakpointsStatesAndSubpaths(): void + { + $leaves = PresetOverrides::leaves([ + 'builderVersion' => '5.2.1', + 'modulePreset' => ['mmmmmmmmmm'], + 'groupPreset' => [self::SPACING => ['presetId' => ['ssssssssss'], 'groupName' => 'divi/spacing']], + 'module' => ['decoration' => ['spacing' => [ + 'desktop' => ['value' => ['padding' => ['top' => '20px'], 'syncVertical' => 'on'], 'hover' => [ + 'margin' => ['bottom' => '4px'], + ]], + 'phone' => ['value' => ['padding' => ['top' => '8px']]], + ]]], + 'title' => ['innerContent' => self::desktop('Hallo'), 'decoration' => ['font' => ['font' => self::desktop( + new stdClass() + )]]], + 'content' => ['decoration' => ['bodyFont' => self::desktop(['families' => ['Lato', 'Arial']])]], + ]); + + self::assertSame( + [ + 'content.decoration.bodyFont|desktop|value|families' => ['Lato', 'Arial'], + 'module.decoration.spacing|desktop|value|padding.top' => '20px', + 'module.decoration.spacing|desktop|value|syncVertical' => 'on', + 'module.decoration.spacing|desktop|hover|margin.bottom' => '4px', + 'module.decoration.spacing|phone|value|padding.top' => '8px', + 'title.innerContent|desktop|value|' => 'Hallo', + ], + $leaves + ); + } + + public function testFindReportsOnlyDifferingValuesOfTheModulePreset(): void + { + $attrs = [ + 'modulePreset' => ['mmmmmmmmmm'], + 'module' => ['decoration' => ['spacing' => self::desktop(['padding' => ['top' => '40px', 'bottom' => '10px']])]], + // 700 as a number is the same value as "700" (the builder writes strings, an API client may not). + 'title' => ['decoration' => ['font' => ['font' => self::desktop(['weight' => 700])]]], + ]; + + self::assertSame( + ['mmmmmmmmmm' => ['module.decoration.spacing|desktop|value|padding.top']], + self::overrides()->find($attrs) + ); + } + + public function testNoOverlapNoReport(): void + { + // A local padding.left does not cover the preset's padding.top/bottom (array_replace_recursive). + $attrs = [ + 'modulePreset' => ['mmmmmmmmmm'], + 'module' => ['decoration' => ['spacing' => self::desktop(['padding' => ['left' => '5px']])]], + 'title' => ['innerContent' => self::desktop('Text')], + ]; + + self::assertSame([], self::overrides()->find($attrs)); + self::assertSame([], self::overrides()->find([])); + } + + public function testGroupPresetLeavesMoveFromTheStoredGroupIdToTheBlocksKey(): void + { + // The preset was made for content.decoration.bodyFont; this block uses it on text.decoration.bodyFont. + $attrs = [ + 'groupPreset' => ['text.decoration.bodyFont' => ['presetId' => ['ffffffffff'], 'groupName' => 'divi/font-body']], + 'text' => ['decoration' => ['bodyFont' => [ + 'desktop' => ['value' => ['size' => '22px'], 'hover' => ['size' => '20px']], + 'phone' => ['value' => ['size' => '12px']], + ]]], + // Same path as the preset's stored groupId, but not the block's key: no overlap. + 'content' => ['decoration' => ['bodyFont' => self::desktop(['size' => '99px'])]], + ]; + + self::assertSame( + ['ffffffffff' => [ + 'text.decoration.bodyFont|desktop|value|size', + 'text.decoration.bodyFont|phone|value|size', + ]], + self::overrides()->find($attrs) + ); + } + + public function testNestedGroupPresetsOfAnAssignedModulePresetCount(): void + { + $attrs = [ + 'modulePreset' => ['mmmmmmmmmm'], + 'title' => ['decoration' => ['bodyFont' => self::desktop(['size' => '30px'])]], + ]; + + // The nested entry sits on title.decoration.bodyFont of the module preset; its leaves move there. + self::assertSame( + ['ffffffffff' => ['title.decoration.bodyFont|desktop|value|size']], + self::overrides()->find($attrs) + ); + } + + public function testPresetsNobodyAssignedAreNotCounted(): void + { + // Default presets ("", "default", "_initial") and unknown IDs are no explicit assignment (ruling R4). + $attrs = [ + 'modulePreset' => ['', 'default', '_initial', 'unbekannt'], + 'module' => ['decoration' => ['spacing' => self::desktop(['padding' => ['top' => '40px']])]], + ]; + + self::assertSame([], self::overrides()->find($attrs)); + self::assertSame($attrs, self::overrides()->clear($attrs)); + } + + public function testStackAndSingleStringAreRead(): void + { + $attrs = [ + 'modulePreset' => 'mmmmmmmmmm', + 'groupPreset' => [self::SPACING => ['presetId' => 'ssssssssss', 'groupName' => 'divi/spacing']], + 'module' => ['decoration' => ['spacing' => self::desktop([ + 'padding' => ['bottom' => '11px'], 'margin' => ['bottom' => '1px'], + ])]], + ]; + + self::assertSame( + [ + 'mmmmmmmmmm' => ['module.decoration.spacing|desktop|value|padding.bottom'], + 'ssssssssss' => ['module.decoration.spacing|desktop|value|margin.bottom'], + ], + self::overrides()->find($attrs) + ); + } + + public function testClearRemovesLocalLeavesOnPresetLeavesAndEmptyParents(): void + { + $attrs = [ + 'builderVersion' => '5.2.1', + 'modulePreset' => ['mmmmmmmmmm'], + 'module' => ['decoration' => ['spacing' => self::desktop([ + // padding.top is a preset leaf (different value), padding.bottom too (same value), left is not. + 'padding' => ['top' => '40px', 'bottom' => '10px', 'left' => '5px'], + ])]], + 'title' => [ + 'innerContent' => self::desktop('Hallo'), + 'decoration' => ['font' => ['font' => self::desktop(['weight' => '400'])]], + ], + ]; + + self::assertSame( + [ + 'builderVersion' => '5.2.1', + 'modulePreset' => ['mmmmmmmmmm'], + 'module' => ['decoration' => ['spacing' => self::desktop(['padding' => ['left' => '5px']])]], + 'title' => ['innerContent' => self::desktop('Hallo')], + ], + self::overrides()->clear($attrs) + ); + } + + public function testClearDropsAWholeBranchWhenNothingElseIsLeft(): void + { + $attrs = [ + 'modulePreset' => ['mmmmmmmmmm'], + 'title' => ['decoration' => ['font' => ['font' => self::desktop(['weight' => '400'])]]], + ]; + + self::assertSame(['modulePreset' => ['mmmmmmmmmm']], self::overrides()->clear($attrs)); + } + + public function testDescribeFollowsRulingR5(): void + { + $attrs = [ + 'modulePreset' => ['mmmmmmmmmm'], + 'groupPreset' => ['text.decoration.bodyFont' => ['presetId' => ['ffffffffff'], 'groupName' => 'divi/font-body']], + 'module' => ['decoration' => ['spacing' => self::desktop(['padding' => ['top' => '40px']])]], + 'text' => ['decoration' => ['bodyFont' => [ + 'desktop' => ['hover' => ['size' => '1px'], 'value' => ['size' => '2px']], + 'phone' => ['value' => ['size' => '3px']], + ]]], + ]; + $names = ['mmmmmmmmmm' => 'Blurb Leistung', 'ffffffffff' => 'Fließtext']; + + self::assertSame( + [ + 's0.r0.c0.m2: lokale Werte überdecken „Blurb Leistung“: module.decoration.spacing ' + . '– clear_overrides: true entfernt sie.', + 's0.r0.c0.m2: lokale Werte überdecken „Fließtext“: text.decoration.bodyFont, ' + . 'text.decoration.bodyFont (desktop, hover), text.decoration.bodyFont (phone) ' + . '– clear_overrides: true entfernt sie.', + ], + self::overrides()->describe('s0.r0.c0.m2', $attrs, $names) + ); + } + + public function testDescribeShowsAtMostFivePathsAndCountsTheRest(): void + { + $preset = ['id' => 'pppppppppp', 'name' => 'Viele', 'attrs' => []]; + $local = ['modulePreset' => ['pppppppppp']]; + foreach (range(1, 7) as $number) { + $preset['attrs']['module']['decoration']['p' . $number] = self::desktop('a'); + $local['module']['decoration']['p' . $number] = self::desktop('b'); + } + $overrides = new PresetOverrides(static fn (string $id): ?array => $id === 'pppppppppp' ? $preset : null); + + self::assertSame( + ['x1: lokale Werte überdecken „pppppppppp“: module.decoration.p1, module.decoration.p2, ' + . 'module.decoration.p3, module.decoration.p4, module.decoration.p5 und 2 weitere ' + . '– clear_overrides: true entfernt sie.'], + $overrides->describe('x1', $local, []) + ); + } + + public function testDescribeIsEmptyWithoutOverrides(): void + { + self::assertSame([], self::overrides()->describe('s0', ['modulePreset' => ['mmmmmmmmmm']], [])); + } +}