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>
127 lines
4.5 KiB
PHP
127 lines
4.5 KiB
PHP
<?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);
|
||
}
|
||
}
|
||
}
|
||
}
|