with META_KEY (no leading "_", ruling S12; settings from the map entry * of that exact name, else META_SETTINGS); or a key of the dynamic content map. * * Dynamic content map (ruling S22), constructor argument $map: * array> option name => [setting key => field type] * e.g. ['post_title' => ['before' => 'text', 'after' => 'text'], 'post_date' => [… 'date_format' => 'select']] * Field types: "text" → ValueSanitizer::text(); "richtext" or "html" → html(); "url" → url() * (refused when it empties a value); "select_post" → a numeric id of a post the user may read; * "select", "yes_no_button", "number" → a scalar without markup ("<", ">"), strings cleaned with * text(); any other type is refused (the map comes from a filter – untrusted, ruling S37). * Default (null): DEFAULT_DYNAMIC_CONTENT. Task 5 builds the map from Divi's live registry * DynamicContentOptions::get_options($post_id, 'edit') – option name => its "fields" (key => * field "type"). Always, whatever the map says: "enable_html" is rejected (VariableRef), loop_* * names only when the map names them, custom_meta_ names and the values of settings whose * key ends in "meta_key" (meta_key, select_meta_key, loop_meta_key …) obey META_KEY after Divi's * own prefix stripping (rulings S29, S30), and no string – before or after cleaning (ruling S28) * – may contain a reference or ")$". A map of another shape fails in the constructor with * InvalidArgumentException. */ final class DynamicContentRules { /** custom_meta_: no leading "_" – Divi renders protected meta publicly (ruling S12). */ public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/D'; /** @var array> Placeholders::POST values and their settings. */ public const DEFAULT_DYNAMIC_CONTENT = [ 'post_title' => self::BEFORE_AFTER, 'post_excerpt' => self::BEFORE_AFTER, 'post_date' => self::BEFORE_AFTER + self::DATE, 'post_modified_date' => self::BEFORE_AFTER + self::DATE, 'post_author' => self::BEFORE_AFTER, 'post_link_url' => [], 'post_featured_image' => [], 'post_categories' => self::BEFORE_AFTER, 'post_tags' => self::BEFORE_AFTER, ]; /** Settings of custom_meta_ without a map entry of its own. */ public const META_SETTINGS = self::BEFORE_AFTER; /** * Settings of content references to global variables and theme fonts: none (ruling S27). * Divi ignores them: theme fonts resolve to var() (DynamicData.php:300), and the gvid * option "doesn't have any settings" and renders without them * (DynamicContentGlobalVariableOptions.php:66-69, 155-161). */ public const DESIGN_SETTINGS = []; private const BEFORE_AFTER = ['before' => 'richtext', 'after' => 'richtext']; private const DATE = ['date_format' => 'select', 'custom_date_format' => 'text']; private const META_PREFIX = 'custom_meta_'; /** * Prefixes Divi strips from a meta key option with str_replace – every occurrence, not just * the leading one (DynamicContentOptionPostMetaKey.php:232,249,275,286; * DynamicContentOptionLoopPostMetaKey.php:366-373). Ruling S30. */ private const META_KEY_PREFIXES = [ self::META_PREFIX, 'loop_post_meta_key_', 'loop_user_meta_key_', 'loop_term_meta_key_', ]; /** @var array> */ private array $map; /** * @param array|null $map See the class docblock. * @throws InvalidArgumentException When the map does not have that shape. */ public function __construct(private DesignSystem $design, ?array $map = null) { $this->map = $map === null ? self::DEFAULT_DYNAMIC_CONTENT : self::checkMap($map); } /** * @return array Setting key => field type allowed for the content name. * @throws InvalidArgumentException When the name is not allowed. */ public function fieldsFor(string $name): array { // Ruling S29: the meta key rule holds for map entries too. if (str_starts_with($name, self::META_PREFIX)) { if (! self::isMetaKey($name)) { throw VariableRef::unknownName($name, 'content'); } return $this->map[$name] ?? self::META_SETTINGS; } if (isset($this->map[$name])) { return $this->map[$name]; } if (str_starts_with($name, 'loop_')) { throw new InvalidArgumentException(sprintf( 'Schleifen-Inhalt "%s" ist hier nicht verfügbar (nur innerhalb von Divi-Schleifen).', $name )); } if (in_array($name, $this->design->contentVariableIds(), true)) { return self::DESIGN_SETTINGS; } throw VariableRef::unknownName($name, 'content'); } /** * Cleans one content setting by its field type (rulings S21/S22). The steps, in this order: * 1. control characters off the RAW value (S32/S32a); * 2. the raw value must not contain a reference marker or ")$" (S2); * 3. the field type's check and sanitizer (textSetting / selectPostSetting / scalarSetting); * 4. the final string must not contain a marker, ")$" or control characters (S28) – only rejection; * 5. settings named …meta_key must name an unprotected meta key (S29/S30). * * @throws InvalidArgumentException */ public static function cleanSetting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer): mixed { // Ruling S32/S32a: control characters go on the RAW value, before every check and the // sanitizer ("<" + VT stays a literal "<" for strip_tags; a later strip would build a tag). if (is_string($value)) { $value = ControlChars::strip($value); } if (is_string($value) && (VariableRef::contains($value) || str_contains($value, VariableRef::END))) { throw VariableRef::notAlone(); } $clean = match ($fieldType) { 'text', 'richtext', 'html', 'url' => self::textSetting($key, $value, $fieldType, $sanitizer), 'select_post' => self::selectPostSetting($key, $value, $sanitizer), 'select', 'yes_no_button', 'number' => self::scalarSetting($key, $value, $sanitizer), // Ruling S37: map types come from a filter (untrusted) – unknown types are refused. default => throw new InvalidArgumentException(sprintf( 'Einstellung "%s" im Divi-Verweis: Feldtyp "%s" wird nicht unterstützt.', $key, $fieldType )), }; // Ruling S28: sanitizing may rebuild a marker ("$variable(" → "$variable("). After the // sanitizer the only action is rejection (S32a) – controls went on the raw value above. if ( is_string($clean) && ( VariableRef::contains($clean) || str_contains($clean, VariableRef::END) || ControlChars::strip($clean) !== $clean ) ) { throw VariableRef::notAlone(); } // Ruling S29: meta key settings (post_meta_key's meta_key/select_meta_key, loop_meta_key …) // name a meta key – never a protected one. Matched by the key SUFFIX "meta_key" (S29/S30): // re-check Divi's dynamic content options for new meta key settings on every Divi version bump. if (str_ends_with($key, 'meta_key') && $clean !== '' && ! self::isMetaKey((string) $clean)) { throw new InvalidArgumentException(sprintf( 'Einstellung "%s" im Divi-Verweis: ungültiger Meta-Schlüssel (Buchstaben, Ziffern, "_" und "-", ' . 'nicht mit "_" am Anfang).', $key )); } return $clean; } /** * Checks the meta key Divi derives from $key (ruling S30): a leading known prefix is removed the * way Divi does it – str_replace of every occurrence – and a prefix still present afterwards is * rejected (defensive). "telefon", "custom_meta_telefon", "loop_post_meta_key_telefon" yes; * "_edit_lock", "custom_meta__edit_lock", "custom_meta_custom_meta__edit_lock", * "loop_post_meta_key__edit_lock", "a b" no. */ public static function isMetaKey(string $key): bool { foreach (self::META_KEY_PREFIXES as $prefix) { if (str_starts_with($key, $prefix)) { $key = str_replace($prefix, '', $key); break; } } foreach (self::META_KEY_PREFIXES as $prefix) { if (str_contains($key, $prefix)) { return false; } } return preg_match(self::META_KEY, $key) === 1; } /** * text, richtext/html and url settings: a string, cleaned by the matching sanitizer; a URL * the sanitizer empties is refused. * * @throws InvalidArgumentException */ private static function textSetting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer): string { if (! is_string($value)) { throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis: nur Text.', $key)); } $clean = match ($fieldType) { 'text' => $sanitizer->text($value), 'url' => $sanitizer->url($value), default => $sanitizer->html($value), }; if ($fieldType === 'url' && $clean === '' && trim($value) !== '') { throw new InvalidArgumentException( sprintf('Einstellung "%s" im Divi-Verweis: ungültige URL.', $key) ); } return $clean; } /** * Ruling S37: a numeric post id the user may read – no slugs (Divi resolves those itself). * Returned as sent (int or digit string). * * @throws InvalidArgumentException */ private static function selectPostSetting(string $key, mixed $value, ValueSanitizer $sanitizer): mixed { $digits = is_string($value) && preg_match('/^[1-9][0-9]*$/D', $value) === 1; $id = is_int($value) ? $value : ($digits ? (int) $value : 0); if ($id <= 0 || ! $sanitizer->canReadPost($id)) { throw new InvalidArgumentException(sprintf( 'Einstellung "%s" im Divi-Verweis: Beitrags-ID eines lesbaren Eintrags erwartet.', $key )); } return $value; } /** * select, yes_no_button and number settings: a scalar without markup; strings cleaned as text. * * @throws InvalidArgumentException */ private static function scalarSetting(string $key, mixed $value, ValueSanitizer $sanitizer): mixed { if (! is_scalar($value) || (is_string($value) && preg_match('/[<>]/', $value) === 1)) { throw new InvalidArgumentException(sprintf( 'Einstellung "%s" im Divi-Verweis muss ein einfacher Wert ohne Markup sein.', $key )); } return is_string($value) ? $sanitizer->text($value) : $value; } /** * @param array $map * @return array> * @throws InvalidArgumentException */ private static function checkMap(array $map): array { foreach ($map as $name => $fields) { if (! is_string($name) || ! is_array($fields)) { throw new InvalidArgumentException('Dynamic content map: erwartet Name => [Einstellung => Feldtyp].'); } foreach ($fields as $key => $type) { if (! is_string($key) || ! is_string($type)) { throw new InvalidArgumentException(sprintf( 'Dynamic content map "%s": erwartet Einstellung => Feldtyp (Text).', $name )); } } } /** @var array> $map */ return $map; } }