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:
2026-09-26 18:05:38 +02:00
co-authored by Claude Opus 5.5
parent e420145cbe
commit 874a92c6be
9 changed files with 403 additions and 316 deletions
@@ -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;
}
}