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', ]; /** * 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. number-advanced (e.g. border * radius) holds an object of corner values and therefore stays unknown. A path that has * both a plain field and named sub-fields keeps only the sub-field map (the plain field * then counts as unknown). */ private const DYNAMIC_CONTENT_KINDS = [ 'text' => 'text', 'url' => 'url', 'image' => 'image', 'color' => 'color', 'number' => 'number', ]; private const ATTR_NAME_PATTERN = '/^[A-Za-z0-9]+(\.[A-Za-z0-9]+)+$/'; /** * @param array $attributes Metadata "attributes" of one module. * @param array $defaults Its default attributes. * @return list 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 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> */ public static function kinds(array $attributes): array { $candidates = []; foreach ($attributes as $definition) { $settings = is_array($definition['settings'] ?? null) ? $definition['settings'] : []; self::collectKinds($settings, $candidates); } $kinds = ['module.meta.adminLabel' => 'text']; 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; } } return $kinds; } /** * Up to $max known paths closest to $path (same attribute group first). * * @param list $known * @return list */ 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 $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 $settings * @param array $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); } } } /** * 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 */ private static function collectKinds(array $node, array &$candidates): void { $attrName = $node['attrName'] ?? null; $component = is_array($node['component'] ?? null) ? $node['component'] : []; $componentName = $component['name'] ?? null; if ( is_string($attrName) && preg_match(self::ATTR_NAME_PATTERN, $attrName) && ($component['type'] ?? null) === 'field' && is_string($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) { if (is_array($value)) { self::collectKinds($value, $candidates); } } } /** @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 { foreach (array_keys(AttrMap::flatten($attrs)) as $path) { $paths[$path] = true; } } }