From 73433ae8628ce2e6da81b27dd42f88107a24378f Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 13:10:11 +0200 Subject: [PATCH] feat: per-subName attribute-path kinds for composite Divi paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/guides/divi-attrs.md | 13 ++- .../Divi/Schema/AttrPathIndex.php | 97 +++++++++++++++---- .../Divi/Schema/DiviCatalogAbility.php | 10 +- .../Divi/Schema/ModuleCatalog.php | 24 +++-- .../Divi/Schema/DiviCatalogAbilityTest.php | 13 +++ .../Divi/Schema/ModuleCatalogTest.php | 58 ++++++++++- 6 files changed, 179 insertions(+), 36 deletions(-) diff --git a/docs/guides/divi-attrs.md b/docs/guides/divi-attrs.md index e4e5a8f..2f316eb 100644 --- a/docs/guides/divi-attrs.md +++ b/docs/guides/divi-attrs.md @@ -20,11 +20,14 @@ Breakpoints: `desktop`, `tablet`, `phone` (je nach Website auch `tabletWide`, `p ## Pfade finden -- `lit-divi-catalog` mit `module` (z. B. `"heading"`) listet alle gültigen Pfade dieses Moduls auf dieser - Website (live aus Divi) und die Kurzform-Felder mit ihrem Pfad. `path_kinds` nennt die Feldart eines Pfads - (`text`, `richtext`, `code`, `url`, `image`, `color`, `enum`, `onoff`, `number`, `list`, `icon`), soweit aus - den Divi-Metadaten ableitbar; zusammengesetzte Pfade (mehrere mögliche Feldarten je nach Unterschlüssel) - gelten als unbekannt, statt eine Feldart zu raten. +- `lit-divi-catalog` ohne `module` listet alle Module dieser Website; mit `module` (z. B. `"heading"`) alle + gültigen Pfade dieses Moduls auf dieser Website (live aus Divi) und die Kurzform-Felder mit ihrem Pfad. + `path_kinds` nennt die Feldart eines Pfads (`text`, `richtext`, `code`, `url`, `image`, `color`, `enum`, + `onoff`, `number`, `list`, `icon`), soweit aus den Divi-Metadaten ableitbar. Bei einem zusammengesetzten + 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`). - Schrift-Gruppen liegen eine Ebene tiefer: `…decoration.font.font`, `…decoration.bodyFont.body.font`, `…decoration.headingFont.h2.font`, `module.advanced.text.text`. diff --git a/src/Integrations/Divi/Schema/AttrPathIndex.php b/src/Integrations/Divi/Schema/AttrPathIndex.php index 896e7a3..573a1c7 100644 --- a/src/Integrations/Divi/Schema/AttrPathIndex.php +++ b/src/Integrations/Divi/Schema/AttrPathIndex.php @@ -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 $attributes Metadata "attributes" of one module. - * @return array + * @return array> */ 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 $node - * @param array> $candidates + * @param array $node + * @param array>> $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 $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 $attrs */ private static function fromDefaults(array $attrs, array &$paths): void { diff --git a/src/Integrations/Divi/Schema/DiviCatalogAbility.php b/src/Integrations/Divi/Schema/DiviCatalogAbility.php index 263a725..fe6e640 100644 --- a/src/Integrations/Divi/Schema/DiviCatalogAbility.php +++ b/src/Integrations/Divi/Schema/DiviCatalogAbility.php @@ -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'], diff --git a/src/Integrations/Divi/Schema/ModuleCatalog.php b/src/Integrations/Divi/Schema/ModuleCatalog.php index d94066b..f4620b6 100644 --- a/src/Integrations/Divi/Schema/ModuleCatalog.php +++ b/src/Integrations/Divi/Schema/ModuleCatalog.php @@ -17,7 +17,7 @@ final class ModuleCatalog /** @var array> */ private array $paths = []; - /** @var array> */ + /** @var array>> */ private array $kinds = []; /** @var callable(string): array */ @@ -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 + * @return array> */ 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}> */ diff --git a/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php b/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php index ebcb333..2367d7c 100644 --- a/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php @@ -60,6 +60,19 @@ final class DiviCatalogAbilityTest extends TestCase 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 { self::assertSame('lit_not_found', $this->ability()->execute(['module' => 'slider-pro'])->get_error_code()); diff --git a/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php b/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php index 2e5e2d0..4d015c7 100644 --- a/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php +++ b/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php @@ -157,14 +157,62 @@ final class ModuleCatalogTest extends TestCase $catalog = self::sampleCatalog(); // 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::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', 'noSuchField')); - // fullwidth-header's scrollDown.decoration.icon is the same pattern (onoff/icon/color/number). - self::assertSame('unknown', $catalog->pathKind('divi/fullwidth-header', 'scrollDown.decoration.icon')); + // fullwidth-header's scrollDown.decoration.icon: show (toggle), color (color-picker), + // 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']); } }