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