Files
WP-LIT-MCP/src/Integrations/Divi/Schema/AttrPathIndex.php
T
janniclas.loosenandClaude Opus 5.5 b24eb6c3e9 fix(security): no direct HTTP access to plugin PHP files (audit L2, cheap part)
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
2026-09-26 19:32:12 +02:00

288 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}
}