Files
KantSites/src/Content/StyleBundle.php
T
kantarci 3391fea58b
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
Add visual button presets, jump targets and menu controls
2026-09-14 13:47:49 +02:00

127 lines
4.6 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', 'buttons.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);
}
}
}
}