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>
292 lines
14 KiB
PHP
292 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Content;
|
|
|
|
use App\Domain\InvalidPage;
|
|
use App\Domain\StaleWrite;
|
|
use Doctrine\DBAL\Connection;
|
|
|
|
/**
|
|
* Post metadata and the terms that group posts. Metadata describes the page, not its content, so
|
|
* it lives in page columns instead of the block document: the document schema and the editor stay
|
|
* untouched, and the archive can order and paginate in SQL instead of decoding every document.
|
|
*/
|
|
final class PostRepository
|
|
{
|
|
public const KINDS = ['category' => 'Kategorie', 'tag' => 'Schlagwort'];
|
|
public const ARCHIVE_PREFIX = ['category' => 'kategorie', 'tag' => 'schlagwort'];
|
|
public const PER_PAGE = 10;
|
|
|
|
public function __construct(private Connection $db, private MediaLibrary $media, private SiteSettings $settings)
|
|
{
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $categories term names, created on first use
|
|
* @param list<string> $tags term names, created on first use
|
|
*/
|
|
public function saveMetadata(string $pageId, array $values, array $categories, array $tags, int $expectedVersion): void
|
|
{
|
|
$excerpt = trim((string) ($values['excerpt'] ?? ''));
|
|
if (mb_strlen($excerpt) > 500) {
|
|
throw new InvalidPage('Der Auszug darf höchstens 500 Zeichen lang sein.');
|
|
}
|
|
$author = trim((string) ($values['author'] ?? ''));
|
|
if (mb_strlen($author) > 120) {
|
|
throw new InvalidPage('Der Autorname darf höchstens 120 Zeichen lang sein.');
|
|
}
|
|
$date = trim((string) ($values['publishedAt'] ?? ''));
|
|
if ('' !== $date && 1 !== preg_match('/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?$/D', $date)) {
|
|
throw new InvalidPage('Das Datum braucht die Form JJJJ-MM-TT, wahlweise mit Uhrzeit.');
|
|
}
|
|
$mediaId = trim((string) ($values['featuredMediaId'] ?? ''));
|
|
$ogMediaId = trim((string) ($values['ogMediaId'] ?? ''));
|
|
foreach (['Beitragsbild' => $mediaId, 'Vorschaubild' => $ogMediaId] as $label => $candidate) {
|
|
if ('' !== $candidate && (1 !== preg_match('/^[a-f0-9]{32}$/D', $candidate) || null === $this->media->find($candidate))) {
|
|
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, $seoTitle, $categories, $tags, $expectedVersion): void {
|
|
$changed = $this->db->executeStatement(
|
|
'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,
|
|
],
|
|
);
|
|
if (1 !== $changed) {
|
|
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
|
|
}
|
|
$this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]);
|
|
foreach (['category' => $categories, 'tag' => $tags] as $kind => $names) {
|
|
foreach ($names as $name) {
|
|
$this->db->insert('page_term', ['page_id' => $pageId, 'term_id' => $this->term($kind, $name)]);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Finds or creates a term and returns its ID. Names are matched by their slug. */
|
|
private function term(string $kind, string $name, string $locale = 'de'): string
|
|
{
|
|
$name = trim($name);
|
|
$slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtr(mb_strtolower($name, 'UTF-8'), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss'])) ?? '', '-');
|
|
if ('' === $slug || mb_strlen($name) > 120) {
|
|
throw new InvalidPage(sprintf('„%s“ ist als %s nicht verwendbar.', $name, self::KINDS[$kind]));
|
|
}
|
|
$existing = $this->db->fetchOne('SELECT id FROM term WHERE website_id = ? AND locale = ? AND kind = ? AND slug = ?', ['default', $locale, $kind, substr($slug, 0, 80)]);
|
|
if (false !== $existing) {
|
|
return $existing;
|
|
}
|
|
$id = bin2hex(random_bytes(16));
|
|
$this->db->insert('term', ['id' => $id, 'website_id' => 'default', 'locale' => $locale, 'kind' => $kind, 'slug' => substr($slug, 0, 80), 'name' => $name]);
|
|
|
|
return $id;
|
|
}
|
|
|
|
/** Stamps the publication date when a post first goes public and has no date of its own. */
|
|
public function stampPublication(string $pageId): void
|
|
{
|
|
$this->db->executeStatement(
|
|
"UPDATE page SET published_at = ? WHERE id = ? AND type = 'post' AND published_at IS NULL",
|
|
[gmdate('Y-m-d H:i:s'), $pageId],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{posts: list<array>, total: int, pages: int, page: int}
|
|
*/
|
|
public function published(int $page = 1, ?string $termId = null, int $perPage = self::PER_PAGE, string $locale = 'de'): array
|
|
{
|
|
$where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL";
|
|
$params = ['default', $locale];
|
|
$join = '';
|
|
if (null !== $termId) {
|
|
$join = ' JOIN page_term pt ON pt.page_id = p.id AND pt.term_id = ?';
|
|
array_unshift($params, $termId);
|
|
}
|
|
$total = (int) $this->db->fetchOne('SELECT COUNT(*) FROM page p'.$join.' WHERE '.$where, $params);
|
|
$pages = max(1, (int) ceil($total / $perPage));
|
|
$page = max(1, min($page, $pages));
|
|
$rows = $this->db->fetchAllAssociative(
|
|
'SELECT p.id, p.path, p.excerpt, p.author, p.featured_media_id, p.published_at, r.document FROM page p'
|
|
.$join.' JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE '.$where
|
|
// A missing date sorts last, then newest first, with the path as a stable tiebreaker.
|
|
.' ORDER BY p.published_at IS NULL, p.published_at DESC, p.path ASC LIMIT '.$perPage.' OFFSET '.(($page - 1) * $perPage),
|
|
$params,
|
|
);
|
|
|
|
// 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 $images, array $terms): array
|
|
{
|
|
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
|
$item = $images[$row['featured_media_id']] ?? null;
|
|
|
|
return [
|
|
'title' => $document['title'] ?? $row['path'],
|
|
'href' => $this->settings->publicPath($row['path']),
|
|
'excerpt' => $row['excerpt'] ?? $document['description'] ?? '',
|
|
'author' => $row['author'],
|
|
'publishedAt' => $row['published_at'],
|
|
'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}>>
|
|
*/
|
|
/**
|
|
* Der vorherige und der nächste Beitrag, in der Reihenfolge des Archivs.
|
|
*
|
|
* Gesucht wird innerhalb derselben Kategorie, wenn der Beitrag eine hat: von einem Projekt
|
|
* führt man zum nächsten Projekt und nicht zur nächsten Pressemitteilung. Hat er keine, gilt
|
|
* die ganze Liste. Am Rand wird nicht umgebrochen - der erste Beitrag hat keinen Vorgänger,
|
|
* und ein Ring aus zwei Beiträgen wäre eine Schleife, keine Navigation.
|
|
*
|
|
* @return array{previous: ?array, next: ?array}
|
|
*/
|
|
public function neighbours(string $pageId, string $locale = 'de'): array
|
|
{
|
|
$termId = (string) $this->db->fetchOne(
|
|
'SELECT t.id FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? AND t.kind = ? ORDER BY t.name LIMIT 1',
|
|
[$pageId, 'category'],
|
|
);
|
|
// Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier.
|
|
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale)['posts'];
|
|
// present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse.
|
|
// Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist.
|
|
$own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId]));
|
|
$index = null;
|
|
foreach ($all as $position => $post) {
|
|
if ($post['href'] === $own) {
|
|
$index = $position;
|
|
break;
|
|
}
|
|
}
|
|
if (null === $index) {
|
|
return ['previous' => null, 'next' => null];
|
|
}
|
|
|
|
return [
|
|
// „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
|
|
'previous' => $all[$index - 1] ?? null,
|
|
'next' => $all[$index + 1] ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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' => []];
|
|
foreach ($this->db->fetchAllAssociative('SELECT t.kind, t.slug, t.name FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? ORDER BY t.name', [$pageId]) as $row) {
|
|
$terms[$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
|
|
}
|
|
|
|
return $terms;
|
|
}
|
|
|
|
public function findTerm(string $kind, string $slug, string $locale = 'de'): ?array
|
|
{
|
|
$row = $this->db->fetchAssociative('SELECT id, kind, slug, name FROM term WHERE website_id = ? AND locale = ? AND kind = ? AND slug = ?', ['default', $locale, $kind, $slug]);
|
|
|
|
return false === $row ? null : $row;
|
|
}
|
|
|
|
/** @return list<array{id: string, kind: string, slug: string, name: string}> */
|
|
public function allTerms(string $locale = 'de'): array
|
|
{
|
|
return $this->db->fetchAllAssociative('SELECT id, kind, slug, name FROM term WHERE website_id = ? AND locale = ? ORDER BY kind, name', ['default', $locale]);
|
|
}
|
|
|
|
/** Term names of one page, as the comma separated list the metadata form edits. */
|
|
public function namesFor(string $pageId): array
|
|
{
|
|
$terms = $this->termsFor($pageId);
|
|
|
|
return [
|
|
'category' => implode(', ', array_column($terms['category'], 'name')),
|
|
'tag' => implode(', ', array_column($terms['tag'], 'name')),
|
|
];
|
|
}
|
|
|
|
/** Removes term assignments of a deleted page. Unused terms stay; they are reusable labels. */
|
|
public function forget(string $pageId): void
|
|
{
|
|
$this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]);
|
|
}
|
|
}
|