Files
WP-LIT-MCP/src/Integrations/Divi/Compose/ShortformWriter.php
T
janniclas.loosenandClaude Opus 5.5 8688a02402 fix: Task 3 review round 1 - strip control chars before the sanitizer (S32a), S34 minors
Control characters now go on the raw value in AttrWriter::cleanNode and
VariableRef::setting; after the sanitizer only rejection remains ('<' + VT/FF
no longer turns into a tag). VALUE_KEY anchored with D, exact column-width
fractions, nested rows in columns, specialty columns refused, fullwidth by
catalog category, preset ambiguity in DesignSystem::modulePresetId().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 14:26:18 +02:00

284 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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';
/** 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) || VariableRef::typeOf($reference) !== 'color') {
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);
}
$alt = $altKey !== null && $withAlt ? $this->alt($module, $path, $image['alt'], $altKey) : '';
if ($alt !== '') {
$attrs = AttrMap::set($attrs, $base . (string) $altKey, $alt);
}
return $attrs;
}
/**
* The attachment's alt text, cleaned as text – or '' (no alt) when it carries a reference,
* whole or rebuilt by cleaning (ruling S33b): library data never becomes dynamic content.
*/
private function alt(string $module, string $path, string $alt, string $altKey): string
{
// 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))) {
return '';
}
try {
$clean = $this->attrs->cleanValue($module, $path, $alt, $altKey, 'text');
} catch (InvalidArgumentException) {
return '';
}
return is_string($clean) && ! VariableRef::contains($clean) ? $clean : '';
}
/**
* @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;
}
}