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,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
|
||||
/**
|
||||
* Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded
|
||||
* (spec §2 principle 4); Divi 4 is never edited (spec §3.4).
|
||||
*/
|
||||
final class DiviIntegration implements Integration
|
||||
{
|
||||
private DiviModules $modules;
|
||||
|
||||
public function __construct(private ContentSchema $schema)
|
||||
{
|
||||
$this->modules = new DiviModules();
|
||||
}
|
||||
|
||||
public function id(): string
|
||||
{
|
||||
return 'divi';
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return DiviModules::isAvailable();
|
||||
}
|
||||
|
||||
public function abilities(): array
|
||||
{
|
||||
return [
|
||||
new DiviCatalogAbility($this->modules),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Schema;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\JsonMap;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* lit/divi-catalog: the modules of this Divi installation with their short-form fields;
|
||||
* with "module" the details of one module incl. all known attribute paths (spec §5.7).
|
||||
*/
|
||||
final class DiviCatalogAbility implements Ability
|
||||
{
|
||||
public const CODE_NOTE = 'Code-Modul (code) nur verwenden, wenn kein natives Modul passt – es ist im '
|
||||
. 'Divi-Builder nicht visuell bearbeitbar.';
|
||||
|
||||
public function __construct(private DiviModules $modules)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/divi-catalog';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Divi-Modulkatalog';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Listet die Divi-Module dieser Website (Name, Titel, Art, erlaubte Kind-Module) und welche eine '
|
||||
. 'Kurzform haben. Mit module: Kurzform-Felder und alle gültigen Attributpfade des Moduls für die '
|
||||
. 'Vollform, je Pfad nach Möglichkeit mit Feldart (path_kinds).';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'module' => ['type' => 'string', 'description' => 'Modul, z. B. "button" oder "divi/button".'],
|
||||
],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'modules' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string'],
|
||||
'title' => ['type' => 'string'],
|
||||
'category' => ['type' => 'string'],
|
||||
'children' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
'shortform' => ['type' => 'boolean'],
|
||||
],
|
||||
'required' => ['name', 'title', 'category', 'children', 'shortform'],
|
||||
],
|
||||
],
|
||||
'module' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string'],
|
||||
'title' => ['type' => 'string'],
|
||||
'category' => ['type' => 'string'],
|
||||
'children' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
'item_type' => ['type' => ['string', 'null']],
|
||||
'shortform' => ['type' => 'object'],
|
||||
'paths' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
'path_kinds' => [
|
||||
'type' => 'object',
|
||||
'description' => 'Bekannter Attributpfad => Feldart (text, richtext, code, url, '
|
||||
. 'image, color, enum, onoff, number, list, icon), soweit aus den Divi-Metadaten '
|
||||
. 'ableitbar; fehlende Pfade gelten als unbekannt.',
|
||||
],
|
||||
],
|
||||
'required' => ['name', 'title', 'category', 'children', 'shortform', 'paths', 'path_kinds'],
|
||||
],
|
||||
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
],
|
||||
'required' => ['notes'],
|
||||
];
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return current_user_can('edit_posts')
|
||||
? true
|
||||
: Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.');
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$catalog = $this->modules->catalog();
|
||||
$notes = [self::CODE_NOTE, 'Details zur Vollform: lit-guide mit topic "divi-attrs".'];
|
||||
|
||||
if (! is_string($input['module'] ?? null) || $input['module'] === '') {
|
||||
$modules = [];
|
||||
foreach ($catalog->summary() as $module) {
|
||||
$modules[] = $module + ['shortform' => ShortformMap::has(substr($module['name'], strlen('divi/')))];
|
||||
}
|
||||
|
||||
return ['modules' => $modules, 'notes' => $notes];
|
||||
}
|
||||
|
||||
$name = ModuleCatalog::normalize($input['module']);
|
||||
if (! $catalog->has($name)) {
|
||||
return Errors::notFound(
|
||||
sprintf('Modul "%s" gibt es auf dieser Website nicht.', $input['module']),
|
||||
'Verfügbare Module mit lit-divi-catalog ohne module abrufen.'
|
||||
);
|
||||
}
|
||||
|
||||
$short = substr($name, strlen('divi/'));
|
||||
$item = ShortformMap::childType($short);
|
||||
|
||||
return [
|
||||
'module' => [
|
||||
'name' => $name,
|
||||
'title' => $catalog->title($name),
|
||||
'category' => $catalog->category($name),
|
||||
'children' => $catalog->childrenOf($name),
|
||||
'item_type' => $item,
|
||||
'shortform' => JsonMap::of(ShortformMap::fields($short)),
|
||||
'paths' => $catalog->paths($name),
|
||||
'path_kinds' => JsonMap::of($catalog->kinds($name)),
|
||||
],
|
||||
'notes' => $notes,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Schema;
|
||||
|
||||
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
|
||||
|
||||
/**
|
||||
* Reads Divi's module metadata once per request (Divi 5 only). Not final so abilities can be
|
||||
* unit-tested with a mock.
|
||||
*/
|
||||
class DiviModules
|
||||
{
|
||||
private ?ModuleCatalog $catalog = null;
|
||||
|
||||
public static function isAvailable(): bool
|
||||
{
|
||||
return class_exists(ModuleRegistration::class);
|
||||
}
|
||||
|
||||
public function catalog(): ModuleCatalog
|
||||
{
|
||||
if ($this->catalog === null) {
|
||||
$metadata = ModuleRegistration::get_all_core_modules_metadata();
|
||||
$this->catalog = new ModuleCatalog(
|
||||
is_array($metadata) ? $metadata : [],
|
||||
static function (string $name): array {
|
||||
$defaults = ModuleRegistration::get_default_attrs($name, 'default');
|
||||
|
||||
return is_array($defaults) ? $defaults : [];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return $this->catalog;
|
||||
}
|
||||
|
||||
/** Divi version written into builderVersion of new blocks. */
|
||||
public function builderVersion(): string
|
||||
{
|
||||
return defined('ET_BUILDER_PRODUCT_VERSION') ? (string) ET_BUILDER_PRODUCT_VERSION : '5.2.1';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Schema;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||
|
||||
/**
|
||||
* Curated short form (spec §5.4) for the ~20 everyday modules: field => where Divi 5 keeps
|
||||
* the value. Paths come from Divi's conversion outlines (…/module-library/src/components/
|
||||
* <module>/conversion-outline.json, "*" = breakpoint/state) and are checked against the live
|
||||
* catalog by tests/Integration/divi-shortform-check.php.
|
||||
*
|
||||
* A field is ['path' => attribute path, 'sub' => key inside the desktop value or null,
|
||||
* 'kind' => value kind, 'description' => German help text], plus 'values' for enums,
|
||||
* 'keys' ([url, id, alt]) for media and 'with' (fixed extra sub values) where needed.
|
||||
* Kinds: text, html, code, url, media, enum, onoff, level, number, ids, color.
|
||||
*/
|
||||
final class ShortformMap
|
||||
{
|
||||
/** @var array<string, array{primary: ?string, child: ?string, fields: array<string, array<string, mixed>>}>|null */
|
||||
private static ?array $modules = null;
|
||||
|
||||
/** @return list<string> Short module names with a short form. */
|
||||
public static function types(): array
|
||||
{
|
||||
return array_keys(self::modules());
|
||||
}
|
||||
|
||||
/** $type is the short name ("button"). */
|
||||
public static function has(string $type): bool
|
||||
{
|
||||
return isset(self::modules()[$type]);
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>> */
|
||||
public static function fields(string $type): array
|
||||
{
|
||||
return self::modules()[$type]['fields'] ?? [];
|
||||
}
|
||||
|
||||
/** Short name of the item type a parent holds in "items" (accordion → accordion-item). */
|
||||
public static function childType(string $type): ?string
|
||||
{
|
||||
return self::modules()[$type]['child'] ?? null;
|
||||
}
|
||||
|
||||
/** Field whose emptiness makes the module "empty" (spec §5.5), null if none. */
|
||||
public static function primary(string $type): ?string
|
||||
{
|
||||
return self::modules()[$type]['primary'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current desktop values of the short-form fields (media fields are write-only and left out).
|
||||
*
|
||||
* @param array<string, mixed> $attrs
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function values(string $type, array $attrs): array
|
||||
{
|
||||
$values = [];
|
||||
foreach (self::fields($type) as $field => $spec) {
|
||||
if ($spec['kind'] === 'media') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = AttrMap::desktop($attrs, (string) $spec['path']);
|
||||
if ($spec['sub'] !== null) {
|
||||
$value = is_array($value) ? ($value[$spec['sub']] ?? null) : null;
|
||||
}
|
||||
|
||||
if ($value !== null) {
|
||||
$values[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra keys, values or with.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function field(
|
||||
string $path,
|
||||
?string $sub,
|
||||
string $kind,
|
||||
string $description,
|
||||
array $extra = []
|
||||
): array {
|
||||
return array_merge(['path' => $path, 'sub' => $sub, 'kind' => $kind, 'description' => $description], $extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $fields
|
||||
* @return array{primary: ?string, child: ?string, fields: array<string, array<string, mixed>>}
|
||||
*/
|
||||
private static function module(?string $primary, array $fields, ?string $child = null): array
|
||||
{
|
||||
return ['primary' => $primary, 'child' => $child, 'fields' => $fields];
|
||||
}
|
||||
|
||||
/** @return array<string, array{primary: ?string, child: ?string, fields: array<string, array<string, mixed>>}> */
|
||||
private static function modules(): array
|
||||
{
|
||||
if (self::$modules !== null) {
|
||||
return self::$modules;
|
||||
}
|
||||
|
||||
$title = self::field('title.innerContent', null, 'text', 'Titel');
|
||||
$content = self::field('content.innerContent', null, 'html', 'Inhalt (HTML erlaubt)');
|
||||
$open = self::field('module.advanced.open', null, 'onoff', 'geöffnet (true/false)');
|
||||
$level = self::field('title.decoration.font.font', 'headingLevel', 'level', 'Überschriftenebene h1–h6');
|
||||
$buttonText = self::field('button.innerContent', 'text', 'text', 'Button-Beschriftung');
|
||||
$buttonUrl = self::field('button.innerContent', 'linkUrl', 'url', 'Button-Linkziel');
|
||||
$media = static fn (string $path, array $keys, string $description, array $with = []): array => self::field(
|
||||
$path,
|
||||
null,
|
||||
'media',
|
||||
$description,
|
||||
$with === [] ? ['keys' => $keys] : ['keys' => $keys, 'with' => $with]
|
||||
);
|
||||
|
||||
self::$modules = [
|
||||
'text' => self::module('content', ['content' => $content]),
|
||||
'heading' => self::module('text', [
|
||||
'text' => self::field('title.innerContent', null, 'text', 'Überschrift'),
|
||||
'level' => $level,
|
||||
]),
|
||||
'button' => self::module('text', [
|
||||
'text' => self::field('button.innerContent', 'text', 'text', 'Beschriftung'),
|
||||
'url' => self::field('button.innerContent', 'linkUrl', 'url', 'Linkziel'),
|
||||
'new_window' => self::field('button.innerContent', 'linkTarget', 'onoff', 'in neuem Fenster öffnen'),
|
||||
]),
|
||||
'image' => self::module('src', [
|
||||
'image_id' => $media(
|
||||
'image.innerContent',
|
||||
['src', 'id', 'alt'],
|
||||
'Bild aus der Mediathek (Anhang-ID); setzt URL, ID und Alt-Text'
|
||||
),
|
||||
'src' => self::field('image.innerContent', 'src', 'url', 'Bild-URL'),
|
||||
'alt' => self::field('image.innerContent', 'alt', 'text', 'Alt-Text'),
|
||||
'title' => self::field('image.innerContent', 'titleText', 'text', 'Titel'),
|
||||
'url' => self::field('image.innerContent', 'linkUrl', 'url', 'Linkziel'),
|
||||
]),
|
||||
'blurb' => self::module('title', [
|
||||
'title' => self::field('title.innerContent', 'text', 'text', 'Titel'),
|
||||
'url' => self::field('title.innerContent', 'url', 'url', 'Link des Titels'),
|
||||
'content' => $content,
|
||||
'image_id' => $media(
|
||||
'imageIcon.innerContent',
|
||||
['src', null, 'alt'],
|
||||
'Bild aus der Mediathek (Anhang-ID)',
|
||||
['useIcon' => 'off']
|
||||
),
|
||||
'src' => self::field('imageIcon.innerContent', 'src', 'url', 'Bild-URL', [
|
||||
'with' => ['useIcon' => 'off'],
|
||||
]),
|
||||
'alt' => self::field('imageIcon.innerContent', 'alt', 'text', 'Alt-Text'),
|
||||
'level' => $level,
|
||||
]),
|
||||
'cta' => self::module('title', [
|
||||
'title' => $title,
|
||||
'content' => $content,
|
||||
'button_text' => $buttonText,
|
||||
'button_url' => $buttonUrl,
|
||||
]),
|
||||
'icon' => self::module(null, [
|
||||
'url' => self::field('icon.innerContent', 'url', 'url', 'Linkziel'),
|
||||
'title' => self::field('icon.innerContent', 'title', 'text', 'Titel'),
|
||||
]),
|
||||
'divider' => self::module(null, [
|
||||
'show' => self::field('divider.advanced.line', 'show', 'onoff', 'Linie anzeigen'),
|
||||
'color' => self::field('divider.advanced.line', 'color', 'color', 'Farbe, z. B. {{color:primary}}'),
|
||||
]),
|
||||
'testimonial' => self::module('content', [
|
||||
'author' => self::field('author.innerContent', null, 'text', 'Name'),
|
||||
'job_title' => self::field('jobTitle.innerContent', null, 'text', 'Funktion'),
|
||||
'company' => self::field('company.innerContent', 'text', 'text', 'Firma'),
|
||||
'company_url' => self::field('company.innerContent', 'linkUrl', 'url', 'Link der Firma'),
|
||||
'content' => $content,
|
||||
'portrait_id' => $media('portrait.innerContent', ['url', null, null], 'Porträt aus der Mediathek'),
|
||||
'portrait_url' => self::field('portrait.innerContent', 'url', 'url', 'Porträt-URL'),
|
||||
]),
|
||||
'team-member' => self::module('name', [
|
||||
'name' => self::field('name.innerContent', null, 'text', 'Name'),
|
||||
'position' => self::field('position.innerContent', null, 'text', 'Position'),
|
||||
'content' => $content,
|
||||
'image_id' => $media('image.innerContent', ['url', null, null], 'Foto aus der Mediathek'),
|
||||
'image_url' => self::field('image.innerContent', 'url', 'url', 'Foto-URL'),
|
||||
]),
|
||||
'accordion' => self::module(null, [], 'accordion-item'),
|
||||
'accordion-item' => self::module('title', ['title' => $title, 'content' => $content, 'open' => $open]),
|
||||
'tabs' => self::module(null, [], 'tab'),
|
||||
'tab' => self::module('title', ['title' => $title, 'content' => $content]),
|
||||
'toggle' => self::module('title', ['title' => $title, 'content' => $content, 'open' => $open]),
|
||||
'gallery' => self::module('image_ids', [
|
||||
'image_ids' => self::field('image.advanced.galleryIds', null, 'ids', 'Anhang-IDs, z. B. [12, 33]'),
|
||||
]),
|
||||
'slider' => self::module(null, [], 'slide'),
|
||||
'slide' => self::module('title', [
|
||||
'title' => $title,
|
||||
'content' => $content,
|
||||
'button_text' => $buttonText,
|
||||
'button_url' => $buttonUrl,
|
||||
'image_id' => $media('image.innerContent', ['src', null, 'alt'], 'Bild aus der Mediathek'),
|
||||
'src' => self::field('image.innerContent', 'src', 'url', 'Bild-URL'),
|
||||
'alt' => self::field('image.innerContent', 'alt', 'text', 'Alt-Text'),
|
||||
]),
|
||||
'contact-form' => self::module(null, [
|
||||
'title' => $title,
|
||||
'email' => self::field('email.advanced.receiver', null, 'text', 'Empfänger (leer = Admin-E-Mail)'),
|
||||
'success_message' => self::field('module.advanced.successMessage', null, 'text', 'Text nach Absenden'),
|
||||
'submit_text' => self::field('button.innerContent', 'text', 'text', 'Beschriftung Absenden'),
|
||||
], 'contact-field'),
|
||||
'contact-field' => self::module('title', [
|
||||
'title' => self::field('fieldItem.innerContent', null, 'text', 'Feldname'),
|
||||
'id' => self::field('fieldItem.advanced.id', null, 'text', 'Feld-ID'),
|
||||
'type' => self::field('fieldItem.advanced.type', null, 'enum', 'Feldart (text = mehrzeilig)', [
|
||||
'values' => ['input', 'email', 'text', 'checkbox', 'radio', 'select'],
|
||||
]),
|
||||
'required' => self::field('fieldItem.advanced.required', null, 'onoff', 'Pflichtfeld'),
|
||||
]),
|
||||
'map' => self::module('address', [
|
||||
'address' => self::field('map.innerContent', 'address', 'text', 'Adresse'),
|
||||
'lat' => self::field('map.innerContent', 'lat', 'number', 'Breitengrad'),
|
||||
'lng' => self::field('map.innerContent', 'lng', 'number', 'Längengrad'),
|
||||
'zoom' => self::field('map.innerContent', 'zoom', 'number', 'Zoomstufe'),
|
||||
], 'map-pin'),
|
||||
'map-pin' => self::module('title', [
|
||||
'title' => $title,
|
||||
'content' => $content,
|
||||
'address' => self::field('pin.innerContent', 'address', 'text', 'Adresse'),
|
||||
'lat' => self::field('pin.innerContent', 'lat', 'number', 'Breitengrad'),
|
||||
'lng' => self::field('pin.innerContent', 'lng', 'number', 'Längengrad'),
|
||||
]),
|
||||
'video' => self::module('src', [
|
||||
'src' => self::field('video.innerContent', 'src', 'url', 'Video-URL (YouTube, MP4)'),
|
||||
'thumbnail' => self::field('thumbnail.innerContent', 'src', 'url', 'Vorschaubild-URL'),
|
||||
]),
|
||||
'number-counter' => self::module('number', [
|
||||
'number' => self::field('number.innerContent', null, 'text', 'Zahl'),
|
||||
'title' => $title,
|
||||
'percent' => self::field('number.advanced.enablePercentSign', null, 'onoff', 'Prozentzeichen anzeigen'),
|
||||
]),
|
||||
'social-media-follow' => self::module(null, [], 'social-media-follow-network'),
|
||||
'social-media-follow-network' => self::module('url', [
|
||||
'network' => self::field(
|
||||
'socialNetwork.innerContent',
|
||||
'title',
|
||||
'text',
|
||||
'Netzwerk, z. B. facebook, instagram, linkedin, youtube'
|
||||
),
|
||||
'url' => self::field('socialNetwork.innerContent', 'link', 'url', 'Profil-URL'),
|
||||
'label' => self::field('socialNetwork.innerContent', 'label', 'text', 'Beschriftung'),
|
||||
]),
|
||||
'icon-list' => self::module(null, [], 'icon-list-item'),
|
||||
'icon-list-item' => self::module('text', [
|
||||
'text' => self::field('content.innerContent', null, 'text', 'Text'),
|
||||
]),
|
||||
'code' => self::module('content', [
|
||||
'content' => self::field('content.innerContent', null, 'code', 'HTML/Code – nur ohne passendes Modul'),
|
||||
]),
|
||||
];
|
||||
|
||||
return self::$modules;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user