Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
306 lines
12 KiB
PHP
306 lines
12 KiB
PHP
<?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;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* 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 ValueCleaner::clean() – the single choke point under the kses
|
||
* bypass (D7), see its pipeline – with the curated kind of the field; apply() adds step 0 (S35)
|
||
* and set() the guard for removals (S7/S26). Own rules on top: colors are placeholders, color
|
||
* references or plain CSS colors, checked before and after cleaning (S13); an attachment's alt
|
||
* text carrying a reference is dropped (S33b). 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';
|
||
|
||
/**
|
||
* @param array<string, array<string, array<string, mixed>>>|null $modulesForTests Short form
|
||
* per type, a test seam (trusted wiring only, ledger T2); null = ShortformMap.
|
||
*/
|
||
public function __construct(
|
||
private Placeholders $placeholders,
|
||
private MediaResolver $media,
|
||
private ValueCleaner $cleaner,
|
||
private ?array $modulesForTests = 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
|
||
{
|
||
// Ruling S35: controls go before placeholder resolution and trim ("{\0{post:title}}").
|
||
$values = ControlChars::stripDeep($values);
|
||
$fields = $this->modulesForTests === null ? ShortformMap::fields($type) : ($this->modulesForTests[$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];
|
||
}
|
||
|
||
/**
|
||
* Attribute paths the given short-form fields of $type write to (unknown fields left out).
|
||
*
|
||
* @param list<string> $names Field names.
|
||
* @return array<string, string> Field => attribute path.
|
||
*/
|
||
public function paths(string $type, array $names): array
|
||
{
|
||
$fields = $this->modulesForTests === null ? ShortformMap::fields($type) : ($this->modulesForTests[$type] ?? []);
|
||
$paths = [];
|
||
foreach ($names as $name) {
|
||
if (isset($fields[$name]['path'])) {
|
||
$paths[$name] = (string) $fields[$name]['path'];
|
||
}
|
||
}
|
||
|
||
return $paths;
|
||
}
|
||
|
||
/**
|
||
* 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->cleaner->clean($module, $path, $value, $subKey, ValueKind::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->cleaner->clean($module, $path, $value, $subKey, ValueKind::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->cleaner->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->cleaner->clean($module, $path, $image['url'], $urlKey, ValueKind::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(ControlChars::strip($alt))) {
|
||
return '';
|
||
}
|
||
|
||
try {
|
||
$clean = $this->cleaner->clean($module, $path, $alt, $altKey, ValueKind::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);
|
||
}
|
||
|
||
// The curated kind as sanitizer kind; any other kind is text.
|
||
$kind = ValueKind::tryFrom((string) $spec['kind']) ?? ValueKind::Text;
|
||
if (! is_string($value) && ! is_int($value) && ! is_float($value)) {
|
||
throw new InvalidArgumentException('Text erwartet');
|
||
}
|
||
|
||
$value = (string) $value;
|
||
if ($kind !== ValueKind::Code) {
|
||
$value = $this->placeholders->resolve($value);
|
||
}
|
||
|
||
$clean = $this->cleaner->clean($module, $path, $value, $sub, $kind);
|
||
if ($kind === ValueKind::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;
|
||
}
|
||
}
|