diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 17829ec..28c6a84 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -70,7 +70,7 @@ final class AttrWriter public function apply(string $module, array $attrs, array $changes, string $field): array { // Ruling S35: controls go before placeholder resolution ("{\0{post:title}}" is a placeholder). - $changes = VariableRef::withoutControlsDeep($changes); + $changes = ControlChars::stripDeep($changes); $errors = []; $invalid = []; foreach ($changes as $path => $map) { @@ -248,7 +248,7 @@ final class AttrWriter // Ruling S32/S32a: control characters go on the RAW value, before the marker check and // before the sanitizer – the WordPress sanitizers keep them ("<" + VT is a literal "<" // for strip_tags), so any later strip could build a tag or a marker nobody checked. - $value = VariableRef::withoutControls($value); + $value = ControlChars::strip($value); $routed = $this->kind($module, $path, $subName, $key); if ($kind !== null && ! self::tightens($kind, $routed)) { @@ -276,7 +276,7 @@ final class AttrWriter // Ruling S28: sanitizers strip characters ("", "%41") and can thereby build a marker the // raw check above never saw – so the final string is checked again. After the sanitizer // the only action is rejection (S32a): nothing transforms the cleaned string any more. - if (VariableRef::contains($clean) || VariableRef::withoutControls($clean) !== $clean) { + if (VariableRef::contains($clean) || ControlChars::strip($clean) !== $clean) { throw VariableRef::notAlone(); } diff --git a/src/Integrations/Divi/Compose/ControlChars.php b/src/Integrations/Divi/Compose/ControlChars.php new file mode 100644 index 0000000..e2d4fcb --- /dev/null +++ b/src/Integrations/Divi/Compose/ControlChars.php @@ -0,0 +1,35 @@ + 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 + )); + } + + $design = array_values(DesignSystem::FONT_VARIABLES); + foreach ($this->design->variables() as $variable) { + if ($variable['type'] !== 'colors') { + $design[] = $variable['id']; + } + } + if (in_array($name, $design, 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; + } +} diff --git a/src/Integrations/Divi/Compose/Placeholders.php b/src/Integrations/Divi/Compose/Placeholders.php index 5bbfa7d..ac35da6 100644 --- a/src/Integrations/Divi/Compose/Placeholders.php +++ b/src/Integrations/Divi/Compose/Placeholders.php @@ -40,7 +40,7 @@ final class Placeholders /** * @param array>|null $dynamicContent Divi's dynamic content - * options for VariableRef (ruling S22); null = VariableRef::DEFAULT_DYNAMIC_CONTENT. + * options for VariableRef (ruling S22); null = DynamicContentRules::DEFAULT_DYNAMIC_CONTENT. */ public function __construct(private DesignSystem $design, ?array $dynamicContent = null) { @@ -209,7 +209,7 @@ final class Placeholders private function field(string $name): string { - if (preg_match(VariableRef::META_KEY, $name) !== 1) { + if (preg_match(DynamicContentRules::META_KEY, $name) !== 1) { throw new InvalidArgumentException(sprintf( 'Ungültiger Feldschlüssel "%s" (erlaubt: Buchstaben, Ziffern, "_" und "-", nicht mit "_" am Anfang).', $name diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php index 6caf160..b736dc1 100644 --- a/src/Integrations/Divi/Compose/ShortformWriter.php +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -49,7 +49,7 @@ final class ShortformWriter public function apply(string $type, array $attrs, array $values, string $field): array { // Ruling S35: controls go before placeholder resolution and trim ("{\0{post:title}}"). - $values = VariableRef::withoutControlsDeep($values); + $values = ControlChars::stripDeep($values); $fields = $this->modules === null ? ShortformMap::fields($type) : ($this->modules[$type] ?? []); $errors = []; foreach ($values as $name => $value) { @@ -200,7 +200,7 @@ final class ShortformWriter { // Controls are stripped at the choke point before its marker check (S32a) – so a // NUL-split marker counts as a marker here too, and a canonical result is refused. - if ($alt === '' || VariableRef::contains(VariableRef::withoutControls($alt))) { + if ($alt === '' || VariableRef::contains(ControlChars::strip($alt))) { return ''; } diff --git a/src/Integrations/Divi/Compose/TextReplacer.php b/src/Integrations/Divi/Compose/TextReplacer.php index 5b978cf..15f258d 100644 --- a/src/Integrations/Divi/Compose/TextReplacer.php +++ b/src/Integrations/Divi/Compose/TextReplacer.php @@ -72,7 +72,7 @@ final class TextReplacer $problems = []; if (! mb_check_encoding($search, 'UTF-8') || mb_strlen($search) < 2) { $problems['search'] = 'Suchtext: mindestens 2 Zeichen gültiges UTF-8.'; - } elseif (VariableRef::withoutControls($search) !== $search) { + } elseif (ControlChars::strip($search) !== $search) { $problems['search'] = 'Suchtext darf keine Steuerzeichen enthalten.'; } elseif (VariableRef::contains($search)) { $problems['search'] = 'Suchtext darf keinen $variable(…)$-Verweis enthalten; dynamische Inhalte werden ' @@ -456,7 +456,7 @@ final class TextReplacer foreach (explode("\n", $value) as $line) { $marker = strrpos($line, VariableRef::MARKER); - $end = $marker === false ? false : strrpos($line, ')$'); + $end = $marker === false ? false : strrpos($line, VariableRef::END); if ($marker !== false && ($end === false || $end < $marker)) { return true; } @@ -486,7 +486,7 @@ final class TextReplacer */ private static function stripped(string $value): string { - $value = VariableRef::withoutControls($value); + $value = ControlChars::strip($value); do { $before = $value; $kept = ''; diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index d8d4799..5d7ab28 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -22,93 +22,33 @@ use stdClass; * Allowed names: * - type "color": a global color ID of the design system or a global variable of type "colors"; * settings hue/saturation/lightness/opacity, numbers only. - * - type "content": a non-color global variable or a theme font (no settings, DESIGN_SETTINGS, S27); - * custom_meta_ 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 $dynamicContent: - * 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, 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. + * - type "content": DynamicContentRules – the dynamic content map (ruling S22), custom_meta_ + * (S12/S29/S30), non-color global variables and theme fonts (S27); it also cleans each content + * setting. "enable_html" is always rejected here. + * A dynamic content map of the wrong shape fails in the constructor with InvalidArgumentException. */ final class VariableRef { public const MARKER = '$variable('; - /** 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']; + /** End of a reference: Divi's non-greedy match ends the reference at the first ")$". */ + public const END = ')$'; private const COLOR_SETTINGS = ['hue', 'saturation', 'lightness', 'opacity']; private const WHOLE = '/^\$variable\((.*)\)\$$/sD'; - 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_', - ]; - private const NUMBER = '/^-?\d+(\.\d+)?$/D'; - /** @var array> */ - private array $dynamicContent; + private DynamicContentRules $content; /** - * @param array|null $dynamicContent See the class docblock. + * @param array|null $dynamicContent See DynamicContentRules. * @throws InvalidArgumentException When the map does not have that shape. */ public function __construct(private DesignSystem $design, ?array $dynamicContent = null) { - $this->dynamicContent = $dynamicContent === null - ? self::DEFAULT_DYNAMIC_CONTENT - : self::checkMap($dynamicContent); + $this->content = new DynamicContentRules($design, $dynamicContent); } /** Whether $value contains a reference anywhere (such a value may only be a whole reference). */ @@ -124,12 +64,8 @@ final class VariableRef */ public static function typeOf(string $value): ?string { - if (preg_match(self::WHOLE, $value, $match) !== 1 || str_contains($match[1], ')$')) { - return null; - } - try { - $type = self::decode($match[1])['type'] ?? null; + $type = self::payloadOf($value)['type'] ?? null; } catch (InvalidArgumentException) { return null; } @@ -137,32 +73,6 @@ final class VariableRef return $type === 'color' || $type === 'content' ? $type : null; } - /** - * Removes C0 control characters except tab, LF and CR, and DEL (rulings S32/S32a). Applied - * to RAW values only, before the marker check and the sanitizer: the WordPress sanitizers keep - * them (sanitize_text_field keeps NUL; strip_tags reads "<" + VT/FF as a literal "<"), so a - * strip after sanitizing could build a marker or a tag no check saw. - */ - public static function withoutControls(string $value): string - { - return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value); - } - - /** - * withoutControls() on every string inside $value (keys stay: odd keys are rejected). Ruling - * S35: AttrWriter::apply() and ShortformWriter::apply() run it once on their raw input, before - * placeholder resolution and trim – so "{\0{post:title}}" is the placeholder, checked as one, - * and never literal text that slipped past the placeholder check. - */ - public static function withoutControlsDeep(mixed $value): mixed - { - if (is_string($value)) { - return self::withoutControls($value); - } - - return is_array($value) ? array_map([self::class, 'withoutControlsDeep'], $value) : $value; - } - /** @param array|stdClass $settings */ public static function serialize(string $type, string $name, array|stdClass $settings): string { @@ -171,12 +81,12 @@ final class VariableRef } $json = Json::encode(['type' => $type, 'value' => ['name' => $name, 'settings' => $settings]]); - if (str_contains($json, ')$')) { + if (str_contains($json, self::END)) { // Divi would end the reference there (non-greedy match) – never write that. throw self::notAlone(); } - return '$variable(' . $json . ')$'; + return self::MARKER . $json . self::END; } /** @@ -188,11 +98,11 @@ final class VariableRef */ public function canonical(string $value, ValueSanitizer $sanitizer, bool $url = false): string { - if (preg_match(self::WHOLE, $value, $match) !== 1 || str_contains($match[1], ')$')) { + $payload = self::payloadOf($value); + if ($payload === null) { throw self::notAlone(); } - $payload = self::decode($match[1]); self::onlyKeys($payload, ['type', 'value'], 'Divi-Verweis'); $type = $payload['type'] ?? null; if ($type !== 'color' && $type !== 'content') { @@ -230,6 +140,31 @@ final class VariableRef ); } + public static function unknownName(string $name, string $type): InvalidArgumentException + { + return new InvalidArgumentException(sprintf( + 'Unbekannter Name "%s" für Typ "%s" im Divi-Verweis (nur vorhandene globale Farben/Variablen, ' + . 'Theme-Schriften, Divi-Inhaltsquellen und custom_meta_ ohne führendes "_").', + $name, + $type + )); + } + + /** + * The decoded payload of $value when it is exactly one whole reference; null otherwise. + * + * @return array|null + * @throws InvalidArgumentException When the payload is no JSON object (decode()). + */ + private static function payloadOf(string $value): ?array + { + if (preg_match(self::WHOLE, $value, $match) !== 1 || str_contains($match[1], self::END)) { + return null; + } + + return self::decode($match[1]); + } + /** @return array */ private static function decode(string $json): array { @@ -301,7 +236,7 @@ final class VariableRef */ private function contentSettings(string $name, array $settings, ValueSanitizer $sanitizer, bool $url): array { - $fields = $this->contentFields($name); + $fields = $this->content->fieldsFor($name); $clean = []; foreach ($settings as $key => $value) { $key = (string) $key; @@ -311,201 +246,12 @@ final class VariableRef if ($url && ($key === 'before' || $key === 'after') && $value !== '') { throw new InvalidArgumentException(sprintf('"%s" im Divi-Verweis: in URL-Feldern verboten.', $key)); } - $clean[$key] = self::setting($key, $value, $fields[$key], $sanitizer); + $clean[$key] = DynamicContentRules::cleanSetting($key, $value, $fields[$key], $sanitizer); } return $clean; } - /** - * @return array Setting key => field type allowed for the content name. - * @throws InvalidArgumentException When the name is not allowed. - */ - private function contentFields(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 self::unknownName($name, 'content'); - } - - return $this->dynamicContent[$name] ?? self::META_SETTINGS; - } - - if (isset($this->dynamicContent[$name])) { - return $this->dynamicContent[$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 - )); - } - - $design = array_values(DesignSystem::FONT_VARIABLES); - foreach ($this->design->variables() as $variable) { - if ($variable['type'] !== 'colors') { - $design[] = $variable['id']; - } - } - if (in_array($name, $design, true)) { - return self::DESIGN_SETTINGS; - } - - throw self::unknownName($name, 'content'); - } - - /** Cleans one content setting by its field type (ruling S21/S22). */ - private static function setting(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 = self::withoutControls($value); - } - - if (is_string($value) && (self::contains($value) || str_contains($value, ')$'))) { - throw self::notAlone(); - } - - switch ($fieldType) { - case 'text': - case 'richtext': - case 'html': - case 'url': - 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) - ); - } - break; - case 'select_post': - // Ruling S37: a numeric post id the user may read – no slugs (Divi resolves those itself). - $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 - )); - } - - $clean = $value; - break; - case 'select': - case 'yes_no_button': - case 'number': - 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 - )); - } - - $clean = is_string($value) ? $sanitizer->text($value) : $value; - break; - default: - // Ruling S37: map types come from a filter (untrusted) – unknown types are refused. - 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) - && (self::contains($clean) || str_contains($clean, ')$') || self::withoutControls($clean) !== $clean) - ) { - throw self::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. - */ - private 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; - } - - /** - * @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; - } - - private static function unknownName(string $name, string $type): InvalidArgumentException - { - return new InvalidArgumentException(sprintf( - 'Unbekannter Name "%s" für Typ "%s" im Divi-Verweis (nur vorhandene globale Farben/Variablen, ' - . 'Theme-Schriften, Divi-Inhaltsquellen und custom_meta_ ohne führendes "_").', - $name, - $type - )); - } - /** @param list $allowed */ private static function unknownSetting(string $key, array $allowed): InvalidArgumentException { diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php index 369aa72..c824443 100644 --- a/src/Integrations/Divi/Pages/DynamicContentMap.php +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -6,24 +6,24 @@ namespace LIT\MCP\Integrations\Divi\Pages; use Closure; use LIT\MCP\Integrations\Divi\Compose\Placeholders; -use LIT\MCP\Integrations\Divi\Compose\VariableRef; +use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules; use LIT\MCP\Log; use Throwable; /** - * VariableRef's dynamic content allow map (ruling S22) from Divi's live registry + * DynamicContentRules' dynamic content allow map (ruling S22) from Divi's live registry * DynamicContentOptions::get_options($postId, 'edit') – the options the Visual Builder offers * for this entry (filter divi_module_dynamic_content_options; the list depends on the post: * post_categories/post_tags only for types with those taxonomies). Shape: * option name => [setting key => field type], e.g. post_date => [before => richtext, …, - * date_format => select]. VariableRef routes each setting by that type and keeps its fixed + * date_format => select]. DynamicContentRules routes each setting by that type and keeps its fixed * rules whatever the map says (enable_html refused, meta keys S29/S30, no markers S28). * * - loop_* options never enter the map: Divi offers them only inside loop-enabled modules, - * which the tools do not track – VariableRef refuses them ("nur innerhalb von Divi-Schleifen"). - * - The map REPLACES VariableRef's default, so every Placeholders::POST option is added when - * the registry lacks it, with the settings the placeholders write (VariableRef's default). - * - Registry unavailable (no Divi, empty, error): null – VariableRef's default map applies. + * which the tools do not track – DynamicContentRules refuses them ("nur innerhalb von Divi-Schleifen"). + * - The map REPLACES DynamicContentRules' default, so every Placeholders::POST option is added when + * the registry lacks it, with the settings the placeholders write (DynamicContentRules' default). + * - Registry unavailable (no Divi, empty, error): null – DynamicContentRules' default map applies. * * Not final so abilities can be tested with a fake registry. */ @@ -38,7 +38,7 @@ class DynamicContentMap { } - /** @return array>|null Null = use VariableRef's default map. */ + /** @return array>|null Null = use DynamicContentRules' default map. */ public function forPost(int $postId): ?array { try { @@ -74,7 +74,7 @@ class DynamicContentMap } foreach (Placeholders::POST as $option) { - $map[$option] = ($map[$option] ?? []) + (VariableRef::DEFAULT_DYNAMIC_CONTENT[$option] ?? []); + $map[$option] = ($map[$option] ?? []) + (DynamicContentRules::DEFAULT_DYNAMIC_CONTENT[$option] ?? []); } return $map; @@ -85,7 +85,7 @@ class DynamicContentMap * outputs them as HTML (DynamicContentElements::get_wrapper_element, wp_kses by the author's * unfiltered_html), and sanitize_text_field() would trim their meaningful spaces ("Von "). * html() is kses for editors, the S28 marker checks hold either way. No readable type: '' – - * VariableRef's strictest branch (a scalar without markup). + * DynamicContentRules' strictest branch (a scalar without markup). */ private static function type(string $key, mixed $field): string { diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php index 90a56e9..a932439 100644 --- a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use InvalidArgumentException; +use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules; use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; @@ -50,7 +51,7 @@ final class DynamicContentMapTest extends TestCase foreach (Placeholders::POST as $option) { self::assertArrayHasKey($option, $map, $option); - foreach (array_keys(VariableRef::DEFAULT_DYNAMIC_CONTENT[$option]) as $setting) { + foreach (array_keys(DynamicContentRules::DEFAULT_DYNAMIC_CONTENT[$option]) as $setting) { self::assertArrayHasKey($setting, $map[$option], "$option.$setting"); } }