feat: per-subName attribute-path kinds for composite Divi paths

lit/divi-catalog's path_kinds previously named one kind per whole attribute
path, built from Divi's metadata component name alone. For a composite path
(Divi groups several named sub-fields, "subName", under one path – e.g.
buttonOne.innerContent holds "text" and "linkUrl") that was wrong: the whole
path got one guessed kind (or none), even though the sub-fields can differ
(fullwidth-header's buttonOne/buttonTwo.innerContent -> "text", though it
also carries a "linkUrl"; image.innerContent -> "image", though the value
also carries alt/id/titleText the metadata never names on its own).

AttrPathIndex::kinds() now resolves a composite path to a map
subName => kind instead, preferring Divi's features.dynamicContent.type over
the (often shared/generic) field component for a named sub-field – this is
what tells a button's plain text sub-field apart from its linkUrl sub-field,
both of which use the same generic "divi/text" component. A plain (non-
composite) path is unaffected. Any subName the metadata never names (or that
disagrees between candidates) is therefore simply absent from the map, and
ModuleCatalog::pathKind() defaults it, like every unresolved path, to
"unknown" - the strictest routing for 2b's sanitizer, never a guess.

ModuleCatalog::pathKind() gains an optional $subName parameter to read a
composite path's map. docs/guides/divi-attrs.md documents the new shape and
that lit-divi-catalog without `module` lists all modules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 13:12:49 +02:00
co-authored by Claude Opus 5.5
parent 4137882008
commit 73433ae862
6 changed files with 179 additions and 36 deletions
+8 -5
View File
@@ -20,11 +20,14 @@ Breakpoints: `desktop`, `tablet`, `phone` (je nach Website auch `tabletWide`, `p
## Pfade finden ## Pfade finden
- `lit-divi-catalog` mit `module` (z. B. `"heading"`) listet alle gültigen Pfade dieses Moduls auf dieser - `lit-divi-catalog` ohne `module` listet alle Module dieser Website; mit `module` (z. B. `"heading"`) alle
Website (live aus Divi) und die Kurzform-Felder mit ihrem Pfad. `path_kinds` nennt die Feldart eines Pfads gültigen Pfade dieses Moduls auf dieser Website (live aus Divi) und die Kurzform-Felder mit ihrem Pfad.
(`text`, `richtext`, `code`, `url`, `image`, `color`, `enum`, `onoff`, `number`, `list`, `icon`), soweit aus `path_kinds` nennt die Feldart eines Pfads (`text`, `richtext`, `code`, `url`, `image`, `color`, `enum`,
den Divi-Metadaten ableitbar; zusammengesetzte Pfade (mehrere mögliche Feldarten je nach Unterschlüssel) `onoff`, `number`, `list`, `icon`), soweit aus den Divi-Metadaten ableitbar. Bei einem zusammengesetzten
gelten als unbekannt, statt eine Feldart zu raten. Pfad (mehrere benannte Unterfelder unter einem Pfad, z. B. `buttonOne.innerContent` mit `text` und
`linkUrl`) ist der Eintrag stattdessen eine Zuordnung Unterfeld → Feldart (`{"text": "text", "linkUrl":
"url"}`), soweit für die einzelnen Unterfelder ableitbar; fehlende Pfade oder Unterfelder gelten als
unbekannt, statt eine Feldart zu raten.
- `lit-divi-node-get` zeigt die gesetzten Werte eines vorhandenen Knotens in genau dieser Form (`attrs`). - `lit-divi-node-get` zeigt die gesetzten Werte eines vorhandenen Knotens in genau dieser Form (`attrs`).
- Schrift-Gruppen liegen eine Ebene tiefer: `…decoration.font.font`, `…decoration.bodyFont.body.font`, - Schrift-Gruppen liegen eine Ebene tiefer: `…decoration.font.font`, `…decoration.bodyFont.body.font`,
`…decoration.headingFont.h2.font`, `module.advanced.text.text`. `…decoration.headingFont.h2.font`, `module.advanced.text.text`.
+77 -14
View File
@@ -61,6 +61,25 @@ final class AttrPathIndex
'divi/icon-picker' => 'icon', 'divi/icon-picker' => 'icon',
]; ];
/**
* Divi's features.dynamicContent.type => value kind, for a composite path's named
* sub-field ("subName", e.g. buttonOne.innerContent's "linkUrl"): several sub-fields of
* one composite path often share one generic field component (e.g. "divi/text" for both
* a button's plain text and its link URL – see kinds()), but Divi's own dynamicContent
* type still tells them apart reliably. Only used for composite (subName-bearing)
* candidates; a plain path keeps trusting its field component alone (COMPONENT_KINDS),
* so an already more specific kind there (richtext, code, …) is never downgraded to the
* generic "text" dynamicContent commonly names for both.
*/
private const DYNAMIC_CONTENT_KINDS = [
'text' => 'text',
'url' => 'url',
'image' => 'image',
'color' => 'color',
'number' => 'number',
'number-advanced' => 'number',
];
private const ATTR_NAME_PATTERN = '/^[A-Za-z0-9]+(\.[A-Za-z0-9]+)+$/'; private const ATTR_NAME_PATTERN = '/^[A-Za-z0-9]+(\.[A-Za-z0-9]+)+$/';
/** /**
@@ -102,15 +121,23 @@ final class AttrPathIndex
/** /**
* Known attribute path => value kind (text/richtext/code/url/image/color/enum/onoff/number/ * Known attribute path => value kind (text/richtext/code/url/image/color/enum/onoff/number/
* list/icon), wherever Divi's metadata names exactly one unambiguous field component for the * list/icon), wherever Divi's metadata names exactly one unambiguous field component for
* path. A path is left out (callers default to "unknown") when Divi's own component is a * the whole path – OR, for a composite path (Divi groups several named sub-fields, each
* "group" (a composite of several sub-kinds, e.g. button.innerContent = text + link + target) * with its own "subName", under one attribute path – e.g. buttonOne.innerContent holds
* or when several distinct "field" components share the same attrName with different kinds * "text" and "linkUrl"), a map subName => kind for exactly the sub-fields the metadata
* (e.g. imageIcon.innerContent = onoff useIcon + icon icon + image src) – a wrong-but-present * names its own attrName+subName+field component for. Any other key actually present at
* kind would be more dangerous for 2b's sanitization routing than an honest "unknown". * runtime under a composite path (e.g. an image field's auto-synced "alt", never a
* metadata field of its own) is therefore never in that map – callers must default it to
* "unknown" (ModuleCatalog::pathKind()), never inherit the path's other sub-kind.
*
* A path (or one of its sub-fields) is left out entirely when Divi's own component is a
* "group" with no kind of its own, when its attrName is only named on a sibling "group"
* node rather than the field itself (button.innerContent's "text": no reliable way back to
* it), or when several candidates for the same (path, subName) disagree – a wrong-but-
* present kind would be more dangerous for 2b's sanitization routing than "unknown".
* *
* @param array<string, mixed> $attributes Metadata "attributes" of one module. * @param array<string, mixed> $attributes Metadata "attributes" of one module.
* @return array<string, string> * @return array<string, string|array<string, string>>
*/ */
public static function kinds(array $attributes): array public static function kinds(array $attributes): array
{ {
@@ -121,11 +148,31 @@ final class AttrPathIndex
} }
$kinds = ['module.meta.adminLabel' => 'text']; $kinds = ['module.meta.adminLabel' => 'text'];
foreach ($candidates as $attrName => $found) { foreach ($candidates as $attrName => $bySubName) {
$unique = array_unique($found); if (count($bySubName) === 1 && array_key_exists('', $bySubName)) {
$unique = array_unique($bySubName['']);
if (count($unique) === 1) { if (count($unique) === 1) {
$kinds[$attrName] = $unique[0]; $kinds[$attrName] = $unique[0];
} }
continue;
}
$map = [];
foreach ($bySubName as $subName => $found) {
if ($subName === '') {
continue;
}
$unique = array_unique($found);
if (count($unique) === 1) {
$map[$subName] = $unique[0];
}
}
if ($map !== []) {
$kinds[$attrName] = $map;
}
} }
return $kinds; return $kinds;
@@ -182,11 +229,13 @@ final class AttrPathIndex
} }
/** /**
* Collects every (attrName => kind) candidate found, without yet resolving conflicts – a * Collects every (attrName, subName-or-"") => kind candidate found, without yet resolving
* "group" component (a composite of several sub-kinds) never contributes a candidate. * conflicts – a "group" component (a composite of several sub-kinds with no kind of its
* own) never contributes a candidate, nor does a node with a subName but no attrName of
* its own (its attrName is only named on a sibling "group" node one level up).
* *
* @param array<array-key, mixed> $node * @param array<array-key, mixed> $node
* @param array<string, list<string>> $candidates * @param array<string, array<string, list<string>>> $candidates
*/ */
private static function collectKinds(array $node, array &$candidates): void private static function collectKinds(array $node, array &$candidates): void
{ {
@@ -198,9 +247,13 @@ final class AttrPathIndex
&& preg_match(self::ATTR_NAME_PATTERN, $attrName) && preg_match(self::ATTR_NAME_PATTERN, $attrName)
&& ($component['type'] ?? null) === 'field' && ($component['type'] ?? null) === 'field'
&& is_string($componentName) && is_string($componentName)
&& isset(self::COMPONENT_KINDS[$componentName])
) { ) {
$candidates[$attrName][] = self::COMPONENT_KINDS[$componentName]; $subName = is_string($node['subName'] ?? null) ? $node['subName'] : '';
$kind = $subName !== '' ? self::dynamicContentKind($node) : null;
$kind ??= self::COMPONENT_KINDS[$componentName] ?? null;
if ($kind !== null) {
$candidates[$attrName][$subName][] = $kind;
}
} }
foreach ($node as $value) { foreach ($node as $value) {
@@ -210,6 +263,16 @@ final class AttrPathIndex
} }
} }
/** @param array<array-key, mixed> $node */
private static function dynamicContentKind(array $node): ?string
{
$features = is_array($node['features'] ?? null) ? $node['features'] : [];
$dynamic = is_array($features['dynamicContent'] ?? null) ? $features['dynamicContent'] : [];
$type = $dynamic['type'] ?? null;
return is_string($type) ? (self::DYNAMIC_CONTENT_KINDS[$type] ?? null) : null;
}
/** @param array<string, mixed> $attrs */ /** @param array<string, mixed> $attrs */
private static function fromDefaults(array $attrs, array &$paths): void private static function fromDefaults(array $attrs, array &$paths): void
{ {
@@ -35,8 +35,9 @@ final class DiviCatalogAbility implements Ability
public function description(): string public function description(): string
{ {
return 'Listet die Divi-Module dieser Website (Name, Titel, Art, erlaubte Kind-Module) und welche eine ' 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 ' . 'Kurzform haben; ohne module alle Module. Mit module: Kurzform-Felder und alle gültigen '
. 'Vollform, je Pfad nach Möglichkeit mit Feldart (path_kinds).'; . 'Attributpfade des Moduls für die Vollform, je Pfad nach Möglichkeit mit Feldart (path_kinds), bei '
. 'zusammengesetzten Pfaden als Zuordnung Unterfeld => Feldart.';
} }
public function inputSchema(): array public function inputSchema(): array
@@ -83,7 +84,10 @@ final class DiviCatalogAbility implements Ability
'type' => 'object', 'type' => 'object',
'description' => 'Bekannter Attributpfad => Feldart (text, richtext, code, url, ' 'description' => 'Bekannter Attributpfad => Feldart (text, richtext, code, url, '
. 'image, color, enum, onoff, number, list, icon), soweit aus den Divi-Metadaten ' . 'image, color, enum, onoff, number, list, icon), soweit aus den Divi-Metadaten '
. 'ableitbar; fehlende Pfade gelten als unbekannt.', . 'ableitbar. Bei einem zusammengesetzten Pfad (mehrere benannte Unterfelder unter '
. 'einem Pfad, z. B. buttonOne.innerContent mit "text" und "linkUrl") ist der Wert '
. 'stattdessen eine Zuordnung Unterfeld => Feldart, soweit für die einzelnen '
. 'Unterfelder ableitbar. Fehlende Pfade oder Unterfelder gelten als unbekannt.',
], ],
], ],
'required' => ['name', 'title', 'category', 'children', 'shortform', 'paths', 'path_kinds'], 'required' => ['name', 'title', 'category', 'children', 'shortform', 'paths', 'path_kinds'],
+18 -6
View File
@@ -17,7 +17,7 @@ final class ModuleCatalog
/** @var array<string, list<string>> */ /** @var array<string, list<string>> */
private array $paths = []; private array $paths = [];
/** @var array<string, array<string, string>> */ /** @var array<string, array<string, string|array<string, string>>> */
private array $kinds = []; private array $kinds = [];
/** @var callable(string): array<string, mixed> */ /** @var callable(string): array<string, mixed> */
@@ -118,9 +118,12 @@ final class ModuleCatalog
/** /**
* Known attribute path => value kind for this module (spec §5.6, ruling R3), wherever * 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. * Divi's metadata names an unambiguous field component – or, for a composite path (several
* named sub-fields under one path, e.g. buttonOne.innerContent's "text"/"linkUrl"), a map
* subName => kind (see AttrPathIndex::kinds()). Not every known path, or every sub-field of
* a composite one, resolves.
* *
* @return array<string, string> * @return array<string, string|array<string, string>>
*/ */
public function kinds(string $name): array public function kinds(string $name): array
{ {
@@ -132,10 +135,19 @@ final class ModuleCatalog
return $this->kinds[$name]; 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 * Value kind of one path, or of one named sub-field ($subName) of a composite path;
* "unknown" if Divi's metadata does not resolve one (spec §5.6) – including a composite
* path asked about without a $subName, or for a sub-field the metadata never names.
*/
public function pathKind(string $name, string $path, ?string $subName = null): string
{ {
return $this->kinds($name)[$path] ?? 'unknown'; $kind = $this->kinds($name)[$path] ?? null;
if (is_string($kind)) {
return $kind;
}
return is_array($kind) && $subName !== null ? ($kind[$subName] ?? 'unknown') : 'unknown';
} }
/** @return list<array{name: string, title: string, category: string, children: list<string>}> */ /** @return list<array{name: string, title: string, category: string, children: list<string>}> */
@@ -60,6 +60,19 @@ final class DiviCatalogAbilityTest extends TestCase
self::assertArrayNotHasKey('button.innerContent', $button['path_kinds']); self::assertArrayNotHasKey('button.innerContent', $button['path_kinds']);
} }
/** A composite path (several named sub-fields under one attribute path) surfaces as a
* subName => kind map, not a single guessed string (spec §5.6, ruling F1 #1). */
public function testDetailExposesCompositePathKindsAsASubNameMap(): void
{
$header = $this->ability()->execute(['module' => 'divi/fullwidth-header'])['module'];
self::assertSame(
['text' => 'text', 'linkUrl' => 'url'],
$header['path_kinds']['buttonOne.innerContent']
);
self::assertSame(['src' => 'image'], $header['path_kinds']['image.innerContent']);
}
public function testUnknownModuleAndPermission(): void public function testUnknownModuleAndPermission(): void
{ {
self::assertSame('lit_not_found', $this->ability()->execute(['module' => 'slider-pro'])->get_error_code()); self::assertSame('lit_not_found', $this->ability()->execute(['module' => 'slider-pro'])->get_error_code());
@@ -157,14 +157,62 @@ final class ModuleCatalogTest extends TestCase
$catalog = self::sampleCatalog(); $catalog = self::sampleCatalog();
// button.innerContent's only attrName-annotated node is the "link" sub-group itself // button.innerContent's only attrName-annotated node is the "link" sub-group itself
// (component.type "group": link text + linkUrl + linkTarget) – not a single scalar kind. // (component.type "group": link text + linkUrl + linkTarget), and its "text" sub-field
// carries no attrName of its own – Divi's metadata gives no reliable subName => kind
// map here at all, so the whole path stays unknown (never guessed).
self::assertSame('unknown', $catalog->pathKind('divi/button', 'button.innerContent')); self::assertSame('unknown', $catalog->pathKind('divi/button', 'button.innerContent'));
self::assertArrayNotHasKey('button.innerContent', $catalog->kinds('divi/button'));
}
// imageIcon.innerContent carries three "field" components under the same attrName with /**
// different kinds (useIcon: onoff, icon: icon, src: image) – a genuine conflict. * Composite paths (Divi groups several named sub-fields, "subName", under one attribute
* path) resolve to a subName => kind map wherever the metadata reliably names each
* sub-field's own component (spec §5.6, ruling F1 #1) – asking without a subName, or for
* a subName the metadata never names (e.g. an image's auto-synced "alt"), stays unknown.
*/
public function testDerivesPerSubNameKindsForCompositePaths(): void
{
$catalog = self::sampleCatalog();
// imageIcon.innerContent (divi/blurb): useIcon (toggle), icon (icon-picker), src
// (upload, image) each carry attrName + subName + their own field component.
self::assertSame(
['useIcon' => 'onoff', 'icon' => 'icon', 'src' => 'image'],
$catalog->kinds('divi/blurb')['imageIcon.innerContent']
);
self::assertSame('onoff', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent', 'useIcon'));
self::assertSame('icon', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent', 'icon'));
self::assertSame('image', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent', 'src'));
// No subName given, or one Divi's metadata never names: unknown, never guessed.
self::assertSame('unknown', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent')); self::assertSame('unknown', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent'));
self::assertSame('unknown', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent', 'noSuchField'));
// fullwidth-header's scrollDown.decoration.icon is the same pattern (onoff/icon/color/number). // fullwidth-header's scrollDown.decoration.icon: show (toggle), color (color-picker),
self::assertSame('unknown', $catalog->pathKind('divi/fullwidth-header', 'scrollDown.decoration.icon')); // size (range) – same composite pattern one level deeper.
self::assertSame(
['show' => 'onoff', 'color' => 'color', 'size' => 'number'],
$catalog->kinds('divi/fullwidth-header')['scrollDown.decoration.icon']
);
// buttonOne/buttonTwo.innerContent: both sub-fields use the same generic "divi/text"
// field component, but "linkUrl" is a URL, not plain text – only Divi's own
// features.dynamicContent.type tells them apart.
foreach (['buttonOne', 'buttonTwo'] as $attrName) {
self::assertSame('text', $catalog->pathKind('divi/fullwidth-header', $attrName . '.innerContent', 'text'));
self::assertSame('url', $catalog->pathKind('divi/fullwidth-header', $attrName . '.innerContent', 'linkUrl'));
}
// image.innerContent only has an attrName+subName for "src" – the value actually
// carries more keys at runtime (alt, id, titleText, synced by the upload component),
// none of which Divi's metadata names on their own, so they stay unknown rather than
// inheriting "image".
self::assertSame(['src' => 'image'], $catalog->kinds('divi/fullwidth-header')['image.innerContent']);
self::assertSame('image', $catalog->pathKind('divi/fullwidth-header', 'image.innerContent', 'src'));
self::assertSame('unknown', $catalog->pathKind('divi/fullwidth-header', 'image.innerContent', 'alt'));
self::assertSame('unknown', $catalog->pathKind('divi/fullwidth-header', 'image.innerContent'));
// A plain (non-composite) path is unaffected: still a flat string, not a map.
self::assertSame('richtext', $catalog->pathKind('divi/text', 'content.innerContent'));
self::assertIsString($catalog->kinds('divi/text')['content.innerContent']);
} }
} }