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:
@@ -61,6 +61,25 @@ final class AttrPathIndex
|
||||
'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]+)+$/';
|
||||
|
||||
/**
|
||||
@@ -102,15 +121,23 @@ final class AttrPathIndex
|
||||
|
||||
/**
|
||||
* 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
|
||||
* path. A path is left out (callers default to "unknown") when Divi's own component is a
|
||||
* "group" (a composite of several sub-kinds, e.g. button.innerContent = text + link + target)
|
||||
* or when several distinct "field" components share the same attrName with different kinds
|
||||
* (e.g. imageIcon.innerContent = onoff useIcon + icon icon + image src) – a wrong-but-present
|
||||
* kind would be more dangerous for 2b's sanitization routing than an honest "unknown".
|
||||
* list/icon), wherever Divi's metadata names exactly one unambiguous field component for
|
||||
* the whole path – OR, for a composite path (Divi groups several named sub-fields, each
|
||||
* with its own "subName", under one attribute path – e.g. buttonOne.innerContent holds
|
||||
* "text" and "linkUrl"), a map subName => kind for exactly the sub-fields the metadata
|
||||
* names its own attrName+subName+field component for. Any other key actually present at
|
||||
* 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.
|
||||
* @return array<string, string>
|
||||
* @return array<string, string|array<string, string>>
|
||||
*/
|
||||
public static function kinds(array $attributes): array
|
||||
{
|
||||
@@ -121,10 +148,30 @@ final class AttrPathIndex
|
||||
}
|
||||
|
||||
$kinds = ['module.meta.adminLabel' => 'text'];
|
||||
foreach ($candidates as $attrName => $found) {
|
||||
$unique = array_unique($found);
|
||||
if (count($unique) === 1) {
|
||||
$kinds[$attrName] = $unique[0];
|
||||
foreach ($candidates as $attrName => $bySubName) {
|
||||
if (count($bySubName) === 1 && array_key_exists('', $bySubName)) {
|
||||
$unique = array_unique($bySubName['']);
|
||||
if (count($unique) === 1) {
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,11 +229,13 @@ final class AttrPathIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects every (attrName => kind) candidate found, without yet resolving conflicts – a
|
||||
* "group" component (a composite of several sub-kinds) never contributes a candidate.
|
||||
* Collects every (attrName, subName-or-"") => kind candidate found, without yet resolving
|
||||
* 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<string, list<string>> $candidates
|
||||
* @param array<array-key, mixed> $node
|
||||
* @param array<string, array<string, list<string>>> $candidates
|
||||
*/
|
||||
private static function collectKinds(array $node, array &$candidates): void
|
||||
{
|
||||
@@ -198,9 +247,13 @@ final class AttrPathIndex
|
||||
&& preg_match(self::ATTR_NAME_PATTERN, $attrName)
|
||||
&& ($component['type'] ?? null) === 'field'
|
||||
&& 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) {
|
||||
@@ -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 */
|
||||
private static function fromDefaults(array $attrs, array &$paths): void
|
||||
{
|
||||
|
||||
@@ -35,8 +35,9 @@ final class DiviCatalogAbility implements Ability
|
||||
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).';
|
||||
. 'Kurzform haben; ohne module alle Module. Mit module: Kurzform-Felder und alle gültigen '
|
||||
. '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
|
||||
@@ -83,7 +84,10 @@ final class DiviCatalogAbility implements Ability
|
||||
'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.',
|
||||
. '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'],
|
||||
|
||||
@@ -17,7 +17,7 @@ final class ModuleCatalog
|
||||
/** @var array<string, list<string>> */
|
||||
private array $paths = [];
|
||||
|
||||
/** @var array<string, array<string, string>> */
|
||||
/** @var array<string, array<string, string|array<string, string>>> */
|
||||
private array $kinds = [];
|
||||
|
||||
/** @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
|
||||
* 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
|
||||
{
|
||||
@@ -132,10 +135,19 @@ final class ModuleCatalog
|
||||
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>}> */
|
||||
|
||||
Reference in New Issue
Block a user