.desktop.value[.]"; * 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>>|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 $attrs Current attributes. * @param array $values Field => value. * @return array{attrs: array, errors: array} */ 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 $attrs * @param array $spec * @param array $all All values of this call (media must not overwrite an explicit alt). * @return array * @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 $attrs * @param array{0: string, 1: ?string, 2: ?string} $keys URL key, ID key, alt key. * @return array * @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 $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; } }