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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user