Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
288 lines
12 KiB
PHP
288 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Schema;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Known attribute paths of one module, derived from Divi's module metadata (settings of
|
||
* every attribute group, attrName references) and its default attributes (spec §5.4:
|
||
* "Attributpfade werden gegen den Katalog validiert"). Font and text groups store their
|
||
* values one or two levels deeper; those paths are expanded (title.decoration.font.font).
|
||
*/
|
||
final class AttrPathIndex
|
||
{
|
||
/** "category.group" => sub paths holding the breakpoint maps. */
|
||
public const NESTED = [
|
||
'decoration.font' => ['font', 'textShadow'],
|
||
'decoration.bodyFont' => [
|
||
'body.font', 'body.textShadow', 'link.font', 'link.textShadow', 'ul.font', 'ul.textShadow', 'ul.list',
|
||
'ol.font', 'ol.textShadow', 'ol.list', 'quote.font', 'quote.textShadow', 'quote.border',
|
||
],
|
||
'decoration.headingFont' => [
|
||
'h1.font', 'h1.textShadow', 'h2.font', 'h2.textShadow', 'h3.font', 'h3.textShadow',
|
||
'h4.font', 'h4.textShadow', 'h5.font', 'h5.textShadow', 'h6.font', 'h6.textShadow',
|
||
],
|
||
'advanced.text' => ['text', 'textShadow'],
|
||
];
|
||
|
||
/** Keys inside a settings category that describe the UI, not an attribute group. */
|
||
private const UI_KEYS = ['groupType', 'item', 'items', 'component', 'groups', 'priority', 'render', 'panel'];
|
||
|
||
/**
|
||
* Divi field component name => value kind (spec §5.6/ruling R3: 2b routes sanitization by
|
||
* this, falling back to key-name matching where a path has no unambiguous scalar kind).
|
||
* Structural/style groups (fonts, spacing, shadows, backgrounds, …) rarely name a single
|
||
* component for their whole group in the metadata, so they are simply left out here; see
|
||
* ModuleCatalog::pathKind() for the "unknown" default.
|
||
*/
|
||
private const COMPONENT_KINDS = [
|
||
'divi/text' => 'text',
|
||
'divi/textarea' => 'text',
|
||
'divi/timepicker' => 'text',
|
||
'divi/google-api-key-field' => 'text',
|
||
'divi/richtext' => 'richtext',
|
||
'divi/code' => 'code',
|
||
'divi/link' => 'url',
|
||
'divi/button-link' => 'url',
|
||
'divi/upload' => 'image',
|
||
'divi/upload-gallery' => 'image',
|
||
'divi/color-picker' => 'color',
|
||
'divi/select' => 'enum',
|
||
'divi/select-menu' => 'enum',
|
||
'divi/select-column-class' => 'enum',
|
||
'divi/select-widget-area' => 'enum',
|
||
'divi/checkboxes' => 'list',
|
||
'divi/sortable-list' => 'list',
|
||
'divi/toggle' => 'onoff',
|
||
'divi/range' => 'number',
|
||
'divi/icon-picker' => 'icon',
|
||
];
|
||
|
||
/**
|
||
* Divi's features.dynamicContent.type => value kind, for a composite path's named
|
||
* sub-field ("subName", e.g. buttonOne.innerContent's "linkUrl"): several sub-fields of
|
||
* one composite path often share one generic field component (e.g. "divi/text" for both
|
||
* a button's plain text and its link URL – see kinds()), but Divi's own dynamicContent
|
||
* type still tells them apart reliably. Only used for composite (subName-bearing)
|
||
* candidates; a plain path keeps trusting its field component alone (COMPONENT_KINDS),
|
||
* so an already more specific kind there (richtext, code, …) is never downgraded to the
|
||
* generic "text" dynamicContent commonly names for both. number-advanced (e.g. border
|
||
* radius) holds an object of corner values and therefore stays unknown. A path that has
|
||
* both a plain field and named sub-fields keeps only the sub-field map (the plain field
|
||
* then counts as unknown).
|
||
*/
|
||
private const DYNAMIC_CONTENT_KINDS = [
|
||
'text' => 'text',
|
||
'url' => 'url',
|
||
'image' => 'image',
|
||
'color' => 'color',
|
||
'number' => 'number',
|
||
];
|
||
|
||
private const ATTR_NAME_PATTERN = '/^[A-Za-z0-9]+(\.[A-Za-z0-9]+)+$/';
|
||
|
||
/**
|
||
* @param array<string, mixed> $attributes Metadata "attributes" of one module.
|
||
* @param array<string, mixed> $defaults Its default attributes.
|
||
* @return list<string> Sorted.
|
||
*/
|
||
public static function paths(array $attributes, array $defaults): array
|
||
{
|
||
$paths = ['module.meta.adminLabel' => true];
|
||
foreach ($attributes as $attrName => $definition) {
|
||
$settings = is_array($definition['settings'] ?? null) ? $definition['settings'] : [];
|
||
foreach ($settings as $category => $groups) {
|
||
if ($category === 'innerContent') {
|
||
$paths[$attrName . '.innerContent'] = true;
|
||
continue;
|
||
}
|
||
|
||
if ($category === 'meta' || ! is_array($groups)) {
|
||
continue;
|
||
}
|
||
|
||
foreach (array_keys($groups) as $group) {
|
||
if (is_string($group) && ! in_array($group, self::UI_KEYS, true)) {
|
||
self::add($paths, $attrName . '.' . $category . '.' . $group);
|
||
}
|
||
}
|
||
}
|
||
|
||
self::attrNames($settings, $paths);
|
||
}
|
||
|
||
self::fromDefaults($defaults, $paths);
|
||
$list = array_map('strval', array_keys($paths));
|
||
sort($list);
|
||
|
||
return $list;
|
||
}
|
||
|
||
/**
|
||
* Known attribute path => value kind (text/richtext/code/url/image/color/enum/onoff/number/
|
||
* list/icon), wherever Divi's metadata names exactly one unambiguous field component for
|
||
* the whole path – OR, for a composite path (Divi groups several named sub-fields, each
|
||
* with its own "subName", under one attribute path – e.g. buttonOne.innerContent holds
|
||
* "text" and "linkUrl"), a map subName => kind for exactly the sub-fields the metadata
|
||
* names its own attrName+subName+field component for. Any other key actually present at
|
||
* runtime under a composite path (e.g. an image field's auto-synced "alt", never a
|
||
* metadata field of its own) is therefore never in that map – callers must default it to
|
||
* "unknown" (ModuleCatalog::pathKind()), never inherit the path's other sub-kind.
|
||
*
|
||
* A path (or one of its sub-fields) is left out entirely when Divi's own component is a
|
||
* "group" with no kind of its own, when its attrName is only named on a sibling "group"
|
||
* node rather than the field itself (button.innerContent's "text": no reliable way back to
|
||
* it), or when several candidates for the same (path, subName) disagree – a wrong-but-
|
||
* present kind would be more dangerous for 2b's sanitization routing than "unknown".
|
||
*
|
||
* @param array<string, mixed> $attributes Metadata "attributes" of one module.
|
||
* @return array<string, string|array<string, string>>
|
||
*/
|
||
public static function kinds(array $attributes): array
|
||
{
|
||
$candidates = [];
|
||
foreach ($attributes as $definition) {
|
||
$settings = is_array($definition['settings'] ?? null) ? $definition['settings'] : [];
|
||
self::collectKinds($settings, $candidates);
|
||
}
|
||
|
||
$kinds = ['module.meta.adminLabel' => 'text'];
|
||
foreach ($candidates as $attrName => $bySubName) {
|
||
if (count($bySubName) === 1 && array_key_exists('', $bySubName)) {
|
||
$unique = array_unique($bySubName['']);
|
||
if (count($unique) === 1) {
|
||
$kinds[$attrName] = $unique[0];
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
$map = [];
|
||
foreach ($bySubName as $subName => $found) {
|
||
if ($subName === '') {
|
||
continue;
|
||
}
|
||
|
||
$unique = array_unique($found);
|
||
if (count($unique) === 1) {
|
||
$map[$subName] = $unique[0];
|
||
}
|
||
}
|
||
|
||
if ($map !== []) {
|
||
$kinds[$attrName] = $map;
|
||
}
|
||
}
|
||
|
||
return $kinds;
|
||
}
|
||
|
||
/**
|
||
* Up to $max known paths closest to $path (same attribute group first).
|
||
*
|
||
* @param list<string> $known
|
||
* @return list<string>
|
||
*/
|
||
public static function suggest(string $path, array $known, int $max = 5): array
|
||
{
|
||
$first = explode('.', $path)[0];
|
||
$scored = [];
|
||
foreach ($known as $candidate) {
|
||
$penalty = explode('.', $candidate)[0] === $first ? 0 : 10;
|
||
$scored[$candidate] = levenshtein($path, $candidate) + $penalty;
|
||
}
|
||
asort($scored);
|
||
|
||
return array_slice(array_map('strval', array_keys($scored)), 0, $max);
|
||
}
|
||
|
||
/** @param array<string, bool> $paths */
|
||
private static function add(array &$paths, string $path): void
|
||
{
|
||
$segments = explode('.', $path);
|
||
$nested = count($segments) === 3 ? (self::NESTED[$segments[1] . '.' . $segments[2]] ?? null) : null;
|
||
if ($nested === null) {
|
||
$paths[$path] = true;
|
||
|
||
return;
|
||
}
|
||
|
||
foreach ($nested as $sub) {
|
||
$paths[$path . '.' . $sub] = true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $settings
|
||
* @param array<string, bool> $paths
|
||
*/
|
||
private static function attrNames(array $settings, array &$paths): void
|
||
{
|
||
foreach ($settings as $key => $value) {
|
||
if ($key === 'attrName' && is_string($value) && preg_match(self::ATTR_NAME_PATTERN, $value)) {
|
||
self::add($paths, $value);
|
||
} elseif (is_array($value)) {
|
||
self::attrNames($value, $paths);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Collects every (attrName, subName-or-"") => kind candidate found, without yet resolving
|
||
* conflicts – a "group" component (a composite of several sub-kinds with no kind of its
|
||
* own) never contributes a candidate, nor does a node with a subName but no attrName of
|
||
* its own (its attrName is only named on a sibling "group" node one level up).
|
||
*
|
||
* @param array<array-key, mixed> $node
|
||
* @param array<string, array<string, list<string>>> $candidates
|
||
*/
|
||
private static function collectKinds(array $node, array &$candidates): void
|
||
{
|
||
$attrName = $node['attrName'] ?? null;
|
||
$component = is_array($node['component'] ?? null) ? $node['component'] : [];
|
||
$componentName = $component['name'] ?? null;
|
||
if (
|
||
is_string($attrName)
|
||
&& preg_match(self::ATTR_NAME_PATTERN, $attrName)
|
||
&& ($component['type'] ?? null) === 'field'
|
||
&& is_string($componentName)
|
||
) {
|
||
$subName = is_string($node['subName'] ?? null) ? $node['subName'] : '';
|
||
$kind = $subName !== '' ? self::dynamicContentKind($node) : null;
|
||
$kind ??= self::COMPONENT_KINDS[$componentName] ?? null;
|
||
if ($kind !== null) {
|
||
$candidates[$attrName][$subName][] = $kind;
|
||
}
|
||
}
|
||
|
||
foreach ($node as $value) {
|
||
if (is_array($value)) {
|
||
self::collectKinds($value, $candidates);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** @param array<array-key, mixed> $node */
|
||
private static function dynamicContentKind(array $node): ?string
|
||
{
|
||
$features = is_array($node['features'] ?? null) ? $node['features'] : [];
|
||
$dynamic = is_array($features['dynamicContent'] ?? null) ? $features['dynamicContent'] : [];
|
||
$type = $dynamic['type'] ?? null;
|
||
|
||
return is_string($type) ? (self::DYNAMIC_CONTENT_KINDS[$type] ?? null) : null;
|
||
}
|
||
|
||
/** @param array<string, mixed> $attrs */
|
||
private static function fromDefaults(array $attrs, array &$paths): void
|
||
{
|
||
foreach (array_keys(AttrMap::flatten($attrs)) as $path) {
|
||
$paths[$path] = true;
|
||
}
|
||
}
|
||
}
|