feat: Divi module catalog, attribute paths, short form and lit/divi-catalog

Adds per-path attribute kind (text/richtext/code/url/image/color/enum/
onoff/number/list/icon), derived from Divi's field component metadata
where an attribute path names one unambiguously (module.paths() itself
is unchanged; kinds() is a new, separate lookup exposed as path_kinds
on lit/divi-catalog's module detail). This is not in the task-4 brief;
it is added because 2b's sanitization routing (ruling R3) needs a
metadata-derived field kind per attribute path. Verified live against
the full 100-module catalog (564/4358 paths resolve a kind; the rest
default to "unknown" and fall back to 2b's key-name routing).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 11:48:27 +02:00
co-authored by Claude Opus 5.5
parent 08d70abfc7
commit 2f4dcf9ce4
14 changed files with 5227 additions and 2 deletions
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Schema;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
/**
* 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',
];
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 an unambiguous field component for the path.
* Not every known path (see paths()) resolves to a kind; callers default to "unknown".
*
* @param array<string, mixed> $attributes Metadata "attributes" of one module.
* @return array<string, string>
*/
public static function kinds(array $attributes): array
{
$kinds = ['module.meta.adminLabel' => 'text'];
foreach ($attributes as $definition) {
$settings = is_array($definition['settings'] ?? null) ? $definition['settings'] : [];
self::collectKinds($settings, $kinds);
}
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);
}
}
}
/**
* @param array<array-key, mixed> $node
* @param array<string, string> $kinds
*/
private static function collectKinds(array $node, array &$kinds): void
{
$attrName = $node['attrName'] ?? null;
$componentName = $node['component']['name'] ?? null;
if (
is_string($attrName)
&& preg_match(self::ATTR_NAME_PATTERN, $attrName)
&& is_string($componentName)
&& isset(self::COMPONENT_KINDS[$componentName])
) {
$kinds[$attrName] = self::COMPONENT_KINDS[$componentName];
}
foreach ($node as $value) {
if (is_array($value)) {
self::collectKinds($value, $kinds);
}
}
}
/**
* @param array<array-key, mixed> $attrs
* @param array<string, bool> $paths
*/
private static function fromDefaults(array $attrs, string $prefix, array &$paths): void
{
foreach ($attrs as $key => $value) {
if ($prefix === '' && in_array($key, AttrMap::META_KEYS, true)) {
continue;
}
$path = $prefix === '' ? (string) $key : $prefix . '.' . $key;
if (AttrMap::isBreakpointMap($value)) {
$paths[$path] = true;
} elseif (is_array($value)) {
self::fromDefaults($value, $path, $paths);
}
}
}
}