refactor: split VariableRef into ControlChars and DynamicContentRules (H2)
- ControlChars::strip()/stripDeep() (was VariableRef::withoutControls/Deep) with the S32/S32a/S35 docblock; callers AttrWriter, ShortformWriter, TextReplacer and the setting cleaner use it. - DynamicContentRules (design system + dynamic content map): fieldsFor() (was contentFields: meta prefix -> map -> loop_ -> design names -> unknown), cleanSetting() (was setting(): raw strip -> raw marker -> field type -> final-string check -> meta key check, same order), one private method per field family (textSetting, selectPostSetting, scalarSetting), isMetaKey(), checkMap(), and the constants DEFAULT_DYNAMIC_CONTENT, META_SETTINGS, DESIGN_SETTINGS, META_KEY. - VariableRef keeps the syntax: const END = ')$' replaces the six literals, payloadOf() is shared by typeOf() and canonical(); unknownName() is public for the rules. Error messages unchanged. Tests unchanged apart from a class name in DynamicContentMapTest. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -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 ("<x>", "%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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
/**
|
||||
* 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. After the sanitizer a changed
|
||||
* result of strip() only ever means rejection. Pure PHP.
|
||||
*/
|
||||
final class ControlChars
|
||||
{
|
||||
public static function strip(string $value): string
|
||||
{
|
||||
return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* strip() 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 stripDeep(mixed $value): mixed
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return self::strip($value);
|
||||
}
|
||||
|
||||
return is_array($value) ? array_map([self::class, 'stripDeep'], $value) : $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||
|
||||
/**
|
||||
* The allow list of content references ($variable({"type":"content",…})$) that VariableRef
|
||||
* delegates to: which names exist and how each of their settings is cleaned. Pure PHP.
|
||||
*
|
||||
* Allowed names: a non-color global variable or a theme font (no settings, DESIGN_SETTINGS,
|
||||
* S27); custom_meta_<key> 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<string, array<string, string>> 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_<key> 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_<key>: no leading "_" – Divi renders protected meta publicly (ruling S12). */
|
||||
public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/D';
|
||||
|
||||
/** @var array<string, array<string, string>> 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_<key> 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(<name>) (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<string, array<string, string>> */
|
||||
private array $map;
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed>|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<string, string> 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<x>(" → "$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<array-key, mixed> $map
|
||||
* @return array<string, array<string, string>>
|
||||
* @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<string, array<string, string>> $map */
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ final class Placeholders
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, string>>|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
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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_<key> 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<string, array<string, string>> 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_<key> 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_<key>
|
||||
* (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_<key>: no leading "_" – Divi renders protected meta publicly (ruling S12). */
|
||||
public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/D';
|
||||
|
||||
/** @var array<string, array<string, string>> 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_<key> 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(<name>) (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<string, array<string, string>> */
|
||||
private array $dynamicContent;
|
||||
private DynamicContentRules $content;
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed>|null $dynamicContent See the class docblock.
|
||||
* @param array<array-key, mixed>|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<string, mixed>|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_<Schlüssel> ohne führendes "_").',
|
||||
$name,
|
||||
$type
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* The decoded payload of $value when it is exactly one whole reference; null otherwise.
|
||||
*
|
||||
* @return array<array-key, mixed>|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<array-key, mixed> */
|
||||
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<string, string> 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<x>(" → "$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<array-key, mixed> $map
|
||||
* @return array<string, array<string, string>>
|
||||
* @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<string, array<string, string>> $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_<Schlüssel> ohne führendes "_").',
|
||||
$name,
|
||||
$type
|
||||
));
|
||||
}
|
||||
|
||||
/** @param list<string> $allowed */
|
||||
private static function unknownSetting(string $key, array $allowed): InvalidArgumentException
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user