Knopf nach oben, Bildbetrachter, Rechtszeile als Baustein (Schema v19)
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

Der Knopf "nach oben" ist ein Anker, kein Skript: er springt an den
Seitenanfang, eingeblendet ueber eine Scroll-Zeitachse. Abschaltbar und
vollstaendig einstellbar - Seite, Form, Groesse, Farben, Schwelle,
Beschriftung.

Bilder lassen sich vergroessern, je Bild waehlbar (nichts / vergroessern
/ Adresse), Galerien mit einem Schalter. Grundlage ist ein gewoehnlicher
Verweis auf die grosse Fassung: ohne JavaScript oeffnet er das Bild, mit
JavaScript einen dialog-Betrachter mit Blaettern, Escape und Pfeiltasten.
Das Skript laedt nur, wo es etwas zu tun hat.

Die Rechtszeile wird ein Baustein und steht damit im Fussbereich statt
als heller Streifen darunter. Die eingebaute Leiste erscheint nur noch,
wenn die Seite den Baustein nicht enthaelt.

Dabei gefunden: zwei Stylesheet-Regeln griffen seit drei
Veroeffentlichungen nicht mehr. Beim Entfernen alter Regeln waren
Selektorreste stehen geblieben, und CSS nimmt dann die naechste Regel
mit - betroffen waren der Einwilligungsdialog und die Rechtszeile. Ein
Test prueft jetzt jede CSS-Datei auf abgebrochene Selektoren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-13 19:35:43 +02:00
1 parent 74766f6684
commit de1e66350b
13 files changed
+464 -18

No files matched your search

+14 -2
View File
@@ -29,6 +29,8 @@ final class DocumentValidator
public const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
/** Schema v18: die Navigation zwischen Beiträgen, vom Renderer gefüllt. */
public const V18_TYPES = ['postnav'];
/** Die Rechtszeile als Baustein: gestaltbar und dort, wo sie hingehoert. */
public const V19_TYPES = ['legal'];
/** Schema v15: a single icon, a progress bar and a slider that works without a script. */
public const V15_TYPES = ['icon', 'progress', 'slider'];
@@ -40,7 +42,7 @@ final class DocumentValidator
public function validate(array $document, bool $requireHeading = true): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], true), 'Unsupported document schema.');
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], true), 'Unsupported document schema.');
$this->text($document['title'] ?? null, 160);
$this->text($document['description'] ?? null, 320);
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
@@ -68,7 +70,7 @@ final class DocumentValidator
// page: a deactivated plugin must never make stored content unreadable. Nothing from
// props is rendered for such a block, so unvalidated values never reach the output.
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
// else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
@@ -292,6 +294,10 @@ final class DocumentValidator
// so a stored document can never redirect a submission or change what is asked.
$this->require(is_string($props['form'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80, 'Invalid form address.');
}
if ('gallery' === $type && isset($props['lightbox'])) {
$this->require($schemaVersion >= 19, 'Der Bildbetrachter braucht Schema v19.');
$this->require(is_bool($props['lightbox']), 'Der Bildbetrachter ist an oder aus.');
}
if ('image' === $type) {
$this->url($props['src'] ?? null);
// Ein Bild darf verlinken. Klingt selbstverstaendlich, fehlte aber: ein Logo im
@@ -300,6 +306,12 @@ final class DocumentValidator
$this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.');
$this->url($props['href']);
}
// Was ein Klick auf das Bild tut. Ohne Angabe: nichts - wie bisher.
if (isset($props['click'])) {
$this->require($schemaVersion >= 19, 'Die Klickwirkung eines Bildes braucht Schema v19.');
$this->require(in_array($props['click'], ['keine', 'vergroessern', 'link'], true), 'Für den Klick sind „keine", „vergroessern" und „link" vorgesehen.');
$this->require('link' !== $props['click'] || '' !== ($props['href'] ?? ''), 'Ein Bild, das auf eine Adresse zeigen soll, braucht eine.');
}
$this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.');
$this->text($props['alt'] ?? null, 300, $props['decorative']);
$this->require(!$props['decorative'] || '' === $props['alt'], 'Decorative images need empty alternative text.');
+25
View File
@@ -34,6 +34,17 @@ final class SiteSettings
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim
// Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
'faviconMediaId' => '',
// Der Knopf „nach oben". Aus, weil eine kurze Seite ihn nicht braucht; alles daran ist
// einstellbar, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung
// passen muss. Eingeblendet wird er beim Scrollen - ohne eine Zeile JavaScript.
'scrollTop' => false,
'scrollTopPosition' => 'rechts',
'scrollTopShape' => 'rund',
'scrollTopSize' => 48,
'scrollTopColor' => '',
'scrollTopInk' => '',
'scrollTopLabel' => 'Nach oben',
'scrollTopAfter' => 400,
// Cloudflare Turnstile fuer Formulare. Aus, weil eine Website ohne Spamwelle keinen
// Dritten einbinden soll - und weil die Pruefung JavaScript verlangt.
'turnstile' => false,
@@ -138,6 +149,20 @@ final class SiteSettings
}
$values[$key] = $wert;
}
$values['scrollTop'] = (bool) ($input['scrollTop'] ?? false);
$values['scrollTopPosition'] = in_array($input['scrollTopPosition'] ?? '', ['rechts', 'links'], true) ? $input['scrollTopPosition'] : 'rechts';
$values['scrollTopShape'] = in_array($input['scrollTopShape'] ?? '', ['rund', 'weich', 'eckig'], true) ? $input['scrollTopShape'] : 'rund';
$values['scrollTopSize'] = max(36, min(72, (int) ($input['scrollTopSize'] ?? 48)));
// Ab wie vielen Pixeln er erscheint. Null heisst: sofort.
$values['scrollTopAfter'] = max(0, min(4000, (int) ($input['scrollTopAfter'] ?? 400)));
$values['scrollTopLabel'] = mb_substr(trim((string) ($input['scrollTopLabel'] ?? '')), 0, 60) ?: 'Nach oben';
foreach (['scrollTopColor', 'scrollTopInk'] as $feld) {
$farbe = trim((string) ($input[$feld] ?? ''));
if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) {
throw new InvalidPage('Die Farben des Knopfes „nach oben" werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.');
}
$values[$feld] = $farbe;
}
$values['turnstile'] = (bool) ($input['turnstile'] ?? false);
if ($values['turnstile'] && ('' === $values['turnstileSiteKey'] || '' === $values['turnstileSecret'])) {
// Eingeschaltet ohne Schluessel hiesse: jedes Formular lehnt jede Absendung ab.
+73 -2
View File
@@ -54,7 +54,7 @@ final class PageController extends AbstractController
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
$sections = $this->sections($page['document'], $areas);
$styles = $this->theme->css($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
}
@@ -89,6 +89,9 @@ final class PageController extends AbstractController
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
'needsSlider' => $this->hasAutoplaySlider($page['document']),
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
// Own scripts arrive as one file per page, never inline, so the policy can stay
// hash based and no page without a script loses its strict setting.
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
@@ -107,13 +110,18 @@ final class PageController extends AbstractController
'googleFonts' => $googleFonts,
'localFonts' => $localFonts,
'styleBundle' => $this->bundle->url([] !== $localFonts),
// Steht die Rechtszeile als Baustein im Dokument oder in einem Bereich, entfaellt
// die eingebaute Leiste am Seitenende - zweimal dasselbe waere schlechter als gar
// nichts, und die Pflicht ist mit dem Baustein erfuellt.
'hasLegalBlock' => $this->hasType($page['document'], 'legal')
|| (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasType($area, 'legal')),
]);
}
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
$mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com';
@@ -249,6 +257,50 @@ final class PageController extends AbstractController
*
* @return list<array>
*/
/**
* Die Gestaltungswerte des Knopfes „nach oben", als Variablen für das Stylesheet.
*
* Sie stehen in den Einstellungen und nicht im Theme, weil sie zur Website gehören und nicht
* zum Dokument - aber sie müssen in denselben Stilblock, dessen Prüfsumme in der
* Sicherheitsrichtlinie steht. Ein zweiter Block wäre ein zweiter Hash.
*/
private function scrollTopStyles(array $settings, array $theme): string
{
if (!($settings['scrollTop'] ?? false)) {
return '';
}
$farbe = static function (string $wert) use ($theme): ?string {
if ('' === $wert) {
return null;
}
if (!str_starts_with($wert, '@')) {
return $wert;
}
foreach ($theme['colors'] as $eintrag) {
if ($eintrag['key'] === substr($wert, 1)) {
return sprintf('var(--k-%s)', $eintrag['key']);
}
}
return null;
};
$werte = [sprintf('--kant-top-size:%dpx', (int) ($settings['scrollTopSize'] ?? 48))];
$werte[] = sprintf('--kant-top-radius:%s', match ($settings['scrollTopShape'] ?? 'rund') {
'eckig' => '0',
'weich' => '12px',
default => '999px',
});
$werte[] = sprintf('--kant-top-after:%dpx', (int) ($settings['scrollTopAfter'] ?? 400));
foreach (['scrollTopColor' => '--kant-top-bg', 'scrollTopInk' => '--kant-top-ink'] as $feld => $variable) {
$wert = $farbe((string) ($settings[$feld] ?? ''));
if (null !== $wert) {
$werte[] = $variable.':'.$wert;
}
}
return ':root{'.implode(';', $werte).'}';
}
/**
* Das Bild, das beim Teilen erscheint - und im strukturierten Datensatz.
*
@@ -323,6 +375,25 @@ final class PageController extends AbstractController
}
/** True when the stored document contains at least one block of that type. */
/** Enthaelt das Dokument ein Bild oder eine Galerie, die sich vergroessern laesst? */
private function hasZoom(array $document): bool
{
$gefunden = false;
$walk = function (array $blocks) use (&$walk, &$gefunden): void {
foreach ($blocks as $block) {
if ('vergroessern' === ($block['props']['click'] ?? null) || true === ($block['props']['lightbox'] ?? null)) {
$gefunden = true;
return;
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $gefunden;
}
/** True when a slider on this page asked to advance by itself. */
private function hasAutoplaySlider(array $document): bool
{