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:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Writes tests/Fixtures/divi/metadata-sample.json: Divi's own module metadata (name, title,
|
||||
* category, children, attribute settings) and default attributes for the modules the unit
|
||||
* tests use, so ModuleCatalog is tested against real Divi data without WordPress.
|
||||
* Usage (DDEV, WordPress root): wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-metadata-fixture.php
|
||||
* No `declare(strict_types=1);` – wp eval-file runs the file through eval().
|
||||
*/
|
||||
|
||||
defined('WP_CLI') && WP_CLI || exit;
|
||||
|
||||
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
|
||||
|
||||
if (! class_exists(ModuleRegistration::class)) {
|
||||
WP_CLI::error('Divi 5 is not active.');
|
||||
}
|
||||
|
||||
$wanted = [
|
||||
'section', 'row', 'column', 'text', 'heading', 'button', 'image', 'blurb', 'code', 'accordion',
|
||||
'accordion-item', 'fullwidth-header', 'group',
|
||||
];
|
||||
$all = ModuleRegistration::get_all_core_modules_metadata();
|
||||
$metadata = [];
|
||||
$defaults = [];
|
||||
foreach ($wanted as $key) {
|
||||
$module = $all[$key] ?? null;
|
||||
if (! is_array($module)) {
|
||||
WP_CLI::error('Missing module ' . $key);
|
||||
}
|
||||
|
||||
$attributes = [];
|
||||
foreach ((array) ($module['attributes'] ?? []) as $attrName => $definition) {
|
||||
$attributes[$attrName] = ['settings' => $definition['settings'] ?? []];
|
||||
}
|
||||
|
||||
$metadata[] = array_filter([
|
||||
'name' => $module['name'],
|
||||
'title' => $module['title'] ?? '',
|
||||
'category' => $module['category'] ?? '',
|
||||
'childrenName' => $module['childrenName'] ?? null,
|
||||
'childModuleName' => $module['childModuleName'] ?? null,
|
||||
'attributes' => $attributes,
|
||||
], static fn ($value): bool => $value !== null);
|
||||
$defaults[$module['name']] = ModuleRegistration::get_default_attrs($module['name'], 'default');
|
||||
}
|
||||
|
||||
$file = dirname(__DIR__) . '/Fixtures/divi/metadata-sample.json';
|
||||
$json = wp_json_encode(
|
||||
['divi' => ET_BUILDER_PRODUCT_VERSION, 'metadata' => $metadata, 'defaults' => $defaults],
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
if (! is_string($json) || file_put_contents($file, $json . "\n") === false) {
|
||||
WP_CLI::error('Cannot write ' . $file);
|
||||
}
|
||||
WP_CLI::success(sprintf('metadata-sample.json: %d modules, %d bytes', count($metadata), strlen($json)));
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Checks the curated short form against the live Divi catalog: every module exists, every
|
||||
* field path is a known attribute path, every item type is an allowed child.
|
||||
* Usage (DDEV, WordPress root): wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-shortform-check.php
|
||||
* No `declare(strict_types=1);` – wp eval-file runs the file through eval().
|
||||
*/
|
||||
|
||||
defined('WP_CLI') && WP_CLI || exit;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
|
||||
|
||||
if (! DiviModules::isAvailable()) {
|
||||
WP_CLI::error('Divi 5 is not active.');
|
||||
}
|
||||
|
||||
$started = microtime(true);
|
||||
$catalog = (new DiviModules())->catalog();
|
||||
$problems = [];
|
||||
foreach (ShortformMap::types() as $type) {
|
||||
$name = ModuleCatalog::normalize($type);
|
||||
if (! $catalog->has($name)) {
|
||||
$problems[] = $name . ': unknown module';
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (ShortformMap::fields($type) as $field => $spec) {
|
||||
if (! $catalog->isKnownPath($name, (string) $spec['path'])) {
|
||||
$problems[] = sprintf(
|
||||
'%s.%s: unknown path %s (closest: %s)',
|
||||
$type,
|
||||
$field,
|
||||
$spec['path'],
|
||||
implode(', ', $catalog->suggestions($name, (string) $spec['path']))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$child = ShortformMap::childType($type);
|
||||
if ($child !== null && ! in_array(ModuleCatalog::normalize($child), $catalog->childrenOf($name), true)) {
|
||||
$problems[] = sprintf('%s: %s is no allowed child (%s)', $type, $child, implode(', ', $catalog->childrenOf($name)));
|
||||
}
|
||||
}
|
||||
|
||||
if ($problems !== []) {
|
||||
WP_CLI::error("Short form does not match the catalog:\n" . implode("\n", $problems));
|
||||
}
|
||||
|
||||
WP_CLI::success(sprintf(
|
||||
'%d short-form modules match the catalog of %d modules (%.0f ms).',
|
||||
count(ShortformMap::types()),
|
||||
count($catalog->names()),
|
||||
(microtime(true) - $started) * 1000
|
||||
));
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DiviIntegrationTest extends TestCase
|
||||
{
|
||||
/** Grows with every task that adds Divi abilities – keep in the order of abilities(). */
|
||||
private const EXPECTED = [
|
||||
'lit/divi-catalog',
|
||||
];
|
||||
|
||||
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void
|
||||
{
|
||||
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class));
|
||||
|
||||
self::assertSame('divi', $integration->id());
|
||||
self::assertFalse($integration->isAvailable(), 'no Divi classes in unit tests');
|
||||
self::assertSame(
|
||||
self::EXPECTED,
|
||||
array_map(static fn (Ability $ability): string => $ability->name(), $integration->abilities())
|
||||
);
|
||||
|
||||
foreach ($integration->abilities() as $ability) {
|
||||
self::assertSame('object', $ability->inputSchema()['type'], $ability->name());
|
||||
self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name());
|
||||
self::assertNotNull($ability->outputSchema(), $ability->name());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Schema;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DiviCatalogAbilityTest extends TestCase
|
||||
{
|
||||
private function ability(): DiviCatalogAbility
|
||||
{
|
||||
$modules = Mockery::mock(DiviModules::class);
|
||||
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
|
||||
|
||||
return new DiviCatalogAbility($modules);
|
||||
}
|
||||
|
||||
public function testListsModulesWithShortformFlag(): void
|
||||
{
|
||||
$result = $this->ability()->execute([]);
|
||||
|
||||
$button = array_values(array_filter($result['modules'], static fn (array $m): bool => $m['name'] === 'divi/button'));
|
||||
self::assertSame(
|
||||
[['name' => 'divi/button', 'title' => 'Button', 'category' => 'module', 'children' => [], 'shortform' => true]],
|
||||
$button
|
||||
);
|
||||
$header = array_values(array_filter(
|
||||
$result['modules'],
|
||||
static fn (array $m): bool => $m['name'] === 'divi/fullwidth-header'
|
||||
));
|
||||
self::assertFalse($header[0]['shortform']);
|
||||
self::assertSame(DiviCatalogAbility::CODE_NOTE, $result['notes'][0]);
|
||||
}
|
||||
|
||||
public function testDetailOfOneModule(): void
|
||||
{
|
||||
$result = $this->ability()->execute(['module' => 'Accordion']);
|
||||
|
||||
self::assertSame('divi/accordion', $result['module']['name']);
|
||||
self::assertSame(['divi/accordion-item'], $result['module']['children']);
|
||||
self::assertSame('accordion-item', $result['module']['item_type']);
|
||||
self::assertContains('title.decoration.font.font', $result['module']['paths']);
|
||||
|
||||
$button = $this->ability()->execute(['module' => 'divi/button'])['module'];
|
||||
self::assertSame('linkUrl', $button['shortform']['url']['sub']);
|
||||
}
|
||||
|
||||
public function testDetailExposesTheAttributePathKindsForSanitizationRouting(): void
|
||||
{
|
||||
$button = $this->ability()->execute(['module' => 'divi/button'])['module'];
|
||||
|
||||
self::assertSame('url', $button['path_kinds']['button.innerContent']);
|
||||
self::assertArrayNotHasKey('no.such.path', $button['path_kinds']);
|
||||
}
|
||||
|
||||
public function testUnknownModuleAndPermission(): void
|
||||
{
|
||||
self::assertSame('lit_not_found', $this->ability()->execute(['module' => 'slider-pro'])->get_error_code());
|
||||
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
self::assertSame('lit_forbidden', $this->ability()->permission([])->get_error_code());
|
||||
self::assertTrue($this->ability()->annotations()['readonly']);
|
||||
self::assertTrue($this->ability()->isDirect());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Schema;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Schema\AttrPathIndex;
|
||||
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ModuleCatalogTest extends TestCase
|
||||
{
|
||||
/** Real Divi 5.2.1 metadata (tests/Integration/divi-metadata-fixture.php). */
|
||||
public static function sampleCatalog(): ModuleCatalog
|
||||
{
|
||||
$sample = json_decode(
|
||||
(string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/metadata-sample.json'),
|
||||
true
|
||||
);
|
||||
|
||||
return new ModuleCatalog(
|
||||
$sample['metadata'],
|
||||
static fn (string $name): array => (array) ($sample['defaults'][$name] ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
public function testKnowsModulesTitlesCategoriesAndChildren(): void
|
||||
{
|
||||
$catalog = self::sampleCatalog();
|
||||
|
||||
self::assertTrue($catalog->has('divi/button'));
|
||||
self::assertFalse($catalog->has('button'));
|
||||
self::assertSame('divi/button', ModuleCatalog::normalize(' Button '));
|
||||
self::assertSame('divi/accordion-item', ModuleCatalog::normalize('divi/accordion-item'));
|
||||
self::assertSame('structure', $catalog->category('divi/section'));
|
||||
self::assertSame(['divi/accordion-item'], $catalog->childrenOf('divi/accordion'));
|
||||
self::assertSame(['divi/column'], $catalog->childrenOf('divi/row'));
|
||||
self::assertContains(
|
||||
['name' => 'divi/heading', 'title' => 'Heading', 'category' => 'module', 'children' => []],
|
||||
$catalog->summary()
|
||||
);
|
||||
}
|
||||
|
||||
public function testDerivesAttributePathsFromSettingsAndDefaults(): void
|
||||
{
|
||||
$catalog = self::sampleCatalog();
|
||||
|
||||
foreach (
|
||||
[
|
||||
'button.innerContent', 'button.decoration.button', 'button.decoration.background',
|
||||
'module.advanced.alignment', 'module.decoration.spacing', 'module.meta.adminLabel',
|
||||
'module.advanced.text.text',
|
||||
] as $path
|
||||
) {
|
||||
self::assertTrue($catalog->isKnownPath('divi/button', $path), $path);
|
||||
}
|
||||
|
||||
self::assertTrue($catalog->isKnownPath('divi/heading', 'title.decoration.font.font'));
|
||||
self::assertFalse($catalog->isKnownPath('divi/heading', 'title.decoration.font'));
|
||||
self::assertTrue($catalog->isKnownPath('divi/text', 'content.decoration.bodyFont.body.font'));
|
||||
self::assertTrue($catalog->isKnownPath('divi/accordion-item', 'module.advanced.open'));
|
||||
self::assertTrue($catalog->isKnownPath('divi/column', 'module.decoration.sizing'));
|
||||
self::assertFalse($catalog->isKnownPath('divi/button', 'button.innerContent.text'));
|
||||
self::assertSame('title.decoration.font.font', $catalog->suggestions('divi/heading', 'title.decoration.fnt.font')[0]);
|
||||
}
|
||||
|
||||
public function testPathIndexFromHandwrittenMetadata(): void
|
||||
{
|
||||
$paths = AttrPathIndex::paths(
|
||||
[
|
||||
'title' => ['settings' => [
|
||||
'innerContent' => ['groupType' => 'group-item', 'item' => ['attrName' => 'title.innerContent']],
|
||||
'decoration' => ['font' => [], 'groupType' => 'x'],
|
||||
]],
|
||||
'module' => ['settings' => [
|
||||
'meta' => ['meta' => []],
|
||||
'advanced' => ['text' => ['item' => ['attrName' => 'module.advanced.alignment']]],
|
||||
]],
|
||||
],
|
||||
['module' => ['advanced' => ['open' => ['desktop' => ['value' => 'off']]]], 'builderVersion' => '5']
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
'module.advanced.alignment', 'module.advanced.open', 'module.advanced.text.text',
|
||||
'module.advanced.text.textShadow', 'module.meta.adminLabel', 'title.decoration.font.font',
|
||||
'title.decoration.font.textShadow', 'title.innerContent',
|
||||
],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
|
||||
public function testEveryShortformPathIsKnownForTheSampledModules(): void
|
||||
{
|
||||
$catalog = self::sampleCatalog();
|
||||
foreach (['text', 'heading', 'button', 'image', 'blurb', 'code', 'accordion-item'] as $type) {
|
||||
foreach (ShortformMap::fields($type) as $field => $spec) {
|
||||
self::assertTrue(
|
||||
$catalog->isKnownPath('divi/' . $type, $spec['path']),
|
||||
sprintf('%s.%s → %s', $type, $field, $spec['path'])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testShortformTableIsConsistent(): void
|
||||
{
|
||||
$kinds = ['text', 'html', 'code', 'url', 'media', 'enum', 'onoff', 'level', 'number', 'ids', 'color'];
|
||||
foreach (ShortformMap::types() as $type) {
|
||||
$fields = ShortformMap::fields($type);
|
||||
$primary = ShortformMap::primary($type);
|
||||
self::assertTrue($primary === null || isset($fields[$primary]), $type . ' primary');
|
||||
$child = ShortformMap::childType($type);
|
||||
self::assertTrue($child === null || ShortformMap::has($child), $type . ' child');
|
||||
foreach ($fields as $field => $spec) {
|
||||
self::assertContains($spec['kind'], $kinds, $type . '.' . $field);
|
||||
self::assertSame(1, preg_match('/^[a-zA-Z]+(\.[a-zA-Z]+)+$/', $spec['path']), $type . '.' . $field);
|
||||
self::assertSame($spec['kind'] === 'media', isset($spec['keys']), $type . '.' . $field);
|
||||
self::assertSame($spec['kind'] === 'enum', isset($spec['values']), $type . '.' . $field);
|
||||
}
|
||||
}
|
||||
|
||||
self::assertSame(
|
||||
['text' => 'Termin', 'url' => '/kontakt'],
|
||||
ShortformMap::values('button', ['button' => ['innerContent' => ['desktop' => ['value' => [
|
||||
'text' => 'Termin', 'linkUrl' => '/kontakt',
|
||||
]]]]])
|
||||
);
|
||||
}
|
||||
|
||||
public function testDerivesAttributePathKindsFromComponentMetadataWherePossible(): void
|
||||
{
|
||||
$catalog = self::sampleCatalog();
|
||||
|
||||
self::assertSame('richtext', $catalog->pathKind('divi/text', 'content.innerContent'));
|
||||
self::assertSame('text', $catalog->pathKind('divi/heading', 'title.innerContent'));
|
||||
self::assertSame('code', $catalog->pathKind('divi/code', 'content.innerContent'));
|
||||
self::assertSame('url', $catalog->pathKind('divi/button', 'button.innerContent'));
|
||||
self::assertSame('text', $catalog->pathKind('divi/button', 'module.meta.adminLabel'));
|
||||
// Structural/style groups (fonts, backgrounds, …) carry no single scalar component in
|
||||
// Divi's metadata; 2b falls back to key-name routing for them (spec §5.6, ruling R3).
|
||||
self::assertSame('unknown', $catalog->pathKind('divi/button', 'button.decoration.background'));
|
||||
self::assertSame('unknown', $catalog->pathKind('divi/button', 'no.such.path'));
|
||||
|
||||
// Every kind in the module's kinds() map is a string, keyed by a known attribute path.
|
||||
$paths = $catalog->paths('divi/heading');
|
||||
foreach ($catalog->kinds('divi/heading') as $path => $kind) {
|
||||
self::assertIsString($kind);
|
||||
self::assertContains($path, $paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user