Vorschaubild beim Teilen, robots.txt als Bot-Steuerung
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

Das Feld "Vorschaubild beim Teilen" gab es seit dem SEO-Paket,
ausgegeben wurde es nie: keine mit KantSites gebaute Seite hatte ein
og:image. Es wird jetzt aufgeloest - erst das gesetzte Vorschaubild,
sonst das Beitragsbild - als absolute Adresse bis 1600 px, dazu
og:site_name, og:locale, og:image:alt und twitter:card (klein ohne
Bild, gross mit). Dasselbe Bild steht im strukturierten Datensatz
eines Beitrags; Archive haben jetzt ebenfalls Open-Graph-Angaben.

Eigene robots.txt-Zeilen: 200 statt 30, und Leerzeilen bleiben als
Trenner erhalten - Regeln je Bot (welche KI-Crawler lesen duerfen)
brauchen Bloecke, nicht einen Klumpen.

Behoben: die Symbolerzeugung suchte feste Breiten statt der Varianten,
die das Bild wirklich hat - ein 192 px grosses Zeichen, also der
haeufigste Fall, scheiterte damit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-13 14:01:16 +02:00
1 parent f2ef2a4901
commit 35d8270afd
10 files changed
+137 -10

No files matched your search

+10
View File
@@ -440,6 +440,16 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
## ADR 056 – Das Vorschaubild beim Teilen, und die robots.txt als Bot-Steuerung (13.09.2026)
Umut: „Hast du auch schon SEO und Siteindex usw. übernommen?“ Der Abgleich mit dem Original war die Antwort – und er förderte einen Fehler der eigenen Software zutage.
**Das Feld „Vorschaubild beim Teilen“ gab es seit dem SEO-Paket, ausgegeben wurde es nie.** Im Kopf stand `{% if socialImage %}`, gesetzt hat `socialImage` kein Controller. Damit hatte **keine** mit KantSites gebaute Seite ein `og:image` – beim Teilen in WhatsApp, LinkedIn oder Slack erschien eine Karte ohne Bild. Jetzt wird es aufgelöst: erst das eigens gesetzte Vorschaubild, sonst bei einem Beitrag sein Beitragsbild, als absolute Adresse und in der Variante bis 1600 px. Dasselbe Bild steht im strukturierten Datensatz des Beitrags. Ohne Bild gibt es `twitter:card: summary` statt der großen Karte, die sonst eine leere Fläche wäre. Dazu `og:site_name` und `og:locale`, die schlicht fehlten.
**Die robots.txt war zu eng gefasst.** Das Original pflegt eine Liste, welche KI-Crawler die Website lesen dürfen (GPTBot, ClaudeBot, PerplexityBot …) und welche nicht (Bytespider, Diffbot, omgili, ImagesiftBot) – rund siebzig Zeilen in Blöcken je Bot. Das Feld für eigene Zeilen nahm dreißig und warf Leerzeilen weg, machte also aus Blöcken einen Klumpen. Jetzt 200 Zeilen, Leerzeilen bleiben als Trenner erhalten. Die erlaubten Anweisungen bleiben dieselben: Kommentar, `User-agent`, `Allow`, `Disallow`, `Crawl-delay`.
**Kein Sitemap-Index.** Das Original liefert einen (drei Teilkarten von Yoast), KantSites eine einzelne Datei mit allen Adressen. Ein Index ist erst ab 50.000 Adressen oder 50 MB vorgeschrieben; bei 35 Adressen wäre er eine Datei mehr ohne jeden Unterschied für eine Suchmaschine. Aufgeteilt wird, wenn es eine Website gibt, die das braucht – und dann automatisch, nicht als Einstellung.
## ADR 055 – Das Symbol der Website entsteht aus einem Bild der Mediathek (13.09.2026)
Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon ist der einzige Teil der Gestaltung, den ein Besucher sieht, bevor die Seite geladen ist – und bisher war es das mitgelieferte Zeichen, für jede mit KantSites gebaute Website dasselbe.
+4
View File
@@ -4,6 +4,8 @@ Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
**Vom Original übernommen (13.09.2026):** die Meta-Beschreibung der Startseite im Wortlaut von kant-it.de, das Symbol der Website (dort 192 × 192) und die Bot-Regeln der robots.txt. Titel, Canonical, Open Graph, strukturierte Daten (`LocalBusiness` statt Yoasts `ProfessionalService`), Sitemap, robots.txt und `llms.txt` entstehen in KantSites aus dem Inhalt und stimmen mit dem Original überein. **Beim Abgleich aufgefallen:** das `og:image` des echten kant-it.de zeigt auf `wp-content/uploads/kant-it-share.png` – die Datei antwortet mit 404, beim Teilen erscheint dort also kein Bild.
**Im Nachbau von kant-it.de gefunden und behoben:** vier tote Verweise im Hero-Slider – die Adressen waren im Aufbauskript von Hand geschrieben, die Beiträge bekamen längere. Sie werden jetzt aus derselben Regel abgeleitet, mit der die Beiträge entstehen, und ein zweiter Lauf des Skripts aktualisiert vorhandene Seiten, statt sie zu überspringen. Eine Prüfung aller internen Verweise auf 404 gibt es im CMS noch nicht; gefunden wurden sie mit einem einmaligen Durchlauf im Browser.
## Erledigt
@@ -30,6 +32,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
- **Das Vorschaubild beim Teilen erscheint jetzt tatsächlich** (ADR 056): `og:image` wird aus dem Feld der Seite aufgelöst, ersatzweise aus dem Beitragsbild – vorher speicherte das Feld und bewirkte nichts, keine KantSites-Seite hatte ein Teilen-Bild. Dazu `og:site_name`, `og:locale`, `og:image:alt`, `twitter:card` (klein ohne Bild, groß mit) und das Bild im strukturierten Datensatz eines Beitrags. Archive haben jetzt ebenfalls Open-Graph-Angaben.
- **Eigene robots.txt-Zeilen: 200 statt 30, Leerzeilen bleiben** (ADR 056), damit sich Regeln je Bot in Blöcken schreiben lassen – etwa welche KI-Crawler lesen dürfen. Ein Sitemap-Index ist bewusst nicht gebaut: nötig wird er ab 50.000 Adressen.
- **Beiträge sind optional** (ADR 053): ohne einen einzigen veröffentlichten Beitrag antworten `/beitraege` und `/feed.xml` mit 404, der Feedverweis fehlt im Kopf, die Sitemap kennt kein Archiv, und ein Beitragslisten-Baustein zeigt öffentlich nichts – der Hinweis „hier erscheinen Beiträge“ bleibt nur im Editor. Ein Kategorie- oder Schlagwortarchiv verschwindet ebenso, sobald es nichts mehr zu zeigen hat; der Begriff selbst bleibt. Dabei behoben: die Beitragsliste fragte je Beitrag einzeln nach Bild und Begriffen – jetzt je eine Abfrage für die ganze Liste, Startseite des Nachbaus **22 → 16 Abfragen**.
- **Unterstriche nur noch im Fließtext** (ADR 054), dort mit Schalter im Designbereich („Links im Fließtext unterstreichen“, Standard an). Karten, Menü, Kontaktzeilen, Rechtszeile und der „Ansehen“-Streifen über Kartenbildern haben keinen mehr; beim Überfahren erscheint er überall. Die aktuelle Seite im Menü wird über das Schriftgewicht kenntlich – die Akzentfarbe fiel im dunklen Fußmenü durch die Kontrastprüfung.
- **Eigenes Symbol der Website** (ADR 055): ein Bild aus der Mediathek in den Einstellungen wählen, beim Speichern entstehen daraus quadratische PNG-Dateien in 32 und 180 px (`/favicon.png`, `/apple-touch-icon.png`). Ohne Auswahl bleibt es beim mitgelieferten Zeichen.
+6 -2
View File
@@ -203,9 +203,13 @@ final class MediaLibrary
*/
public function renderFavicon(string $mediaId): void
{
// Ueber die Varianten, die dieses Bild tatsaechlich hat, nicht ueber die vier Regelbreiten:
// ein 192 px breites Zeichen hat genau eine Variante mit 192 - und die Suche nach 480
// ging ins Leere. Genau so ein quadratisches Zeichen ist der haeufigste Fall.
$item = $this->find($mediaId);
$source = null;
foreach (array_reverse(self::WIDTHS) as $width) {
$path = $this->file($mediaId, $width);
foreach (array_reverse($item['variants'] ?? []) as $variant) {
$path = $this->file($mediaId, $variant['width']);
if (null !== $path) {
$source = @imagecreatefromwebp($path);
break;
+1 -1
View File
@@ -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.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.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) {
+12 -2
View File
@@ -128,14 +128,24 @@ final class SiteSettings
$values['faviconMediaId'] = $favicon;
$values['postMetaHeader'] = (bool) ($input['postMetaHeader'] ?? false);
// Only comment and simple directive lines; nothing that could smuggle in a second policy.
// Leerzeilen bleiben erhalten (hoechstens eine am Stueck): eine robots.txt mit eigenen
// Regeln je Bot besteht aus Bloecken, und ohne Trennung liest die niemand mehr. Das Limit
// liegt bei 200 Zeilen, weil allein die Liste der KI-Crawler laenger ist als dreissig.
$extra = [];
foreach (preg_split('/\R/', (string) ($input['robotsExtra'] ?? '')) ?: [] as $line) {
$line = trim($line);
if ('' !== $line && 1 === preg_match('/^(#.{0,180}|(?:Disallow|Allow|Crawl-delay|User-agent):[ \t][^\s][^\r\n]{0,180})$/D', $line)) {
if ('' === $line) {
if ([] !== $extra && '' !== end($extra)) {
$extra[] = '';
}
continue;
}
if (1 === preg_match('/^(#.{0,180}|(?:Disallow|Allow|Crawl-delay|User-agent):[ \t][^\s][^\r\n]{0,180})$/D', $line)) {
$extra[] = $line;
}
}
$values['robotsExtra'] = implode("\n", array_slice($extra, 0, 30));
$values['robotsExtra'] = trim(implode("\n", array_slice($extra, 0, 200)));
$home = trim(trim((string) ($input['homePath'] ?? self::DEFAULTS['homePath'])), '/');
if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $home)) {
+33 -1
View File
@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Content\Consent;
use App\Content\CustomCode;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
@@ -20,7 +21,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 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 string $secret, private string $siteOrigin)
{
}
@@ -81,6 +82,7 @@ final class PageController extends AbstractController
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
'breadcrumbs' => $this->breadcrumbs($page),
'socialImage' => $this->socialImage($page),
'structuredData' => $this->structuredData($page),
'areas' => $areas,
'sections' => $sections,
@@ -197,6 +199,32 @@ final class PageController extends AbstractController
*
* @return list<array>
*/
/**
* Das Bild, das beim Teilen erscheint - und im strukturierten Datensatz.
*
* Zuerst das eigens gesetzte Vorschaubild, sonst bei einem Beitrag sein Beitragsbild. Dieses
* Feld gab es schon, ausgegeben wurde es nie: eine Einstellung, die speichert und nichts tut.
* Die Adresse muss absolut sein, weil sie in fremden Diensten landet, und die Variante nicht
* die größte - 1600 px reichen jedem Vorschaubild, 2400 kostet nur Ladezeit beim Teilen.
*
* @return array{url: string, alt: string}|null
*/
private function socialImage(array $page): ?array
{
$id = (string) ($page['og_media_id'] ?? '') ?: (string) ($page['featured_media_id'] ?? '');
if ('' === $id) {
return null;
}
$item = $this->media->find($id);
if (null === $item || [] === $item['variants']) {
return null;
}
$passend = array_filter($item['variants'], static fn (array $variant): bool => $variant['width'] <= 1600);
$variant = end($passend) ?: $item['variants'][0];
return ['url' => rtrim($this->siteOrigin, '/').$variant['src'], 'alt' => (string) ($item['alt'] ?? '')];
}
private function structuredData(array $page): array
{
$origin = rtrim($this->siteOrigin, '/');
@@ -222,6 +250,10 @@ final class PageController extends AbstractController
if (null !== ($page['author'] ?? null) && '' !== $page['author']) {
$article['author'] = ['@type' => 'Person', 'name' => $page['author']];
}
$bild = $this->socialImage($page);
if (null !== $bild) {
$article['image'] = $bild['url'];
}
$blocks[] = $article;
}
$trail = $this->breadcrumbs($page);
+2 -2
View File
@@ -79,8 +79,8 @@
</div>
<p class="field-help">Das Bild im Browser-Reiter und auf dem Startbildschirm. Aus dem gewählten Bild entsteht beim Speichern ein quadratischer Ausschnitt aus der Mitte in 32 und 180 Pixeln – ein Logo mit viel Rand wird darin klein, ein quadratisches Zeichen funktioniert am besten.</p>
<label for="robotsExtra">Zusätzliche Zeilen für robots.txt</label>
<textarea id="robotsExtra" name="robotsExtra" rows="3">{{ settings.robotsExtra }}</textarea>
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>. Höchstens dreißig Zeilen.</p>
<textarea id="robotsExtra" name="robotsExtra" rows="8">{{ settings.robotsExtra }}</textarea>
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>, <code>Allow:</code>, <code>User-agent:</code> und <code>Crawl-delay:</code>. Leerzeilen trennen Blöcke, höchstens 200 Zeilen. Hier stehen zum Beispiel eigene Regeln je Bot – etwa welche KI-Crawler die Website lesen dürfen und welche nicht.</p>
<div class="section-head"><h2>Einwilligung</h2><span class="muted">Nur nötig, wenn die Website etwas von Dritten lädt</span></div>
<label class="check-label" for="stickyHeader"><input id="stickyHeader" name="stickyHeader" type="checkbox" value="1"{{ settings.stickyHeader ? ' checked' : '' }}> Kopfbereich beim Scrollen oben halten</label>
<label class="check-label" for="postMetaHeader"><input id="postMetaHeader" name="postMetaHeader" type="checkbox" value="1"{{ settings.postMetaHeader ? ' checked' : '' }}> Datum, Autor und Kategorie über einem Beitrag zeigen</label>
+6
View File
@@ -6,6 +6,12 @@
<title>{{ heading }}{% if result.page > 1 %} · Seite {{ result.page }}{% endif %} · Beiträge</title>
<meta name="description" content="{{ term ? 'Beiträge zu ' ~ term.name : 'Alle Beiträge' }}.">
<link rel="canonical" href="{{ canonical }}">
{# Auch ein geteiltes Archiv soll einen Namen haben statt einer nackten Adresse. #}
<meta property="og:title" content="{{ heading }}{% if result.page > 1 %} · Seite {{ result.page }}{% endif %}">
<meta property="og:description" content="{{ term ? 'Beiträge zu ' ~ term.name : 'Alle Beiträge' }}.">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ canonical }}">
<meta name="twitter:card" content="summary">
{% if result.page > 1 %}<link rel="prev" href="{{ base }}{{ result.page > 2 ? '?seite=' ~ (result.page - 1) : '' }}">{% endif %}
{% if result.page < result.pages %}<link rel="next" href="{{ base }}?seite={{ result.page + 1 }}">{% endif %}
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
+11 -1
View File
@@ -11,7 +11,17 @@
<meta property="og:description" content="{{ page.document.description }}">
<meta property="og:type" content="{{ page.type|default('page') == 'post' ? 'article' : 'website' }}">
<meta property="og:url" content="{{ canonical }}">
{% if socialImage|default(null) %}<meta property="og:image" content="{{ socialImage }}"><meta name="twitter:card" content="summary_large_image">{% endif %}
{# In der Vorschau des Editors gibt es den Websitenamen nicht - dort fehlt das Feld einfach. #}
{% if page.website_name|default('') %}<meta property="og:site_name" content="{{ page.website_name }}">{% endif %}
<meta property="og:locale" content="{{ page.locale == 'de' ? 'de_DE' : page.locale }}">
{% if socialImage|default(null) %}
<meta property="og:image" content="{{ socialImage.url }}">
{% if socialImage.alt %}<meta property="og:image:alt" content="{{ socialImage.alt }}">{% endif %}
<meta name="twitter:card" content="summary_large_image">
{% else %}
{# Ohne Bild ist die große Karte eine leere Fläche; die kleine zeigt Titel und Text. #}
<meta name="twitter:card" content="summary">
{% endif %}
{% include 'favicon.html.twig' %}
<link rel="stylesheet" href="/assets/site.css">
{% if googleFonts|default(null) %}<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="{{ googleFonts }}">{% endif %}
+52 -1
View File
@@ -877,6 +877,45 @@ final class CmsTest extends TestCase
self::assertStringContainsString('Nächstes Projekt', $rand);
}
public function testThePreviewPictureForSharingActuallyReachesTheHead(): void
{
// Das Feld „Vorschaubild beim Teilen" gab es seit dem SEO-Paket - ausgegeben wurde es
// nie. Eine Einstellung, die speichert und nichts tut, ist schlimmer als keine.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-teilen-');
$image = imagecreatetruecolor(1800, 1000);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'vorschau.png', test: true));
@unlink($file);
$media->update($item['id'], ['title' => 'Vorschau', 'alt' => 'Ein Blick auf die Website', 'archived' => false, 'version' => 1]);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
// Ohne Bild: die kleine Karte, kein Verweis auf ein Bild, das es nicht gibt.
$ohne = $this->request('/')->getContent();
self::assertStringNotContainsString('og:image', $ohne);
self::assertStringContainsString('<meta name="twitter:card" content="summary">', $ohne);
self::assertStringContainsString('<meta property="og:locale" content="de_DE">', $ohne);
$this->db->update('page', ['og_media_id' => $item['id']], ['id' => 'page-1']);
$html = $this->request('/')->getContent();
// Absolut, weil die Adresse in fremden Diensten landet, und höchstens 1600 px breit.
self::assertStringContainsString('<meta property="og:image" content="https://cms.test/media/'.$item['id'].'/1600">', $html);
self::assertStringContainsString('<meta property="og:image:alt" content="Ein Blick auf die Website">', $html);
self::assertStringContainsString('<meta name="twitter:card" content="summary_large_image">', $html);
// Ein Beitrag ohne eigenes Vorschaubild nimmt sein Beitragsbild.
$id = $this->publishedPost('mit-bild', 'Mit Bild', '2026-02-01', ['Projekte'], []);
$this->posts->saveMetadata($id, ['featuredMediaId' => $item['id'], 'publishedAt' => '2026-02-01'], ['Projekte'], [], $this->pages->findForEditor($id)['version']);
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
$beitrag = $this->request('/mit-bild')->getContent();
self::assertStringContainsString('<meta property="og:image" content="https://cms.test/media/'.$item['id'].'/1600">', $beitrag);
// Und dasselbe Bild steht im strukturierten Datensatz des Beitrags.
self::assertStringContainsString('"image":"https://cms.test/media/'.$item['id'].'/1600"', $beitrag);
}
public function testTheSiteIconIsMadeFromAPictureOfTheLibrary(): void
{
// Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Aus einem Bild der
@@ -909,13 +948,25 @@ final class CmsTest extends TestCase
self::assertSame([32, 32], [$size[0], $size[1]], 'Quadratisch, nicht gestaucht.');
self::assertSame(200, $this->request('/apple-touch-icon.png')->getStatusCode());
// Ein winziges quadratisches Zeichen hat keine einzige der vier Regelbreiten - genau so
// sieht ein echtes Favicon aus, und genau daran ist die erste Fassung gescheitert.
$klein = tempnam(sys_get_temp_dir(), 'kant-zeichen-');
$zeichen = imagecreatetruecolor(192, 192);
imagepng($zeichen, $klein);
imagedestroy($zeichen);
$kleines = $media->upload(new UploadedFile($klein, 'zeichen.png', test: true));
@unlink($klein);
self::assertSame([192], array_column($kleines['variants'], 'width'));
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $kleines['id']]);
self::assertSame(200, $this->request('/favicon.png')->getStatusCode());
// Ein Bild, das es nicht gibt, wird nicht gespeichert - sonst zeigte der Kopf ins Leere.
try {
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => str_repeat('a', 32)]);
self::fail('Eine unbekannte Bildkennung muss abgelehnt werden.');
} catch (InvalidPage) {
}
self::assertSame($item['id'], $this->site->load()['faviconMediaId']);
self::assertSame($kleines['id'], $this->site->load()['faviconMediaId']);
}
public function testUnderlinedLinksAreAChoiceAndOnlyApplyToRunningText(): void