feat: Divi short-form writer

ShortformWriter routes every short-form string through AttrWriter::cleanValue()
with the curated field kind (new optional kind override), exposes the color
check for background_color and guards builder-only paths on removal too.

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 13:59:24 +02:00
co-authored by Claude Opus 5.5
parent 4832ec1e10
commit f86cd5b34a
4 changed files with 607 additions and 12 deletions
+31 -11
View File
@@ -91,15 +91,23 @@ final class AttrWriter
/**
* Cleans one value written to $path (used by the short form too). $subKey names the
* sub-field of a composite path ("linkUrl" of button.innerContent) when $value is its
* string alone.
* string alone. $kind ('html'|'code'|'url'|'text') replaces the catalog routing for every
* string of $value: the short form passes its curated field kind (ShortformMap), so a
* short-form url field is always a URL and icon-list-item text always plain text.
*
* @param 'html'|'code'|'url'|'text'|null $kind
* @throws InvalidArgumentException
*/
public function cleanValue(string $module, string $path, mixed $value, ?string $subKey = null): mixed
{
public function cleanValue(
string $module,
string $path,
mixed $value,
?string $subKey = null,
?string $kind = null
): mixed {
$this->guardPath($path);
return $this->cleanNode($module, $path, $value, $subKey, $subKey);
return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind);
}
/**
@@ -133,8 +141,13 @@ final class AttrWriter
return $normalized;
}
/** @throws InvalidArgumentException For Divi's HTML/attribute fields without unfiltered_html (S7). */
private function guardPath(string $path): void
/**
* Rejects Divi's HTML/attribute/CSS fields for users without unfiltered_html (S7/S26); also
* for removals, which the short form does without cleanValue().
*
* @throws InvalidArgumentException
*/
public function guardPath(string $path): void
{
if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) {
throw new InvalidArgumentException(sprintf(
@@ -148,10 +161,17 @@ final class AttrWriter
/**
* @param string|null $subName First key below the state value: selects the sub-kind of a composite path.
* @param string|null $key Nearest key above $value: the URL fallback when the catalog names no kind.
* @param 'html'|'code'|'url'|'text'|null $kind Fixed kind instead of the routing (short form).
* @throws InvalidArgumentException
*/
private function cleanNode(string $module, string $path, mixed $value, ?string $subName, ?string $key): mixed
{
private function cleanNode(
string $module,
string $path,
mixed $value,
?string $subName,
?string $key,
?string $kind = null
): mixed {
if (is_array($value)) {
$clean = [];
foreach ($value as $childKey => $item) {
@@ -163,8 +183,8 @@ final class AttrWriter
}
$clean[$childKey] = is_string($childKey)
? $this->cleanNode($module, $path, $item, $subName ?? $childKey, $childKey)
: $this->cleanNode($module, $path, $item, $subName, $key);
? $this->cleanNode($module, $path, $item, $subName ?? $childKey, $childKey, $kind)
: $this->cleanNode($module, $path, $item, $subName, $key, $kind);
}
return $clean;
@@ -178,7 +198,7 @@ final class AttrWriter
throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.');
}
$kind = $this->kind($module, $path, $subName, $key);
$kind ??= $this->kind($module, $path, $subName, $key);
if (VariableRef::contains($value)) {
// The only way a reference is stored: whole, straight from canonical().
return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url');
@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
/**
* Writes short-form fields (spec §5.4, ShortformMap) into Divi attributes: checks the value per
* kind, resolves placeholders (not for code) and sets it at "<path>.desktop.value[.<sub>]";
* null removes the field. Pure PHP.
*
* Every string goes through AttrWriter::cleanValue() – the single choke point under the kses
* bypass (D7) – with the curated kind of the field: $variable(…)$ values only whole and
* re-serialized by VariableRef (S1/S2/S25), the final string checked again (S28), URL fields
* through the URL sanitizer (S3), builder-only paths guarded (S7/S26). Nothing is passed through.
*/
final class ShortformWriter
{
/** A plain CSS color; no names, no functions but rgb/hsl (ruling S13). */
private const COLOR = '/^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|transparent)$/D';
private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a';
/** Prefix of every color reference VariableRef::serialize() writes (fixed key order). */
private const COLOR_REFERENCE = '$variable({"type":"color",';
/** Curated kind => sanitizer kind of AttrWriter::cleanValue(). */
private const CLEAN_KIND = ['html' => 'html', 'code' => 'code', 'url' => 'url', 'text' => 'text'];
/**
* @param array<string, array<string, array<string, mixed>>>|null $modules Short form per
* type (tests only); null = ShortformMap.
*/
public function __construct(
private Placeholders $placeholders,
private MediaResolver $media,
private AttrWriter $attrs,
private ?array $modules = null
) {
}
/**
* @param string $type Short module name ("button").
* @param array<string, mixed> $attrs Current attributes.
* @param array<string, mixed> $values Field => value.
* @return array{attrs: array<string, mixed>, errors: array<string, string>}
*/
public function apply(string $type, array $attrs, array $values, string $field): array
{
$fields = $this->modules === null ? ShortformMap::fields($type) : ($this->modules[$type] ?? []);
$errors = [];
foreach ($values as $name => $value) {
$name = (string) $name;
$spec = $fields[$name] ?? null;
if ($spec === null) {
$errors[$field . '.' . $name] = $fields === []
? 'dieses Modul hat keine Kurzform; Einstellungen über attrs (Vollform) setzen'
: 'unbekanntes Feld; erlaubt: ' . implode(', ', array_keys($fields));
continue;
}
try {
$attrs = $this->set('divi/' . $type, $attrs, $spec, $value, $values);
} catch (InvalidArgumentException $e) {
$errors[$field . '.' . $name] = $e->getMessage();
}
}
return ['attrs' => $attrs, 'errors' => $errors];
}
/**
* The short-form color check (ruling S13; Task 3 reuses it for background_color): a
* placeholder or reference that is a color reference, or a plain CSS color – checked
* before and after cleaning (S28). Returns the value to store.
*
* @throws InvalidArgumentException
*/
public function color(string $module, string $path, mixed $value, ?string $subKey = null): string
{
if (! is_string($value)) {
throw new InvalidArgumentException(self::COLOR_ERROR);
}
$value = trim($this->placeholders->resolve($value));
if (VariableRef::contains($value)) {
$reference = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text');
if (! is_string($reference) || ! str_starts_with($reference, self::COLOR_REFERENCE)) {
throw new InvalidArgumentException(self::COLOR_ERROR);
}
return $reference;
}
if (preg_match(self::COLOR, $value) !== 1) {
throw new InvalidArgumentException(self::COLOR_ERROR);
}
$clean = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text');
if (! is_string($clean) || preg_match(self::COLOR, $clean) !== 1) {
throw new InvalidArgumentException(self::COLOR_ERROR);
}
return $clean;
}
/**
* @param array<string, mixed> $attrs
* @param array<string, mixed> $spec
* @param array<string, mixed> $all All values of this call (media must not overwrite an explicit alt).
* @return array<string, mixed>
* @throws InvalidArgumentException
*/
private function set(string $module, array $attrs, array $spec, mixed $value, array $all): array
{
$path = (string) $spec['path'];
$sub = $spec['sub'] === null ? null : (string) $spec['sub'];
$base = $path . '.desktop.value';
$this->attrs->guardPath($path);
if ($value === null) {
return AttrMap::remove($attrs, $sub === null ? $path : $base . '.' . $sub);
}
if ($spec['kind'] === 'media') {
$attrs = $this->media($module, $attrs, $path, $spec['keys'], $value, ! array_key_exists('alt', $all));
} else {
$clean = $this->value($module, $path, $sub, $spec, $value);
$attrs = AttrMap::set($attrs, $sub === null ? $base : $base . '.' . $sub, $clean);
}
foreach ($spec['with'] ?? [] as $withKey => $fixed) {
$attrs = AttrMap::set($attrs, $base . '.' . $withKey, $fixed);
}
return $attrs;
}
/**
* @param array<string, mixed> $attrs
* @param array{0: string, 1: ?string, 2: ?string} $keys URL key, ID key, alt key.
* @return array<string, mixed>
* @throws InvalidArgumentException
*/
private function media(string $module, array $attrs, string $path, array $keys, mixed $value, bool $withAlt): array
{
$id = self::id($value);
$image = $id > 0 ? $this->media->image($id) : null;
$notFound = 'kein Bild aus der Mediathek; Anhang-ID z. B. mit lit-media-search suchen';
if ($image === null) {
throw new InvalidArgumentException($notFound);
}
[$urlKey, $idKey, $altKey] = $keys;
$base = $path . '.desktop.value.';
$url = $this->attrs->cleanValue($module, $path, $image['url'], $urlKey, 'url');
if ($url === '') {
throw new InvalidArgumentException($notFound);
}
$attrs = AttrMap::set($attrs, $base . $urlKey, $url);
if ($idKey !== null) {
$attrs = AttrMap::set($attrs, $base . $idKey, (string) $id);
}
if ($altKey !== null && $withAlt && $image['alt'] !== '') {
$alt = $this->attrs->cleanValue($module, $path, $image['alt'], $altKey, 'text');
$attrs = AttrMap::set($attrs, $base . $altKey, $alt);
}
return $attrs;
}
/**
* @param array<string, mixed> $spec
* @throws InvalidArgumentException
*/
private function value(string $module, string $path, ?string $sub, array $spec, mixed $value): mixed
{
switch ($spec['kind']) {
case 'onoff':
if (is_bool($value)) {
return $value ? 'on' : 'off';
}
if ($value === 'on' || $value === 'off') {
return $value;
}
throw new InvalidArgumentException('true oder false erwartet');
case 'level':
if (is_string($value) && preg_match('/^h[1-6]$/D', $value) === 1) {
return $value;
}
throw new InvalidArgumentException('h1 bis h6 erwartet');
case 'number':
$number = is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))
? $value + 0
: null;
if ($number !== null && is_finite((float) $number)) {
return $number;
}
throw new InvalidArgumentException('Zahl erwartet');
case 'enum':
if (is_string($value) && in_array($value, $spec['values'], true)) {
return $value;
}
throw new InvalidArgumentException('erlaubt: ' . implode(', ', $spec['values']));
case 'ids':
return $this->ids($value);
case 'color':
return $this->color($module, $path, $value, $sub);
}
$kind = self::CLEAN_KIND[$spec['kind']] ?? 'text';
if (! is_string($value) && ! is_int($value) && ! is_float($value)) {
throw new InvalidArgumentException('Text erwartet');
}
$value = (string) $value;
if ($kind !== 'code') {
$value = $this->placeholders->resolve($value);
}
$clean = $this->attrs->cleanValue($module, $path, $value, $sub, $kind);
if ($kind === 'url' && $clean === '' && trim($value) !== '') {
throw new InvalidArgumentException('ungültige URL');
}
return $clean;
}
/** @throws InvalidArgumentException */
private function ids(mixed $value): string
{
if (! is_array($value) || $value === [] || ! array_is_list($value)) {
throw new InvalidArgumentException('Liste von Anhang-IDs erwartet, z. B. [12, 33]');
}
$ids = [];
foreach ($value as $item) {
$id = self::id($item);
if ($id <= 0 || $this->media->image($id) === null) {
throw new InvalidArgumentException(sprintf(
'%s ist kein Bild aus der Mediathek',
is_scalar($item) ? (string) $item : gettype($item)
));
}
$ids[] = $id;
}
return implode(',', $ids);
}
/** Attachment ID from an int or a digit string; 0 otherwise. */
private static function id(mixed $value): int
{
if (is_int($value)) {
return $value;
}
return is_string($value) && ctype_digit($value) ? (int) $value : 0;
}
}