Beitraege optional, Unterstriche nur im Text, eigenes Symbol
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

Eine Website ohne Beitraege redet nirgends von Beitraegen: Archiv und
Feed antworten mit 404, der Feedverweis faellt aus dem Kopf, und der
Hinweiskasten der Beitragsliste bleibt im Editor statt oeffentlich zu
erscheinen. Das gilt auch je Kategorie, sobald deren letzter Beitrag
weg ist. Dabei gefunden: die Liste fragte je Beitrag nach Bild und
Begriffen - jetzt je eine Abfrage fuer die ganze Liste (22 -> 16
Abfragen auf der Startseite des Nachbaus).

Unterstrichene Links gibt es nur noch im Fliesstext, dort mit einem
Schalter im Designbereich; Karten, Menue, Kontaktzeilen und der
Ansehen-Streifen haben keinen mehr. Die aktuelle Seite im Menue wird
ueber das Schriftgewicht kenntlich - die Akzentfarbe fiel im dunklen
Fussmenue durch die Kontrastpruefung.

Neu: das Symbol der Website laesst sich festlegen. Aus einem Bild der
Mediathek entstehen beim Speichern quadratische PNG-Dateien in 32 und
180 px; schlaegt das fehl, wird die Einstellung nicht gespeichert.

Die Browserpruefung laeuft jetzt gegen jede Installation statt nur
gegen die Demo: geprueft wird, was in der Sitemap steht, und das
Ladebudget trennt Bilder vom Beiwerk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-13 12:57:23 +02:00
1 parent f0b634f0c1
commit f2ef2a4901
28 files changed
+482 -64

No files matched your search

+94
View File
@@ -60,6 +60,33 @@ final class MediaLibrary
return $row ? $this->present($row) : null;
}
/**
* Mehrere Bilder auf einmal, nach Kennung sortiert.
*
* Eine Liste mit zwölf Beiträgen soll nicht zwölfmal nach je einem Bild fragen. Fehlende
* Kennungen fehlen auch im Ergebnis - der Aufrufer prüft ohnehin auf null.
*
* @param list<string> $ids
*
* @return array<string, array>
*/
public function findMany(array $ids): array
{
$ids = array_values(array_unique(array_filter($ids)));
if ([] === $ids) {
return [];
}
$found = [];
foreach ($this->db->fetchAllAssociative(
'SELECT * FROM media WHERE id IN ('.implode(',', array_fill(0, count($ids), '?')).')',
$ids,
) as $row) {
$found[$row['id']] = $this->present($row);
}
return $found;
}
private function present(array $row): array
{
$row['variants'] = json_decode($row['variants'], true, 512, JSON_THROW_ON_ERROR);
@@ -163,6 +190,73 @@ final class MediaLibrary
return [...$data, 'id' => $id, 'version' => $data['version'] + 1];
}
/** Die Größen, die ein Favicon braucht: der Reiter im Browser und das Symbol auf dem Startbildschirm. */
public const FAVICON_SIZES = [32, 180];
/**
* Erzeugt aus einem Bild der Mediathek die Symboldateien der Website.
*
* Ein Favicon ist quadratisch, winzig und wird von den Browsern bis heute am zuverlässigsten
* als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein mittiger quadratischer
* Ausschnitt statt eines gestauchten Bildes. Erzeugt wird einmal beim Speichern der
* Einstellung, nicht bei jedem Aufruf.
*/
public function renderFavicon(string $mediaId): void
{
$source = null;
foreach (array_reverse(self::WIDTHS) as $width) {
$path = $this->file($mediaId, $width);
if (null !== $path) {
$source = @imagecreatefromwebp($path);
break;
}
}
if (!$source) {
throw new \RuntimeException('Das Bild für das Symbol konnte nicht gelesen werden.');
}
try {
// Mittiger quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke.
$kante = min(imagesx($source), imagesy($source));
$links = (int) ((imagesx($source) - $kante) / 2);
$oben = (int) ((imagesy($source) - $kante) / 2);
foreach (self::FAVICON_SIZES as $size) {
$target = imagecreatetruecolor($size, $size);
try {
imagealphablending($target, false);
imagesavealpha($target, true);
imagecopyresampled($target, $source, 0, 0, $links, $oben, $size, $size, $kante, $kante);
if (!imagepng($target, $this->faviconPath($size), 9)) {
throw new \RuntimeException('Das Symbol konnte nicht gespeichert werden.');
}
} finally {
imagedestroy($target);
}
}
} finally {
imagedestroy($source);
}
}
/** Der Pfad einer Symboldatei, oder null wenn sie (noch) nicht erzeugt wurde. */
public function faviconFile(int $size): ?string
{
if (!in_array($size, self::FAVICON_SIZES, true)) {
return null;
}
$path = $this->faviconPath($size);
return is_file($path) ? $path : null;
}
private function faviconPath(int $size): string
{
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
}
return $this->directory.'/favicon-'.$size.'.png';
}
public function file(string $id, int $width): ?string
{
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
+59 -9
View File
@@ -127,17 +127,24 @@ final class PostRepository
$params,
);
return ['posts' => array_map($this->present(...), $rows), 'total' => $total, 'pages' => $pages, 'page' => $page];
// Bilder und Begriffe für die ganze Liste in je einer Abfrage: vorher fragte jeder Beitrag
// einzeln nach seinem Bild und seinen Begriffen, eine Liste mit zwölf Einträgen kostete
// also 24 Abfragen statt zwei. Bei neighbours(), das 500 Beiträge lädt, war das absurd.
$images = $this->media->findMany(array_map(static fn (array $row) => (string) $row['featured_media_id'], $rows));
$terms = $this->termsForMany(array_column($rows, 'id'));
return [
'posts' => array_map(fn (array $row) => $this->present($row, $images, $terms[$row['id']] ?? ['category' => [], 'tag' => []]), $rows),
'total' => $total,
'pages' => $pages,
'page' => $page,
];
}
private function present(array $row): array
private function present(array $row, array $images, array $terms): array
{
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
$image = null;
if (null !== $row['featured_media_id']) {
$item = $this->media->find($row['featured_media_id']);
$image = null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']];
}
$item = $images[$row['featured_media_id']] ?? null;
return [
'title' => $document['title'] ?? $row['path'],
@@ -145,11 +152,37 @@ final class PostRepository
'excerpt' => $row['excerpt'] ?? $document['description'] ?? '',
'author' => $row['author'],
'publishedAt' => $row['published_at'],
'image' => $image,
'terms' => $this->termsFor($row['id']),
'image' => null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']],
'terms' => $terms,
];
}
/**
* Die Begriffe mehrerer Seiten auf einmal.
*
* @param list<string> $pageIds
*
* @return array<string, array<string, list<array{slug: string, name: string, href: string}>>>
*/
private function termsForMany(array $pageIds): array
{
$pageIds = array_values(array_unique(array_filter($pageIds)));
if ([] === $pageIds) {
return [];
}
$terms = [];
foreach ($this->db->fetchAllAssociative(
'SELECT pt.page_id, t.kind, t.slug, t.name FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id IN ('
.implode(',', array_fill(0, count($pageIds), '?')).') ORDER BY t.name',
$pageIds,
) as $row) {
$terms[$row['page_id']] ??= ['category' => [], 'tag' => []];
$terms[$row['page_id']][$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
}
return $terms;
}
/**
* @return array<string, list<array{slug: string, name: string, href: string}>>
*/
@@ -192,6 +225,23 @@ final class PostRepository
];
}
/**
* Gibt es überhaupt einen veröffentlichten Beitrag?
*
* Eine Website ohne Blog soll nirgends von Beiträgen reden: kein leeres Archiv, kein Feed mit
* null Einträgen, kein Kasten „hier erscheinen Beiträge“ auf einer Seite. Die Antwort wird für
* die Dauer einer Anfrage gemerkt, weil mehrere Stellen sie brauchen.
*/
public function anyPublished(string $locale = 'de'): bool
{
return $this->anyPublished ??= (bool) $this->db->fetchOne(
"SELECT 1 FROM page WHERE website_id = ? AND locale = ? AND type = 'post' AND published_revision_id IS NOT NULL AND deleted_at IS NULL LIMIT 1",
['default', $locale],
);
}
private ?bool $anyPublished = null;
public function termsFor(string $pageId): array
{
$terms = ['category' => [], 'tag' => []];
+28 -2
View File
@@ -4,6 +4,7 @@ namespace App\Content;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\TableNotFoundException;
/**
* Company data and site wide switches. The structured data the pages emit is built from exactly
@@ -30,6 +31,9 @@ final class SiteSettings
// Datum, Autor und Kategorie über dem Beitrag. Aus, wenn der Beitrag seinen eigenen
// Kopf mitbringt - sonst steht die Zeile über einem Bild, das den Titel schon trägt.
'postMetaHeader' => true,
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim
// Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
'faviconMediaId' => '',
'robotsExtra' => '',
// Which stored page answers at the site root. Everything else derives from this.
'homePath' => 'home',
@@ -42,7 +46,7 @@ final class SiteSettings
'imprintPath' => '',
];
public function __construct(private Connection $db)
public function __construct(private Connection $db, private MediaLibrary $media)
{
}
@@ -60,7 +64,14 @@ final class SiteSettings
if (null !== $this->loaded) {
return $this->loaded;
}
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
try {
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
} catch (TableNotFoundException) {
// Vor der Installation gibt es die Tabelle noch nicht. Fragen tun trotzdem schon
// Seiten, die es in dem Moment geben kann - der Installer selbst und die
// Nicht-gefunden-Seite. Die Standardwerte sind dort die richtige Antwort.
return $this->loaded = self::DEFAULTS;
}
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
@@ -100,6 +111,21 @@ final class SiteSettings
}
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
$favicon = trim((string) ($input['faviconMediaId'] ?? ''));
if ('' !== $favicon) {
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) {
throw new InvalidPage('Das gewählte Symbolbild gibt es nicht (mehr).');
}
// Die Dateien entstehen hier und nicht bei jedem Seitenaufruf. Schlägt das fehl, wird
// die Einstellung gar nicht erst gespeichert - ein Verweis auf ein fehlendes Symbol
// wäre schlimmer als das alte Symbol.
try {
$this->media->renderFavicon($favicon);
} catch (\RuntimeException $error) {
throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage());
}
}
$values['faviconMediaId'] = $favicon;
$values['postMetaHeader'] = (bool) ($input['postMetaHeader'] ?? false);
// Only comment and simple directive lines; nothing that could smuggle in a second policy.
$extra = [];
+9 -1
View File
@@ -49,6 +49,10 @@ final class Theme
'linkColor' => '@link',
'containerWidth' => '1240px',
'headingScale' => 1.25,
// Unterstrichene Links im Fließtext. An, weil ein Link, den nur die Farbe verrät,
// für Menschen mit Farbsehschwäche kein Link ist. Wer eine ruhigere Seite will,
// schaltet es hier ab - dann bleibt der Unterstrich beim Überfahren.
'linkUnderline' => true,
],
];
@@ -207,6 +211,7 @@ final class Theme
}
$base[$field] = $value;
}
$base['linkUnderline'] = (bool) ($input['linkUnderline'] ?? false);
foreach (['lineHeight' => [0.8, 3.0], 'headingScale' => [1.0, 2.0]] as $field => [$min, $max]) {
$value = (float) ($input[$field] ?? $base[$field]);
if ($value < $min || $value > $max) {
@@ -299,7 +304,9 @@ final class Theme
return sprintf(
':root{%s;--k-size:%s}.kant{font-family:var(--kf-%s);font-size:var(--k-size);line-height:%s;color:%s;background:%s}'
.'.kant h1,.kant h2,.kant h3,.kant h4,.kant h5,.kant h6{font-family:var(--kf-%s)}'
.'.kant .block-text a,.kant .post-excerpt a{color:%s}.kant .shell{max-width:var(--k-container)}%s',
.'.kant .block-text a,.kant .block-list a,.kant .post-excerpt a{color:%s;text-decoration:%s}'
.'.kant .block-text a:hover,.kant .block-list a:hover,.kant .post-excerpt a:hover{text-decoration:underline}'
.'.kant .shell{max-width:var(--k-container)}%s',
implode(';', $variables),
$base['fontSize'],
$base['fontFamily'],
@@ -308,6 +315,7 @@ final class Theme
$colour($base['backgroundColor']),
$base['headingFontFamily'],
$colour($base['linkColor']),
($base['linkUnderline'] ?? true) ? 'underline' : 'none',
$headings,
);
}