Files
KantSites/src/Content/StyleBundle.php
T
kantarciandClaude Opus 5 074ef0e7e5
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
Eigener Suchtitel, Schriften lokal, Bilder in Anzeigegroesse
Getrennter Titel fuer Suchergebnisse je Seite und Beitrag (Migration
20260913000100): auf der Seite steht "Leistungen", im Suchergebnis darf
der lange Titel stehen. Leer heisst weiterhin: der Titel der Seite.

Schriften kommen jetzt vom eigenen Server. Ein Knopf im Designbereich
oder cms:fonts:fetch holt die gewaehlten Google-Schnitte als WOFF2 nach
public/assets/fonts. Das spart zwei fremde Verbindungen nacheinander
(gemessen 820 ms) und die IP-Weitergabe an Google. Ohne Dateien bleibt
es beim bisherigen Verweis - eine Seite ohne Schrift waere schlimmer.

Ein Stylesheet statt vier, mit Pruefsumme im Namen und ohne Kommentare,
dazu eine .htaccess mit Kompression, Cache und Schutz interner Dateien.
Der Entwicklungsserver sendet dieselben Kopfzeilen, sonst misst man
lokal etwas, das es im Betrieb nicht gibt.

Bilder in der Groesse, in der sie erscheinen: ein nur fuer Desktop
gesetztes Hintergrundbild gilt auf dem Telefon in Telefongroesse
(189 KB -> 40 KB), img nennt seine feste Breite oder sizes="auto", und
fetchpriority liegt auf dem ersten Bild des Inhalts statt auf dem Logo.

Neu: npm run test:links prueft jeden internen Verweis auf seine
Antwort. Lighthouse gegen den Nachbau: Barrierefreiheit, Best
Practices und SEO 100 auf allen vier geprueften Adressen, Performance
100 ausser auf der bilderreichen Startseite (98 mobil, 96 Desktop).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:11:46 +02:00

127 lines
4.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Content;
/**
* Die Stylesheets einer öffentlichen Seite als eine Datei.
*
* Getrennte Dateien sind beim Schreiben richtig – `site.css` das Grundgerüst, `design.css` die
* Gestaltungswerte, `enhancements.css` die Bewegung, `fonts/fonts.css` die Schriften. Beim
* Ausliefern sind sie vier Anfragen, die alle vier das Zeichnen der Seite aufhalten, und auf einer
* Mobilfunkverbindung ist jede davon eine eigene Wartezeit.
*
* Zusammengelegt wird deshalb einmal in eine Datei, deren Name die Prüfsumme des Inhalts trägt:
* ändert sich eine Quelle, ändert sich der Name, und kein Browser zeigt altes Aussehen. Entfernt
* werden dabei die Kommentare – sie erklären den Quelltext, nicht den Browser.
*
* Schlägt das Schreiben fehl (kein Schreibrecht im Ordner), bleibt es bei den Einzeldateien: eine
* Website ohne Bündel ist langsamer, eine Website ohne Stylesheet ist kaputt.
*/
final class StyleBundle
{
/** Die Reihenfolge ist die Reihenfolge im Kopf der Seite - später gewinnt. */
private const SOURCES = ['fonts/fonts.css', 'site.css', 'design.css', 'enhancements.css'];
public function __construct(private string $assetDirectory)
{
}
/**
* Die Adresse des Bündels, oder null wenn es keines gibt.
*
* Erzeugt wird beim ersten Aufruf nach einer Änderung. Das kostet einen Aufruf lang ein paar
* Millisekunden und spart danach jedem Besucher drei Anfragen.
*/
public function url(bool $withFonts = true): ?string
{
$sources = $this->sources($withFonts);
if ([] === $sources) {
return null;
}
$name = 'kant-'.substr(hash('xxh128', implode('|', array_map(
static fn (string $file): string => $file.':'.filemtime($file).':'.filesize($file),
$sources,
))), 0, 16).'.css';
$path = $this->assetDirectory.'/build/'.$name;
if (!is_file($path) && !$this->write($path, $sources)) {
return null;
}
return '/assets/build/'.$name;
}
/** @return list<string> */
private function sources(bool $withFonts): array
{
$found = [];
foreach (self::SOURCES as $source) {
if ('fonts/fonts.css' === $source && !$withFonts) {
continue;
}
$path = $this->assetDirectory.'/'.$source;
if (is_file($path)) {
$found[] = $path;
}
}
// Ohne das Grundgerüst kein Bündel: dann stimmt etwas anderes nicht.
return in_array($this->assetDirectory.'/site.css', $found, true) ? $found : [];
}
/** @param list<string> $sources */
private function write(string $path, array $sources): bool
{
$directory = dirname($path);
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
return false;
}
$css = '';
foreach ($sources as $source) {
$css .= $this->minify((string) file_get_contents($source))."\n";
}
// Erst daneben schreiben, dann umbenennen: ein zweiter Aufruf im selben Moment soll nie
// eine halb geschriebene Datei ausliefern.
$temporary = $path.'.'.bin2hex(random_bytes(4));
if (false === @file_put_contents($temporary, $css) || !@rename($temporary, $path)) {
@unlink($temporary);
return false;
}
$this->clean($directory, basename($path));
return true;
}
/**
* Kommentare raus, Einrückung raus - sonst nichts.
*
* Kein Umbau von Selektoren, kein Zusammenfassen von Regeln: ein Minifier, der die Bedeutung
* ändert, spart ein paar Zeichen und kostet einen Abend Fehlersuche. Leerzeichen innerhalb
* einer Zeile bleiben deshalb stehen – in `calc(100% - 20px)` und in `.a :hover` tragen sie
* Bedeutung.
*/
private function minify(string $css): string
{
$withoutComments = preg_replace('#/\*(?!!).*?\*/#s', '', $css) ?? $css;
$lines = [];
foreach (preg_split('/\R/', $withoutComments) ?: [] as $line) {
$line = trim($line);
if ('' !== $line) {
$lines[] = $line;
}
}
return implode("\n", $lines);
}
/** Ältere Bündel wegräumen; sie werden von niemandem mehr angefragt. */
private function clean(string $directory, string $keep): void
{
foreach (glob($directory.'/kant-*.css') ?: [] as $old) {
if (basename($old) !== $keep && filemtime($old) < time() - 3600) {
@unlink($old);
}
}
}
}