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,150 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Schema;
/**
* The modules this Divi installation knows (spec §5.7 lit-divi-catalog), built from
* ModuleRegistration::get_all_core_modules_metadata() – always live, never a copy.
* Pure PHP: the metadata array and a defaults callback are passed in.
*/
final class ModuleCatalog
{
/** @var array<string, array<string, mixed>> Full name => metadata. */
private array $modules = [];
/** @var array<string, list<string>> */
private array $paths = [];
/** @var array<string, array<string, string>> */
private array $kinds = [];
/** @var callable(string): array<string, mixed> */
private $defaults;
/**
* @param array<array-key, mixed> $metadata Divi's metadata list (keys are ignored).
* @param (callable(string): array<string, mixed>)|null $defaults Default attributes per full module name.
*/
public function __construct(array $metadata, ?callable $defaults = null)
{
foreach ($metadata as $module) {
if (is_array($module) && is_string($module['name'] ?? null)) {
$this->modules[$module['name']] = $module;
}
}
ksort($this->modules);
$this->defaults = $defaults ?? static fn (string $name): array => [];
}
/** "button", "Button" or "divi/button" → "divi/button". */
public static function normalize(string $type): string
{
$type = strtolower(trim($type));
return str_contains($type, '/') ? $type : 'divi/' . $type;
}
public function has(string $name): bool
{
return isset($this->modules[$name]);
}
/** @return list<string> */
public function names(): array
{
return array_map('strval', array_keys($this->modules));
}
public function title(string $name): string
{
$title = $this->modules[$name]['title'] ?? $name;
return is_string($title) ? $title : $name;
}
/** "module", "structure" or "child-module". */
public function category(string $name): string
{
$category = $this->modules[$name]['category'] ?? 'module';
return is_string($category) ? $category : 'module';
}
/** @return list<string> Modules allowed as children (childrenName and childModuleName). */
public function childrenOf(string $name): array
{
$module = $this->modules[$name] ?? [];
$children = is_array($module['childrenName'] ?? null) ? $module['childrenName'] : [];
if (is_string($module['childModuleName'] ?? null) && $module['childModuleName'] !== '') {
$children[] = $module['childModuleName'];
}
return array_values(array_unique(array_filter($children, 'is_string')));
}
/** @return list<string> */
public function paths(string $name): array
{
if (! isset($this->paths[$name])) {
$attributes = $this->modules[$name]['attributes'] ?? [];
$defaults = $this->has($name) ? ($this->defaults)($name) : [];
$this->paths[$name] = AttrPathIndex::paths(
is_array($attributes) ? $attributes : [],
is_array($defaults) ? $defaults : []
);
}
return $this->paths[$name];
}
public function isKnownPath(string $name, string $path): bool
{
return in_array($path, $this->paths($name), true);
}
/** @return list<string> */
public function suggestions(string $name, string $path): array
{
return AttrPathIndex::suggest($path, $this->paths($name));
}
/**
* Known attribute path => value kind for this module (spec §5.6, ruling R3), wherever
* Divi's metadata names an unambiguous field component; not every known path resolves.
*
* @return array<string, string>
*/
public function kinds(string $name): array
{
if (! isset($this->kinds[$name])) {
$attributes = $this->modules[$name]['attributes'] ?? [];
$this->kinds[$name] = AttrPathIndex::kinds(is_array($attributes) ? $attributes : []);
}
return $this->kinds[$name];
}
/** Value kind of one path, "unknown" if Divi's metadata does not resolve one (spec §5.6). */
public function pathKind(string $name, string $path): string
{
return $this->kinds($name)[$path] ?? 'unknown';
}
/** @return list<array{name: string, title: string, category: string, children: list<string>}> */
public function summary(): array
{
$rows = [];
foreach ($this->names() as $name) {
$rows[] = [
'name' => $name,
'title' => $this->title($name),
'category' => $this->category($name),
'children' => $this->childrenOf($name),
];
}
return $rows;
}
}