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
+41
View File
@@ -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;
}
}