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>
This commit is contained in:
1 parent
35d8270afd
commit
074ef0e7e5
34 files changed
+1223
-45
No files matched your search
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\Theme;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Holt die Schriften des Designs auf den eigenen Server.
|
||||
*
|
||||
* Dasselbe wie der Knopf im Designbereich, für Installationen mit Konsolenzugang - und für den
|
||||
* Fall, dass eine frisch aufgesetzte Website die Dateien noch nicht hat.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:fonts:fetch', description: 'Lädt die Google-Schriften des Designs nach public/assets/fonts.')]
|
||||
final class FetchFontsCommand extends Command
|
||||
{
|
||||
public function __construct(private Theme $theme, private FontLibrary $fonts)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
try {
|
||||
$result = $this->fonts->fetch($this->theme->load());
|
||||
} catch (\RuntimeException $error) {
|
||||
$io->error($error->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$io->success(sprintf(
|
||||
'%d Datei(en) geladen, %d KB insgesamt: %s. Die Seiten laden jetzt nichts mehr von Google.',
|
||||
$result['files'],
|
||||
(int) round($result['bytes'] / 1024),
|
||||
implode(', ', $result['families']),
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
/**
|
||||
* Schriften, die die Website selbst ausliefert.
|
||||
*
|
||||
* Eine Schrift von fonts.googleapis.com kostet zweierlei. Technisch: der Browser muss erst das
|
||||
* Stylesheet holen, darin die Adresse der Schriftdatei finden und dann diese holen - zwei fremde
|
||||
* Verbindungen hintereinander, bevor die erste Zeile in der richtigen Schrift steht. Rechtlich:
|
||||
* dabei erfährt ein Dritter die IP-Adresse jedes Besuchers, und dafür gibt es in Deutschland
|
||||
* Abmahnungen (LG München I, 3 O 17493/20).
|
||||
*
|
||||
* Beides verschwindet, wenn die Dateien auf dem eigenen Server liegen. Die Lizenz erlaubt das:
|
||||
* die Schriften bei Google Fonts stehen unter der SIL Open Font License oder Apache 2.0.
|
||||
*
|
||||
* Heruntergeladen wird einmal auf Zuruf, nicht bei einem Seitenaufruf - ein Besucher soll nie auf
|
||||
* einen fremden Server warten, auch nicht mittelbar.
|
||||
*/
|
||||
final class FontLibrary
|
||||
{
|
||||
/**
|
||||
* Der Browser, als den wir uns ausgeben.
|
||||
*
|
||||
* Google liefert je nach Browser ein anderes Stylesheet: alten Browsern TTF, neuen WOFF2. Ohne
|
||||
* diese Kennung bekämen wir Dateien, die viermal so groß sind.
|
||||
*/
|
||||
private const AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
||||
|
||||
/** Zeichenbereiche, die eine deutschsprachige Website braucht. Kyrillisch und Vietnamesisch nicht. */
|
||||
private const SUBSETS = ['latin', 'latin-ext'];
|
||||
|
||||
public function __construct(private string $directory)
|
||||
{
|
||||
}
|
||||
|
||||
/** Liegen für dieses Theme fertige Dateien bereit? */
|
||||
public function hasLocal(array $theme): bool
|
||||
{
|
||||
return [] === $this->missing($theme) && is_file($this->directory.'/fonts.css');
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Schriften des Themes, für die noch keine lokale Datei existiert.
|
||||
*
|
||||
* @return list<string> Beschriftungen wie „Lato 700“
|
||||
*/
|
||||
public function missing(array $theme): array
|
||||
{
|
||||
$missing = [];
|
||||
foreach ($this->wanted($theme) as $family => $weights) {
|
||||
foreach ($weights as $weight) {
|
||||
if ([] === glob($this->directory.'/'.$this->slug($family).'-'.$weight.'-*.woff2')) {
|
||||
$missing[] = $family.' '.$weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt alle Schriften des Themes und schreibt fonts.css daneben.
|
||||
*
|
||||
* @return array{files: int, bytes: int, families: list<string>}
|
||||
*/
|
||||
public function fetch(array $theme): array
|
||||
{
|
||||
$wanted = $this->wanted($theme);
|
||||
if ([] === $wanted) {
|
||||
throw new \RuntimeException('Dieses Design nutzt keine Schrift von Google Fonts.');
|
||||
}
|
||||
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||
throw new \RuntimeException('Der Schriftenordner ist nicht beschreibbar: '.$this->directory);
|
||||
}
|
||||
$rules = [];
|
||||
$files = 0;
|
||||
$bytes = 0;
|
||||
foreach ($wanted as $family => $weights) {
|
||||
$css = $this->stylesheet($family, $weights);
|
||||
foreach ($this->parse($css, $family) as $face) {
|
||||
$name = $this->slug($family).'-'.$face['weight'].'-'.$face['subset'].'.woff2';
|
||||
$path = $this->directory.'/'.$name;
|
||||
if (!is_file($path)) {
|
||||
file_put_contents($path, $this->download($face['url']));
|
||||
++$files;
|
||||
}
|
||||
$bytes += (int) filesize($path);
|
||||
$rules[] = sprintf(
|
||||
"/* %s */\n@font-face{font-family:'%s';font-style:%s;font-weight:%d;font-display:swap;src:url(/assets/fonts/%s) format('woff2');unicode-range:%s}",
|
||||
$face['subset'],
|
||||
$family,
|
||||
$face['style'],
|
||||
$face['weight'],
|
||||
$name,
|
||||
$face['range'],
|
||||
);
|
||||
}
|
||||
}
|
||||
if ([] === $rules) {
|
||||
throw new \RuntimeException('Google Fonts hat keine passende Schriftdatei geliefert.');
|
||||
}
|
||||
file_put_contents($this->directory.'/fonts.css', implode("\n", $rules)."\n");
|
||||
|
||||
return ['files' => $files, 'bytes' => $bytes, 'families' => array_keys($wanted)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Dateien, die für den ersten Eindruck vorab geholt werden sollen.
|
||||
*
|
||||
* Nur der lateinische Grundbereich und nur die Schnitte, die im Fließtext und in
|
||||
* Überschriften stehen - ein Vorabholen, das alles holt, verzögert genau das, was es
|
||||
* beschleunigen soll.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function preload(array $theme): array
|
||||
{
|
||||
$preload = [];
|
||||
foreach ($this->wanted($theme) as $family => $weights) {
|
||||
foreach (array_slice($weights, 0, 2) as $weight) {
|
||||
$name = $this->slug($family).'-'.$weight.'-latin.woff2';
|
||||
if (is_file($this->directory.'/'.$name)) {
|
||||
$preload[] = '/assets/fonts/'.$name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $preload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Familien und Schnitte das Theme von Google bezieht.
|
||||
*
|
||||
* @return array<string, list<int>>
|
||||
*/
|
||||
private function wanted(array $theme): array
|
||||
{
|
||||
$wanted = [];
|
||||
foreach ($theme['fonts'] ?? [] as $font) {
|
||||
if ('google' !== ($font['source'] ?? '') || '' === ($font['family'] ?? '')) {
|
||||
continue;
|
||||
}
|
||||
$weights = array_map(intval(...), $font['weights'] ?? [400]);
|
||||
sort($weights);
|
||||
$wanted[$font['family']] = array_values(array_unique([...($wanted[$font['family']] ?? []), ...$weights]));
|
||||
sort($wanted[$font['family']]);
|
||||
}
|
||||
|
||||
return $wanted;
|
||||
}
|
||||
|
||||
private function stylesheet(string $family, array $weights): string
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://fonts.googleapis.com/css2?family=%s:wght@%s&display=swap',
|
||||
rawurlencode($family),
|
||||
implode(';', $weights),
|
||||
);
|
||||
|
||||
$css = $this->get($url);
|
||||
if (!str_contains($css, '@font-face')) {
|
||||
throw new \RuntimeException(sprintf('Google Fonts liefert für „%s“ keine Schrift. Schreibt sich die Familie genau so?', $family));
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
private function download(string $url): string
|
||||
{
|
||||
if (!str_starts_with($url, 'https://fonts.gstatic.com/')) {
|
||||
throw new \RuntimeException('Die Schriftdatei käme von einer fremden Adresse: '.$url);
|
||||
}
|
||||
|
||||
$body = $this->get($url);
|
||||
// Eine WOFF2-Datei beginnt mit "wOF2". Was anderes ist, wird nicht gespeichert.
|
||||
if (!str_starts_with($body, 'wOF2')) {
|
||||
throw new \RuntimeException('Die geladene Datei ist keine WOFF2-Schrift.');
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die @font-face-Blöcke eines Google-Stylesheets.
|
||||
*
|
||||
* Der Zeichenbereich steht in einem Kommentar vor jedem Block („/* latin *\/“); danach wird
|
||||
* ausgewählt, was eine deutschsprachige Website braucht.
|
||||
*
|
||||
* @return list<array{subset: string, weight: int, style: string, url: string, range: string}>
|
||||
*/
|
||||
public function parse(string $css, string $family): array
|
||||
{
|
||||
$faces = [];
|
||||
preg_match_all('#/\*\s*([a-z0-9-]+)\s*\*/\s*@font-face\s*\{(.+?)\}#s', $css, $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as [, $subset, $block]) {
|
||||
if (!in_array($subset, self::SUBSETS, true)) {
|
||||
continue;
|
||||
}
|
||||
if (1 !== preg_match('#src:\s*url\(([^)]+)\)#', $block, $src)) {
|
||||
continue;
|
||||
}
|
||||
preg_match('#font-weight:\s*(\d+)#', $block, $weight);
|
||||
preg_match('#font-style:\s*([a-z]+)#', $block, $style);
|
||||
preg_match('#unicode-range:\s*([^;}]+)#', $block, $range);
|
||||
$faces[] = [
|
||||
'subset' => $subset,
|
||||
'weight' => (int) ($weight[1] ?? 400),
|
||||
'style' => $style[1] ?? 'normal',
|
||||
'url' => trim($src[1], '\'" '),
|
||||
'range' => trim($range[1] ?? 'U+0000-00FF'),
|
||||
'family' => $family,
|
||||
];
|
||||
}
|
||||
|
||||
return $faces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt eine HTTPS-Adresse - erst über curl, sonst über den Dateizeiger.
|
||||
*
|
||||
* Dieselben zwei Wege wie im Installer, aus demselben Grund: auf einem Webhosting ist mal das
|
||||
* eine da und mal das andere. Eine weitere Abhängigkeit dafür ins Projekt zu holen, würde
|
||||
* jedes Update auf einem Hosting ohne Composer scheitern lassen.
|
||||
*/
|
||||
private function get(string $url): string
|
||||
{
|
||||
if (!str_starts_with($url, 'https://')) {
|
||||
throw new \RuntimeException('Geladen wird nur über HTTPS.');
|
||||
}
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($url);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_USERAGENT => self::AGENT,
|
||||
]);
|
||||
$body = curl_exec($curl);
|
||||
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
if (!is_string($body) || 200 !== $status) {
|
||||
throw new \RuntimeException(sprintf('Google Fonts antwortet mit %d%s.', $status, '' === $error ? '' : ' ('.$error.')'));
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
|
||||
throw new \RuntimeException('Dieser Server darf nicht nach außen verbinden (weder curl noch allow_url_fopen). Die Schriftdateien müssen dann von Hand nach public/assets/fonts/ gelegt werden.');
|
||||
}
|
||||
$context = stream_context_create([
|
||||
'http' => ['timeout' => 60, 'follow_location' => 1, 'max_redirects' => 3, 'header' => 'User-Agent: '.self::AGENT],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$body = @file_get_contents($url, false, $context);
|
||||
if (!is_string($body)) {
|
||||
throw new \RuntimeException('Google Fonts ist von diesem Server aus nicht erreichbar.');
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function slug(string $family): string
|
||||
{
|
||||
return strtolower(preg_replace('/[^a-z0-9]+/i', '-', $family) ?? 'schrift');
|
||||
}
|
||||
}
|
||||
@@ -368,7 +368,7 @@ final class PageRepository
|
||||
public function findPublished(string $path, string $locale = 'de'): ?array
|
||||
{
|
||||
$page = $this->db->fetchAssociative(
|
||||
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL',
|
||||
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.seo_title, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL',
|
||||
[$path, $locale, 'default'],
|
||||
);
|
||||
if (false === $page) {
|
||||
@@ -417,7 +417,7 @@ final class PageRepository
|
||||
|
||||
public function listForEditor(): array
|
||||
{
|
||||
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.og_media_id, p.publish_at, p.header_area, p.footer_area, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);
|
||||
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.seo_title, p.og_media_id, p.publish_at, p.header_area, p.footer_area, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);
|
||||
}
|
||||
|
||||
public function findForEditor(string $id): ?array
|
||||
|
||||
@@ -46,20 +46,27 @@ final class PostRepository
|
||||
throw new InvalidPage(sprintf('Das gewählte %s gibt es nicht.', $label));
|
||||
}
|
||||
}
|
||||
// Der Titel fuer die Suche. Leer heisst: der Titel der Seite gilt. 160 Zeichen sind die
|
||||
// Grenze des Feldes; Google zeigt etwa 60 - der Hinweis dazu steht im Formular.
|
||||
$seoTitle = trim((string) ($values['seoTitle'] ?? ''));
|
||||
if (mb_strlen($seoTitle) > 160) {
|
||||
throw new InvalidPage('Der Titel für Suchergebnisse darf höchstens 160 Zeichen lang sein.');
|
||||
}
|
||||
$robots = (string) ($values['robots'] ?? 'index');
|
||||
if (!in_array($robots, ['index', 'noindex'], true)) {
|
||||
throw new InvalidPage('Für die Indexierung sind nur „index“ und „noindex“ vorgesehen.');
|
||||
}
|
||||
|
||||
$this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $ogMediaId, $robots, $categories, $tags, $expectedVersion): void {
|
||||
$this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $ogMediaId, $robots, $seoTitle, $categories, $tags, $expectedVersion): void {
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, robots = ?, og_media_id = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, robots = ?, seo_title = ?, og_media_id = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
[
|
||||
'' === $excerpt ? null : $excerpt,
|
||||
'' === $author ? null : $author,
|
||||
'' === $mediaId ? null : $mediaId,
|
||||
'' === $date ? null : str_replace('T', ' ', 10 === strlen($date) ? $date.' 00:00' : $date).':00',
|
||||
$robots,
|
||||
'' === $seoTitle ? null : $seoTitle,
|
||||
'' === $ogMediaId ? null : $ogMediaId,
|
||||
$pageId,
|
||||
$expectedVersion,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ final class StyleCompiler
|
||||
private function compileBlock(string $id, array $style): string
|
||||
{
|
||||
$css = '';
|
||||
$style = $this->scaleBackground($style);
|
||||
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
||||
$values = $style[$device] ?? null;
|
||||
if (!is_array($values)) {
|
||||
@@ -322,6 +323,39 @@ final class StyleCompiler
|
||||
* Mobile takes the smallest that is at least 960 wide, tablet the one around 1600, desktop the
|
||||
* largest. A picture that only has small variants simply gives what it has.
|
||||
*/
|
||||
/**
|
||||
* Ein Hintergrundbild gilt auch dann in Gerätegrößen, wenn es nur einmal gesetzt wurde.
|
||||
*
|
||||
* Wer im Editor ein Hintergrundbild wählt, setzt es üblicherweise am Schreibtisch - also unter
|
||||
* „Desktop“. Ohne eigene Regel für die kleineren Geräte erbt ein Telefon diese Regel und lädt
|
||||
* die größte Variante: gemessen 189 KB statt 40 KB für ein Bild, das nie in dieser Auflösung
|
||||
* zu sehen ist. CSS kennt kein srcset, aber die Regeln stehen ohnehin je Gerät da - also
|
||||
* bekommt jedes Gerät dieselbe Bildwahl, die es bekäme, wenn jemand sie einzeln gesetzt hätte.
|
||||
*
|
||||
* Ein ausdrücklich gesetztes Bild wird nie ersetzt; ergänzt wird nur, wo nichts steht.
|
||||
*/
|
||||
private function scaleBackground(array $style): array
|
||||
{
|
||||
$image = $style['desktop']['backgroundImage'] ?? null;
|
||||
if (null === $image) {
|
||||
return $style;
|
||||
}
|
||||
foreach (['tablet', 'mobile'] as $device) {
|
||||
if (!isset($style[$device]['backgroundImage'])) {
|
||||
$style[$device]['backgroundImage'] = $image;
|
||||
// Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem
|
||||
// Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text.
|
||||
foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) {
|
||||
if (isset($style['desktop'][$begleiter]) && !isset($style[$device][$begleiter])) {
|
||||
$style[$device][$begleiter] = $style['desktop'][$begleiter];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $style;
|
||||
}
|
||||
|
||||
private function variantFor(array $variants, string $device): array
|
||||
{
|
||||
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
||||
|
||||
@@ -145,6 +145,7 @@ final class EditorController extends AbstractController
|
||||
'publishedAt' => $request->request->getString('publishedAt'),
|
||||
'featuredMediaId' => $request->request->getString('featuredMediaId'),
|
||||
'ogMediaId' => $request->request->getString('ogMediaId'),
|
||||
'seoTitle' => $request->request->getString('seoTitle'),
|
||||
'robots' => $request->request->getString('robots', 'index'),
|
||||
],
|
||||
$terms($request->request->getString('categories')),
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
@@ -12,6 +13,7 @@ use App\Content\PartialRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\StyleBundle;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\Theme;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -21,7 +23,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private string $secret, private string $siteOrigin)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private string $secret, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -42,6 +44,7 @@ final class PageController extends AbstractController
|
||||
$page = $this->pages->findPublished($path);
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
$localFonts = [];
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($page);
|
||||
if (null !== $page) {
|
||||
@@ -52,7 +55,13 @@ final class PageController extends AbstractController
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
}
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
|
||||
// die Datei noch die IP-Adresse des Besuchers.
|
||||
if ($this->fonts->hasLocal($theme)) {
|
||||
$localFonts = $this->fonts->preload($theme);
|
||||
} else {
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
}
|
||||
}
|
||||
if (null === $page) {
|
||||
// An address that moved or was deliberately removed answers before the 404 does.
|
||||
@@ -87,9 +96,14 @@ final class PageController extends AbstractController
|
||||
'areas' => $areas,
|
||||
'sections' => $sections,
|
||||
// Rendered before the page content, so the header decides first.
|
||||
'eagerImage' => $this->firstImage($areas['header'] ?? [], $page['document']),
|
||||
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
|
||||
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
||||
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
||||
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -11,20 +12,59 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ThemeController extends AbstractController
|
||||
{
|
||||
public function __construct(private Theme $theme)
|
||||
public function __construct(private Theme $theme, private FontLibrary $fonts)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/theme', name: 'admin_theme', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/theme.html.twig', [
|
||||
'theme' => $this->theme->load(),
|
||||
'theme' => $theme,
|
||||
'fallbacks' => Theme::FALLBACKS,
|
||||
'weights' => Theme::WEIGHTS,
|
||||
'fontsLocal' => $this->fonts->hasLocal($theme),
|
||||
'fontsMissing' => $this->fonts->missing($theme),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die gewaehlten Google-Schriften auf den eigenen Server.
|
||||
*
|
||||
* Auf Zuruf, nicht bei einem Seitenaufruf: das dauert Sekunden und braucht eine Verbindung
|
||||
* nach aussen, die ein Besucher nie abwarten soll.
|
||||
*/
|
||||
#[Route('/admin/theme/fonts', name: 'admin_theme_fonts', methods: ['POST'])]
|
||||
public function fonts(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
|
||||
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
|
||||
return $this->redirectToRoute('admin_theme');
|
||||
}
|
||||
try {
|
||||
$result = $this->fonts->fetch($this->theme->load());
|
||||
$this->addFlash('success', sprintf(
|
||||
'%d Schriftdatei(en) geladen, %s insgesamt: %s. Die Website lädt jetzt nichts mehr von Google.',
|
||||
$result['files'],
|
||||
$this->bytes($result['bytes']),
|
||||
implode(', ', $result['families']),
|
||||
));
|
||||
} catch (\RuntimeException $error) {
|
||||
$this->addFlash('error', $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_theme');
|
||||
}
|
||||
|
||||
private function bytes(int $bytes): string
|
||||
{
|
||||
return $bytes < 1024 * 1024 ? round($bytes / 1024).' KB' : round($bytes / 1024 / 1024, 1).' MB';
|
||||
}
|
||||
|
||||
#[Route('/admin/theme/save', name: 'admin_theme_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user