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

+111
View File
@@ -924,6 +924,117 @@ final class CmsTest extends TestCase
self::assertStringContainsString('frame-src https://challenges.cloudflare.com', $csp);
}
public function testAPictureCanBeEnlargedAndWorksWithoutScripting(): void
{
// Umut: „Bilder vergrößern Funktion, ein Popup-Viewer - gerne auch auswählbar wie bei
// WordPress." Auswählbar heißt: je Bild entscheidet sich, was ein Klick tut. Und ohne
// Skript bleibt es benutzbar: dann öffnet der Verweis schlicht die große Fassung.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-zoom-');
$image = imagecreatetruecolor(1600, 900);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'motiv.png', test: true));
@unlink($file);
$quellen = array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $item['variants']);
$gross = end($quellen)['src'];
$bild = static fn (string $id, array $props): array => ['id' => $id, 'type' => 'image', 'props' => [
'src' => $quellen[0]['src'], 'alt' => 'Ein Motiv', 'decorative' => false,
'width' => 1600, 'height' => 900, 'sources' => $quellen, ...$props,
]];
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 19;
$document['blocks'][] = ['id' => 'galerie', 'type' => 'gallery', 'props' => ['lightbox' => true], 'children' => [
$bild('g1', ['click' => 'vergroessern', 'caption' => 'Erstes Bild']),
$bild('g2', ['click' => 'vergroessern']),
]];
$document['blocks'][] = $bild('einzeln', ['click' => 'vergroessern']);
$document['blocks'][] = $bild('ruhig', []);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$antwort = $this->request('/');
$html = $antwort->getContent();
// Der Verweis zeigt auf die größte Fassung - ohne Skript ist genau das die Vergrößerung.
self::assertStringContainsString('<a class="image-link image-zoom" href="'.$gross.'" data-zoom', $html);
self::assertStringContainsString('data-caption="Erstes Bild"', $html);
// Die Galerie ist als Gruppe gekennzeichnet, damit sich darin blättern lässt.
self::assertStringContainsString('data-zoom-group', $html);
// Ein Bild ohne Klickwirkung bleibt, was es war.
self::assertMatchesRegularExpression('#<figure id="ruhig".{0,200}<img#s', $html);
// Drei Bilder mit Klickwirkung, das vierte bleibt unberührt.
self::assertSame(3, substr_count($html, 'image-zoom'));
// Das Skript kommt nur auf Seiten mit Betrachter, und die Richtlinie erlaubt es dort.
self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html);
self::assertStringContainsString("script-src 'self'", (string) $antwort->headers->get('Content-Security-Policy'));
// Eine Seite ohne vergrößerbares Bild lädt es nicht und bleibt ohne Skript.
$ohne = DemoContent::starter('Startseite');
$ohne['schemaVersion'] = 19;
$ohne['blocks'][] = $bild('ruhig', []);
$version = $this->pages->findForEditor('page-1')['version'];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $ohne, $version), $version + 1);
$zweite = $this->request('/');
self::assertStringNotContainsString('lightbox.js', $zweite->getContent());
self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
}
public function testTheLegalLineCanLiveInsideTheFooterInsteadOfUnderneathIt(): void
{
// Umut: „ganz unten Einwilligung ändern, Datenschutz usw. ist links, komplett bitte besser
// lösen." Die eingebaute Leiste klebte als heller Streifen unter einem dunklen Fuß. Als
// Baustein steht sie dort, wo sie hingehört, und erbt die Farben ihres Bereichs.
// Zwei verschiedene Seiten: die Zeile fuehrt zu Datenschutz und Impressum.
$datenschutz = $this->pages->createPage('datenschutz', null, 'page', DemoContent::starter('Datenschutz'));
$seite = $this->pages->findForEditor($datenschutz);
$this->pages->publish($datenschutz, $seite['draft_revision_id'], $seite['version']);
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'privacyPath' => 'datenschutz', 'imprintPath' => 'home']);
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 19;
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
// Ohne Baustein: die eingebaute Leiste am Seitenende.
$html = $this->request('/')->getContent();
self::assertStringContainsString('<nav class="legal-bar"', $html);
// Mit Baustein: nur noch dieser, und zwar an seiner Stelle im Dokument.
$document['blocks'][] = ['id' => 'rechtszeile', 'type' => 'legal', 'props' => []];
$version = $this->pages->findForEditor('page-1')['version'];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
$mit = $this->request('/')->getContent();
self::assertStringContainsString('<nav id="rechtszeile" class="block-legal"', $mit);
self::assertStringNotContainsString('<nav class="legal-bar"', $mit, 'Zweimal dieselbe Zeile ist schlechter als einmal.');
self::assertStringContainsString('Datenschutz', $mit);
}
public function testNoStylesheetHasADanglingSelector(): void
{
// Gefunden, als der Einwilligungsdialog plötzlich ungestaltet unter dem Fuß stand: beim
// Entfernen alter Regeln war der Selektorrest stehen geblieben - „.site-header" allein in
// einer Zeile. CSS liest dann weiter, hängt den nächsten Selektor an und verschluckt
// dessen Regel. Vier Veröffentlichungen lang hat das niemand bemerkt; ein Stylesheet
// beschwert sich nicht, es wirkt nur nicht mehr.
foreach (glob(__DIR__.'/../public/assets/*.css') ?: [] as $datei) {
$ohneKommentare = preg_replace('#/\*.*?\*/#s', '', (string) file_get_contents($datei)) ?? '';
foreach (preg_split('/\R/', $ohneKommentare) ?: [] as $nummer => $zeile) {
$zeile = rtrim($zeile);
if ('' === trim($zeile)) {
continue;
}
// Eine Zeile, die wie ein Selektor beginnt, muss auch wie einer enden: mit „{",
// mit einem Komma (weitere Selektoren folgen) oder mit einer Deklaration.
$beginntWieSelektor = 1 === preg_match('/^[.#a-zA-Z\[:*]/', trim($zeile));
$endetSauber = 1 === preg_match('/[{},;]$/', $zeile) || str_contains($zeile, '{');
self::assertTrue(
!$beginntWieSelektor || $endetSauber,
sprintf('%s Zeile %d endet mitten im Selektor: „%s“', basename($datei), $nummer + 1, trim($zeile)),
);
}
}
}
public function testEveryPublicAnswerCarriesItsProtectiveHeaders(): void
{
// Diese Kopfzeilen fehlen lautlos, wenn jemand eine Antwort an der Stelle vorbeibaut, die