Files
KantSites/src/Plugin/PluginRegistry.php
T
kantarciandClaude Opus 5 c8a8fa7460 Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog
Forms gained steps, conditions, image choices, number fields with units and a
summary step; the DEEG price finder uses all of it. Plugins are manifest plus
Twig: blocks, settings, containers, routes, presets and theme packs, switched
per installation. The review backlog is done: archive pages render through the
normal page pipeline, forms and post lists work inside areas and sections,
repeated sections get their own ids, breakpoints and colours come from the
palette, backup and update work on MySQL and lock each other out.

The task worker no longer shortens a process that has no time limit:
set_time_limit() sets the limit instead of extending it, which turned an
unlimited CLI run into eighty seconds and killed the full test suite far away
from the scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:32:35 +02:00

610 lines
30 KiB
PHP

<?php
namespace App\Plugin;
use App\Domain\InvalidDocument;
use Doctrine\DBAL\Connection;
/**
* Die Erweiterungen dieser Installation: ein Ordner je Plugin unter plugins/, darin plugin.json.
*
* Ein Plugin bringt Bausteine mit. Jeder Baustein hat einen Typ mit Namensraum (`vergleich.bild`),
* ein Schema seiner Eigenschaften, eine Twig-Vorlage und optional ein Stylesheet und ein Skript,
* die nur auf Seiten geladen werden, auf denen der Baustein vorkommt. Der Kern prüft die
* Eigenschaften gegen das Schema, bevor die Vorlage etwas davon sieht - ein Wert, der nicht im
* Schema steht oder nicht hineinpasst, erreicht die Ausgabe nie.
*
* Seit dem zweiten Teil von Arbeitspaket H (ADR 103) kann ein Plugin außerdem: Bausteine mit
* Kindern (Container), Einstellungen für den Betreiber, eigene öffentliche Adressen und
* Seitenvorlagen für den Editor. Alles davon ist Manifest plus Twig - PHP-Code bringt ein
* Plugin nicht mit, und deshalb gibt es auch keine Hooks in Speichern oder Veröffentlichen.
*
* Eingeschaltet wird ein Plugin in der Verwaltung; der Schalter liegt in der Datenbank. Ein
* ausgeschaltetes oder fehlendes Plugin lässt seine Bausteine unangetastet im Dokument (ADR 011):
* sie werden gespeichert, im Editor als Platzhalter gezeigt und öffentlich als Kommentar
* ausgegeben.
*
* Serverseitiger Plugin-Code (Twig-Vorlagen) läuft mit den Rechten der Anwendung. Das ist kein
* Sandkasten und wird auch nicht so genannt: ein Plugin installiert nur, wer dem Code traut.
*/
final class PluginRegistry
{
public const NAME = '/^[a-z][a-z0-9]{1,30}$/D';
public const BLOCK = '/^[a-z][a-z0-9-]{1,40}$/D';
/** Die Arten von Eigenschaften, die ein Schema kennt; jede hat ein Feld im Editor. */
public const KINDS = ['text', 'textarea', 'number', 'boolean', 'select', 'url', 'media'];
/** Was ein Plugin als Datei ausliefern darf. */
public const ASSET_TYPES = ['css' => 'text/css; charset=UTF-8', 'js' => 'text/javascript; charset=UTF-8', 'svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'webp' => 'image/webp', 'woff2' => 'font/woff2'];
/** Was eine eigene Adresse eines Plugins antworten darf. */
public const ROUTE_TYPES = ['html' => 'text/html; charset=UTF-8', 'json' => 'application/json', 'text' => 'text/plain; charset=UTF-8', 'xml' => 'application/xml; charset=UTF-8'];
/** Eine Adresse wie eine Seite: Slugs, bis zu vier Ebenen - dieselbe Form, die der Seitenaufruf annimmt. */
public const ROUTE_PATH = '/^\/[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}$/D';
public const MAX_PROPS = 30;
/** @var array<string, array>|null */
private ?array $manifests = null;
/** @var array<string, array{active: bool, version: string, activatedAt: ?string, settings: array}>|null */
private ?array $rows = null;
public function __construct(private Connection $db, private string $projectDir)
{
}
public function directory(): string
{
return $this->projectDir.'/plugins';
}
/**
* Alle Plugins im Ordner, gültige wie ungültige, mit ihrem Schalter.
*
* @return array<string, array{name: string, title: string, description: string, version: string, requires: string, blocks: array<string, array>, settings: array<string, array>, routes: array<string, array>, presets: list<array>, active: bool, error: ?string, activatedVersion: string, activatedAt: ?string, values: array}>
*/
public function all(): array
{
if (null !== $this->manifests) {
return $this->manifests;
}
$this->manifests = [];
$rows = $this->rows();
foreach (glob($this->directory().'/*/plugin.json') ?: [] as $file) {
$name = basename(dirname($file));
$row = $rows[$name] ?? ['active' => false, 'version' => '', 'activatedAt' => null, 'settings' => []];
$state = ['active' => $row['active'], 'error' => null, 'activatedVersion' => $row['version'], 'activatedAt' => $row['activatedAt']];
try {
$manifest = $this->manifest($file, $name);
// Die gespeicherten Einstellungen, ergänzt um die Vorgaben des Manifests.
$values = [];
foreach ($manifest['settings'] as $key => $schema) {
$values[$key] = array_key_exists($key, $row['settings']) ? $row['settings'][$key] : $schema['default'];
}
$this->manifests[$name] = [...$manifest, ...$state, 'values' => $values];
} catch (\RuntimeException $e) {
// Ein kaputtes Manifest hält die anderen nicht auf: das Plugin erscheint in der
// Verwaltung mit seinem Fehler und ist nie aktiv, egal was der Schalter sagt.
$this->manifests[$name] = ['name' => $name, 'title' => $name, 'description' => '', 'version' => '', 'requires' => '', 'blocks' => [], 'settings' => [], 'routes' => [], 'presets' => [], ...$state, 'active' => false, 'error' => $e->getMessage(), 'values' => []];
}
}
ksort($this->manifests);
return $this->manifests;
}
/** @return array<string, array{active: bool, version: string, activatedAt: ?string, settings: array}> */
private function rows(): array
{
if (null !== $this->rows) {
return $this->rows;
}
$this->rows = [];
try {
foreach ($this->db->fetchAllAssociative('SELECT name, active, version, activated_at, settings FROM plugin') as $row) {
$settings = json_decode((string) ($row['settings'] ?? '{}'), true);
$this->rows[$row['name']] = ['active' => (bool) $row['active'], 'version' => (string) $row['version'], 'activatedAt' => $row['activated_at'], 'settings' => is_array($settings) ? $settings : []];
}
} catch (\Throwable) {
// Vor der Migration gibt es die Tabelle nicht; dann ist kein Plugin aktiv, und die
// Website läuft trotzdem.
}
return $this->rows;
}
public function activate(string $name): void
{
$plugin = $this->all()[$name] ?? throw new \InvalidArgumentException('Dieses Plugin gibt es nicht.');
if (null !== $plugin['error']) {
throw new \InvalidArgumentException('Das Plugin lässt sich nicht einschalten: '.$plugin['error']);
}
$this->write($name, ['active' => true, 'version' => $plugin['version'], 'activated_at' => gmdate('Y-m-d H:i:s')]);
}
public function deactivate(string $name): void
{
$plugin = $this->all()[$name] ?? throw new \InvalidArgumentException('Dieses Plugin gibt es nicht.');
$this->write($name, ['active' => false, 'version' => $plugin['version'], 'activated_at' => null]);
}
/**
* Speichert die Einstellungen eines Plugins, geprüft gegen das Schema seines Manifests.
*
* @throws \InvalidArgumentException
*/
public function saveSettings(string $name, array $input): void
{
$plugin = $this->all()[$name] ?? throw new \InvalidArgumentException('Dieses Plugin gibt es nicht.');
if (null !== $plugin['error']) {
throw new \InvalidArgumentException('Das Plugin lässt sich nicht einstellen: '.$plugin['error']);
}
try {
$values = $this->validateAgainst($plugin['settings'], $input, true);
} catch (InvalidDocument $e) {
throw new \InvalidArgumentException($e->getMessage());
}
$this->write($name, ['settings' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)]);
}
/** Die Einstellungen eines Plugins mit Vorgaben - leer, wenn es das Plugin nicht gibt. */
public function settingsOf(string $name): array
{
return $this->all()[$name]['values'] ?? [];
}
/** @param array<string, mixed> $fields */
private function write(string $name, array $fields): void
{
$existing = $this->rows()[$name] ?? null;
if (null === $existing) {
$this->db->insert('plugin', ['name' => $name, 'active' => false, 'version' => '', 'activated_at' => null, 'settings' => '{}', ...$fields]);
} else {
$this->db->update('plugin', $fields, ['name' => $name]);
}
$this->manifests = null;
$this->rows = null;
}
/** Die Bausteine aller aktiven Plugins, nach Typ. @return array<string, array> */
public function blocks(): array
{
$blocks = [];
foreach ($this->all() as $plugin) {
if (!$plugin['active'] || null !== $plugin['error']) {
continue;
}
foreach ($plugin['blocks'] as $block) {
$blocks[$block['type']] = $block;
}
}
return $blocks;
}
/** Die Definition eines Bausteins, wenn sein Plugin aktiv ist. */
public function block(string $type): ?array
{
return $this->blocks()[$type] ?? null;
}
/** Eine eigene Adresse eines aktiven Plugins, zum Beispiel /vergleich/daten - oder null. */
public function route(string $path): ?array
{
$path = '/'.ltrim($path, '/');
foreach ($this->all() as $plugin) {
if (!$plugin['active'] || null !== $plugin['error']) {
continue;
}
foreach ($plugin['routes'] as $route) {
if ($route['path'] === $path) {
return $route;
}
}
}
return null;
}
/**
* Prüft die Eigenschaften eines Bausteins gegen sein Schema und gibt sie bereinigt zurück.
*
* Fehlende Eigenschaften bekommen ihren Vorgabewert; unbekannte werden ignoriert, damit ein
* Plugin eine Eigenschaft streichen kann, ohne alte Seiten unlesbar zu machen. Was da ist,
* muss passen - sonst InvalidDocument, wie bei jedem Kernbaustein.
*
* @throws InvalidDocument
*/
public function validateProps(string $type, array $props): array
{
$block = $this->block($type) ?? throw new InvalidDocument(sprintf('Der Baustein „%s“ gehört zu keinem aktiven Plugin.', $type));
return $this->validateAgainst($block['props'], $props, false);
}
/**
* Werte gegen ein Schema. Aus einem Formular der Verwaltung kommen Zahlen und Schalter als
* Text - dann wird umgewandelt; aus einem Dokument müssen die Typen schon stimmen.
*
* @throws InvalidDocument
*/
private function validateAgainst(array $schemaMap, array $values, bool $fromForm): array
{
$clean = [];
foreach ($schemaMap as $key => $schema) {
$value = $values[$key] ?? ($fromForm && 'boolean' === $schema['kind'] ? false : $schema['default']);
$label = $schema['label'];
if ($fromForm) {
if ('number' === $schema['kind'] && is_string($value)) {
$value = is_numeric(str_replace(',', '.', trim($value))) ? str_replace(',', '.', trim($value)) + 0 : $value;
}
if ('boolean' === $schema['kind']) {
$value = (bool) $value;
}
}
switch ($schema['kind']) {
case 'text':
case 'textarea':
case 'url':
if (!is_string($value)) {
throw new InvalidDocument(sprintf('„%s“ muss Text sein.', $label));
}
$value = trim($value);
if (mb_strlen($value) > $schema['maxLength']) {
throw new InvalidDocument(sprintf('„%s“ ist auf %d Zeichen begrenzt.', $label, $schema['maxLength']));
}
if ('url' === $schema['kind'] && '' !== $value && 1 !== preg_match('~^(/(?!/)|#[a-zA-Z]|https://|mailto:|tel:)~', $value)) {
throw new InvalidDocument(sprintf('„%s“ muss ein lokaler Pfad, eine Sprungmarke oder eine HTTPS-Adresse sein.', $label));
}
break;
case 'number':
if (!is_int($value) && !is_float($value)) {
throw new InvalidDocument(sprintf('„%s“ muss eine Zahl sein.', $label));
}
if ($value < $schema['min'] || $value > $schema['max']) {
throw new InvalidDocument(sprintf('„%s“ liegt zwischen %s und %s.', $label, $schema['min'], $schema['max']));
}
break;
case 'boolean':
if (!is_bool($value)) {
throw new InvalidDocument(sprintf('„%s“ ist an oder aus.', $label));
}
break;
case 'select':
if (!is_string($value) || !array_key_exists($value, $schema['choices'])) {
throw new InvalidDocument(sprintf('„%s“ kennt diesen Wert nicht.', $label));
}
break;
case 'media':
if (!is_string($value) || ('' !== $value && 1 !== preg_match('/^[a-f0-9]{32}$/D', $value))) {
throw new InvalidDocument(sprintf('„%s“ muss ein Bild aus der Mediathek sein.', $label));
}
break;
}
$clean[$key] = $value;
}
return $clean;
}
/** Was der Editor braucht: Typ, Beschriftung, Symbol, Container-Regeln und das Schema mit Vorgaben. @return list<array> */
public function forEditor(): array
{
return array_values(array_map(static fn (array $block): array => [
'type' => $block['type'], 'label' => $block['label'], 'icon' => $block['icon'], 'plugin' => $block['plugin'],
'description' => $block['description'], 'props' => $block['props'],
'container' => $block['container'], 'children' => $block['children'],
], $this->blocks()));
}
/** Die Seitenvorlagen der aktiven Plugins für den Werkzeugkasten des Editors. @return list<array> */
public function presetsForEditor(): array
{
$presets = [];
foreach ($this->all() as $plugin) {
if (!$plugin['active'] || null !== $plugin['error']) {
continue;
}
foreach ($plugin['presets'] as $preset) {
$presets[] = ['id' => 'plugin:'.$plugin['name'].':'.$preset['name'], 'label' => $preset['label'], 'description' => $preset['description'], 'plugin' => $plugin['title'], 'blocks' => $preset['blocks']];
}
}
return $presets;
}
/**
* Stylesheets und Skripte der Plugin-Bausteine, die in diesen Dokumenten vorkommen - jede
* Datei einmal, in der Reihenfolge des ersten Auftretens.
*
* @param list<array> $documents
*
* @return array{styles: list<string>, scripts: list<string>}
*/
public function assetsFor(array $documents): array
{
$blocks = $this->blocks();
if ([] === $blocks) {
return ['styles' => [], 'scripts' => []];
}
$styles = [];
$scripts = [];
$walk = static function (array $list) use (&$walk, &$styles, &$scripts, $blocks): void {
foreach ($list as $block) {
$definition = $blocks[$block['type'] ?? ''] ?? null;
if (null !== $definition) {
if (null !== $definition['css']) {
$styles[$definition['css']] = true;
}
if (null !== $definition['script']) {
$scripts[$definition['script']] = true;
}
}
$walk($block['children'] ?? []);
}
};
foreach ($documents as $document) {
$walk($document['blocks'] ?? []);
}
return ['styles' => array_keys($styles), 'scripts' => array_keys($scripts)];
}
/** Der Pfad einer Datei, die ein aktives Plugin ausliefern darf - oder null. */
public function assetFile(string $plugin, string $relative): ?string
{
$definition = $this->all()[$plugin] ?? null;
if (null === $definition || !$definition['active'] || null !== $definition['error']) {
return null;
}
if (1 !== preg_match('~^assets/[a-zA-Z0-9_./-]{1,120}$~D', $relative) || str_contains($relative, '..')) {
return null;
}
$extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
if (!isset(self::ASSET_TYPES[$extension])) {
return null;
}
$file = $this->directory().'/'.$plugin.'/'.$relative;
return is_file($file) ? $file : null;
}
/** Liest und prüft ein Manifest. @throws \RuntimeException */
private function manifest(string $file, string $name): array
{
if (1 !== preg_match(self::NAME, $name)) {
throw new \RuntimeException('Der Ordnername darf nur Kleinbuchstaben und Ziffern enthalten und muss mit einem Buchstaben beginnen.');
}
try {
$data = json_decode((string) file_get_contents($file), true, 24, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \RuntimeException('plugin.json ist kein gültiges JSON: '.$e->getMessage());
}
if (!is_array($data) || ($data['name'] ?? null) !== $name) {
throw new \RuntimeException(sprintf('plugin.json muss "name": "%s" enthalten, so wie der Ordner heißt.', $name));
}
$title = trim((string) ($data['title'] ?? ''));
if ('' === $title || mb_strlen($title) > 80) {
throw new \RuntimeException('Das Manifest braucht einen Titel mit höchstens 80 Zeichen.');
}
$version = (string) ($data['version'] ?? '');
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $version)) {
throw new \RuntimeException('Die Fassung des Plugins hat die Form 1.2.3.');
}
$requires = (string) ($data['requires'] ?? '');
if ('' !== $requires) {
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $requires)) {
throw new \RuntimeException('"requires" nennt die kleinste KantSites-Fassung in der Form 1.2.3.');
}
$installed = trim((string) @file_get_contents($this->projectDir.'/VERSION'));
if ('' !== $installed && version_compare($installed, $requires, '<')) {
throw new \RuntimeException(sprintf('Braucht KantSites %s, installiert ist %s.', $requires, $installed));
}
}
$blocks = [];
foreach ((array) ($data['blocks'] ?? []) as $key => $block) {
if (!is_string($key) || 1 !== preg_match(self::BLOCK, $key) || !is_array($block)) {
throw new \RuntimeException(sprintf('Der Bausteinname „%s“ darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.', (string) $key));
}
$blocks[$name.'.'.$key] = $this->blockDefinition($name, $key, $block, $version);
}
// Erlaubte Kinder eines Containers muessen Bausteine sein, die es gibt: Kernbausteine
// oder Bausteine dieses Plugins.
foreach ($blocks as $type => $block) {
foreach ($block['children'] ?? [] as $child) {
if (!isset($blocks[$child]) && !str_contains($child, '.') && 1 !== preg_match('/^[a-z]+$/D', $child)) {
throw new \RuntimeException(sprintf('„%s“ erlaubt das Kind „%s“, das es nicht gibt.', $type, $child));
}
}
}
$settings = [];
if (!is_array($data['settings'] ?? [])) {
throw new \RuntimeException('"settings" ist kein Objekt.');
}
foreach ((array) ($data['settings'] ?? []) as $key => $schema) {
$settings[$key] = $this->propSchema('Einstellungen', (string) $key, is_array($schema) ? $schema : []);
}
$routes = $this->routes($name, $data['routes'] ?? []);
$presets = $this->presets($name, $data['presets'] ?? []);
if ([] === $blocks && [] === $routes && [] === $presets) {
throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage.');
}
return ['name' => $name, 'title' => $title, 'description' => mb_substr(trim((string) ($data['description'] ?? '')), 0, 300), 'version' => $version, 'requires' => $requires, 'blocks' => $blocks, 'settings' => $settings, 'routes' => $routes, 'presets' => $presets];
}
private function blockDefinition(string $plugin, string $key, array $block, string $version): array
{
$label = trim((string) ($block['label'] ?? ''));
if ('' === $label || mb_strlen($label) > 60) {
throw new \RuntimeException(sprintf('Der Baustein „%s“ braucht eine Beschriftung mit höchstens 60 Zeichen.', $key));
}
$template = (string) ($block['template'] ?? '');
if (1 !== preg_match('~^[a-zA-Z0-9_/-]+\.html\.twig$~D', $template) || str_contains($template, '..') || !is_file($this->directory().'/'.$plugin.'/'.$template)) {
throw new \RuntimeException(sprintf('Die Vorlage „%s“ des Bausteins „%s“ fehlt im Pluginordner.', $template, $key));
}
$assets = [];
foreach (['css', 'script'] as $art) {
$relative = $block[$art] ?? null;
if (null === $relative || '' === $relative) {
$assets[$art] = null;
continue;
}
$extension = 'css' === $art ? 'css' : 'js';
if (!is_string($relative) || 1 !== preg_match('~^assets/[a-zA-Z0-9_./-]+\.'.$extension.'$~D', $relative) || str_contains($relative, '..') || !is_file($this->directory().'/'.$plugin.'/'.$relative)) {
throw new \RuntimeException(sprintf('Die Datei „%s“ des Bausteins „%s“ fehlt oder liegt nicht unter assets/.', (string) $relative, $key));
}
$assets[$art] = '/plugin-assets/'.$plugin.'/'.$relative.'?v='.$version;
}
$props = [];
if (!is_array($block['props'] ?? [])) {
throw new \RuntimeException(sprintf('"props" des Bausteins „%s“ ist kein Objekt.', $key));
}
if (count($block['props'] ?? []) > self::MAX_PROPS) {
throw new \RuntimeException(sprintf('Der Baustein „%s“ hat mehr als %d Eigenschaften.', $key, self::MAX_PROPS));
}
foreach ($block['props'] ?? [] as $name => $schema) {
$props[$name] = $this->propSchema($key, (string) $name, is_array($schema) ? $schema : []);
}
// Ein Container nimmt Kinder auf - alle Bausteine oder nur die genannten.
$container = (bool) ($block['container'] ?? false);
$children = null;
if ($container && isset($block['children'])) {
if (!is_array($block['children']) || [] === $block['children']) {
throw new \RuntimeException(sprintf('"children" des Bausteins „%s“ ist eine Liste erlaubter Bausteintypen.', $key));
}
$children = [];
foreach ($block['children'] as $child) {
$child = (string) $child;
if (1 !== preg_match('/^[a-z]+(?:\.[a-z][a-z0-9-]{1,40})?$/D', $child)) {
throw new \RuntimeException(sprintf('„%s“ ist kein Bausteintyp.', $child));
}
$children[] = $child;
}
}
return [
'type' => $plugin.'.'.$key, 'plugin' => $plugin, 'label' => $label,
'description' => mb_substr(trim((string) ($block['description'] ?? '')), 0, 200),
'icon' => mb_substr(trim((string) ($block['icon'] ?? '')), 0, 2) ?: '⬡',
'template' => '@plugins/'.$plugin.'/'.$template,
'css' => $assets['css'], 'script' => $assets['script'],
'props' => $props,
'container' => $container, 'children' => $children,
];
}
/** Eigene Adressen: Pfad wie eine Seite, Vorlage im Pluginordner, Antwortart aus der Liste. */
private function routes(string $plugin, mixed $input): array
{
if (!is_array($input)) {
throw new \RuntimeException('"routes" ist kein Objekt.');
}
$routes = [];
foreach ($input as $key => $route) {
if (!is_string($key) || 1 !== preg_match(self::BLOCK, $key) || !is_array($route)) {
throw new \RuntimeException(sprintf('Der Adressname „%s“ darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.', (string) $key));
}
$path = (string) ($route['path'] ?? '');
if (1 !== preg_match(self::ROUTE_PATH, $path)) {
throw new \RuntimeException(sprintf('Die Adresse „%s“ hat nicht die Form /name oder /name/unterseite.', $path));
}
// Was der Kern selbst beantwortet, darf ein Plugin nicht belegen.
if (1 === preg_match('~^/(admin|media|assets|plugin-assets|formular|kategorie|schlagwort|beitraege|einwilligung)(/|$)~', $path)) {
throw new \RuntimeException(sprintf('Die Adresse „%s“ gehört dem Kern.', $path));
}
$template = (string) ($route['template'] ?? '');
if (1 !== preg_match('~^[a-zA-Z0-9_/-]+(?:\.[a-z]+)?\.twig$~D', $template) || str_contains($template, '..') || !is_file($this->directory().'/'.$plugin.'/'.$template)) {
throw new \RuntimeException(sprintf('Die Vorlage „%s“ der Adresse „%s“ fehlt im Pluginordner.', $template, $key));
}
$type = (string) ($route['type'] ?? 'html');
if (!isset(self::ROUTE_TYPES[$type])) {
throw new \RuntimeException(sprintf('Die Antwortart „%s“ der Adresse „%s“ ist keine von html, json, text, xml.', $type, $key));
}
$routes[$key] = ['name' => $key, 'plugin' => $plugin, 'path' => $path, 'template' => '@plugins/'.$plugin.'/'.$template, 'type' => $type, 'contentType' => self::ROUTE_TYPES[$type]];
}
return $routes;
}
/** Seitenvorlagen: Bausteine, die der Editor als Ganzes einfügt. Geprüft wird beim Einfügen wie jedes Dokument. */
private function presets(string $plugin, mixed $input): array
{
if (!is_array($input)) {
throw new \RuntimeException('"presets" ist kein Objekt.');
}
$presets = [];
foreach ($input as $key => $preset) {
if (!is_string($key) || 1 !== preg_match(self::BLOCK, $key) || !is_array($preset)) {
throw new \RuntimeException(sprintf('Der Vorlagenname „%s“ darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.', (string) $key));
}
$label = trim((string) ($preset['label'] ?? ''));
if ('' === $label || mb_strlen($label) > 60) {
throw new \RuntimeException(sprintf('Die Vorlage „%s“ braucht eine Beschriftung mit höchstens 60 Zeichen.', $key));
}
if (!is_array($preset['blocks'] ?? null) || !array_is_list($preset['blocks']) || [] === $preset['blocks']) {
throw new \RuntimeException(sprintf('Die Vorlage „%s“ braucht eine Liste von Bausteinen unter "blocks".', $key));
}
if (strlen(json_encode($preset['blocks'], JSON_THROW_ON_ERROR)) > 65536) {
throw new \RuntimeException(sprintf('Die Vorlage „%s“ ist größer als 64 KiB.', $key));
}
$presets[] = ['name' => $key, 'label' => $label, 'description' => mb_substr(trim((string) ($preset['description'] ?? '')), 0, 200), 'blocks' => $preset['blocks']];
}
return $presets;
}
private function propSchema(string $block, string $name, array $schema): array
{
if (1 !== preg_match('/^[a-z][a-zA-Z0-9]{0,31}$/D', $name)) {
throw new \RuntimeException(sprintf('Die Eigenschaft „%s“ von „%s“ braucht einen Namen aus Buchstaben und Ziffern.', $name, $block));
}
$kind = (string) ($schema['kind'] ?? 'text');
if (!in_array($kind, self::KINDS, true)) {
throw new \RuntimeException(sprintf('Die Eigenschaft „%s“ von „%s“ hat die unbekannte Art „%s“.', $name, $block, $kind));
}
$label = trim((string) ($schema['label'] ?? $name));
$clean = ['kind' => $kind, 'label' => mb_substr($label, 0, 80), 'help' => mb_substr(trim((string) ($schema['help'] ?? '')), 0, 200)];
switch ($kind) {
case 'text':
case 'url':
$clean['maxLength'] = max(1, min(2000, (int) ($schema['maxLength'] ?? 200)));
$clean['default'] = mb_substr((string) ($schema['default'] ?? ''), 0, $clean['maxLength']);
break;
case 'textarea':
$clean['maxLength'] = max(1, min(10000, (int) ($schema['maxLength'] ?? 2000)));
$clean['default'] = mb_substr((string) ($schema['default'] ?? ''), 0, $clean['maxLength']);
break;
case 'number':
$clean['min'] = is_numeric($schema['min'] ?? null) ? $schema['min'] + 0 : 0;
$clean['max'] = is_numeric($schema['max'] ?? null) ? $schema['max'] + 0 : 1000;
if ($clean['min'] >= $clean['max']) {
throw new \RuntimeException(sprintf('Bei „%s“ von „%s“ muss min unter max liegen.', $name, $block));
}
$default = is_numeric($schema['default'] ?? null) ? $schema['default'] + 0 : $clean['min'];
$clean['default'] = max($clean['min'], min($clean['max'], $default));
break;
case 'boolean':
$clean['default'] = (bool) ($schema['default'] ?? false);
break;
case 'select':
$choices = [];
foreach ((array) ($schema['choices'] ?? []) as $value => $text) {
$value = (string) $value;
if ('' !== $value && mb_strlen($value) <= 40) {
$choices[$value] = mb_substr(trim((string) $text), 0, 60) ?: $value;
}
}
if (count($choices) < 2) {
throw new \RuntimeException(sprintf('Die Auswahl „%s“ von „%s“ braucht mindestens zwei Werte.', $name, $block));
}
$clean['choices'] = $choices;
$default = (string) ($schema['default'] ?? array_key_first($choices));
$clean['default'] = array_key_exists($default, $choices) ? $default : array_key_first($choices);
break;
case 'media':
$clean['default'] = '';
break;
}
return $clean;
}
}