* reads post meta (F25/F26). Relation/file fields show IDs, Pods table storage is no post meta, protected keys * never (S12), unknown types not (ruling P9) – each omitted with its reason. Choice fields are offered, but * Divi shows their stored value, not the label (stage3b render: "senior") – note(). Hidden fields (security * audit M4) are neither offered nor named. Used by lit/divi-dynamic-sources and the Theme Builder body check. * Not final so abilities can be tested with a mock. */ class DynamicFields { /** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */ public const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice']; public const ID_TYPES = ['relation', 'file']; public const UNKNOWN = 'kein bekanntes Feld dieses Inhaltstyps'; private const PREFIX = 'custom_meta_'; /** Divi's option for a meta key typed in by hand (the key is in settings.meta_key). */ private const MANUAL = 'custom_meta_manual_custom_field_value'; /** @var Closure(string): ?string */ private Closure $podStorage; /** @var array>, 1: list>}> offered() by type. */ private array $known = []; /** @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"), null: none. */ public function __construct(private ContentSchema $schema, ?Closure $podStorage = null) { $this->podStorage = $podStorage ?? self::defaultStorage(...); } /** @return array{0: list>, 1: list>} Offered and omitted fields. */ public function offered(string $postType): array { $storage = ($this->podStorage)($postType); $fields = []; $omitted = []; foreach ($this->schema->fields($postType) as $field) { if ($field->status === FieldDefinition::HIDDEN) { continue; } $entry = ['key' => $field->key, 'label' => $field->label]; $reason = self::omission($field, $storage); if ($reason !== null) { $omitted[] = $entry + ['reason' => $reason]; continue; } $fields[] = $entry + [ 'type' => $field->type, 'source' => $field->source, 'placeholder' => '{{field:' . $field->key . '}}', ]; } return [$fields, $omitted]; } /** * offered() once per post type for reason()/note() – they run per key and type of a check, and the default * storage callback loads the pod each time (review M-12 b). * * @return array{0: list>, 1: list>} */ private function known(string $postType): array { return $this->known[$postType] ??= $this->offered($postType); } /** Why {{field:$key}} would not show as text for $postType; null when it does. */ public function reason(string $postType, string $key): ?string { [$fields, $omitted] = $this->known($postType); foreach ($fields as $field) { if ($field['key'] === $key) { return null; } } foreach ($omitted as $field) { if ($field['key'] === $key) { return $field['reason']; } } return self::UNKNOWN; } /** What to know about an offered field of $postType ($key) that shows other text than expected; else null. */ public function note(string $postType, string $key): ?string { [$fields] = $this->known($postType); foreach ($fields as $field) { if ($field['key'] === $key && $field['type'] === 'choice') { return 'Auswahlfeld – Divi zeigt den gespeicherten Wert, nicht die Beschriftung'; } } return null; } /** * The meta key the content reference $value reads, in both forms Divi resolves (ruling Q11, stage3b README * finding 12; DynamicContentOptionPostMetaKey.php:222-262): the name custom_meta_ (the plugin's form) * and the name post_meta_key with select_meta_key "custom_meta_" (Divi's dialog), each also with a key * typed in by hand (custom_meta_manual_custom_field_value or post_meta_key with only meta_key). Null for * anything else. Reads, never validates – writing goes through VariableRef::canonical() (S29/S30). */ public static function metaKeyOf(string $value): ?string { if (VariableRef::typeOf($value) !== 'content') { return null; } $name = VariableRef::nameOf($value); $settings = VariableRef::settingsOf($value); $manual = is_string($settings['meta_key'] ?? null) ? $settings['meta_key'] : ''; if ($name === 'post_meta_key') { $selected = $settings['select_meta_key'] ?? ''; if (! is_string($selected)) { return null; } if ($selected === '') { return $manual !== '' ? $manual : null; } $name = $selected; } if ($name === null || ! str_starts_with($name, self::PREFIX)) { return null; } if ($name === self::MANUAL) { return $manual !== '' ? $manual : null; } $key = str_replace(self::PREFIX, '', $name); return $key !== '' ? $key : null; } private static function omission(FieldDefinition $field, ?string $storage): ?string { return match (true) { ! DynamicContentRules::isMetaKey($field->key) => 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)', $field->source === 'pods' && $storage === 'table' => 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta', in_array($field->type, self::ID_TYPES, true) => 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs', $field->type === 'unknown' => 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text', ! in_array($field->type, self::TEXT_TYPES, true) => sprintf('Feldtyp „%s“ ist nicht textartig', $field->type), default => null, }; } private static function defaultStorage(string $postType): ?string { if (! function_exists('pods_api')) { return null; } try { $pod = pods_api()->load_pod(['name' => $postType]); } catch (Throwable) { return null; } return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null; } }