A site can have further languages (Settings → Languages). A page is built once; each language adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111, document schema v27, migration 20261009000100 for page_translation). - Editor: DE | EN switch, a list of all texts with original, state (missing, translated, original changed) and filter; structure stays with the main language. Undo, drafts, publishing and revisions cover all languages at once. - Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale, sitemap; links to translated pages follow the language; a language switch block. - Header, footer and linked sections translate the same way; menu entries carry labels per language; fixed template texts come from a small dictionary. - Forms: labels, help, options, button and success message per language; messages and the step script speak the language, the submission and the email stay in the main language. - Posts, archives, category names, consent text and the 404 page follow the language. - Single-language sites see none of it and never touch the new table. The forms administration becomes an overview with one row per form and its own edit page; fields are one line each with details collapsed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
7.5 KiB
PHP
160 lines
7.5 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Content\Languages;
|
|
use App\Content\PostRepository;
|
|
use App\Content\SiteSettings;
|
|
use Doctrine\DBAL\Connection;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
|
|
/**
|
|
* The machine readable surface of a site: sitemap, robots and the optional llms.txt. Everything
|
|
* here is derived from published content, never maintained separately, so it cannot drift apart
|
|
* from what a visitor actually sees.
|
|
*/
|
|
final class SeoController extends AbstractController
|
|
{
|
|
public function __construct(
|
|
private Connection $db,
|
|
private SiteSettings $settings,
|
|
private PostRepository $posts,
|
|
private string $siteOrigin,
|
|
private string $adminPath,
|
|
private Languages $languages,
|
|
) {
|
|
}
|
|
|
|
#[Route('/sitemap.xml', name: 'sitemap', methods: ['GET'])]
|
|
public function sitemap(): Response
|
|
{
|
|
$origin = rtrim($this->siteOrigin, '/');
|
|
$home = $this->settings->homePath();
|
|
$entries = [];
|
|
// Only published pages that are allowed to be indexed. A draft or a noindex page in the
|
|
// sitemap would ask a search engine to fetch something it must not list.
|
|
foreach ($this->db->fetchAllAssociative(
|
|
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.id <> ? ORDER BY p.path",
|
|
['default', $this->settings->load()['notFoundPageId']],
|
|
) as $row) {
|
|
$entries[] = [
|
|
'loc' => $origin.$this->settings->publicPath($row['path'], $home),
|
|
// Das juengere der beiden Daten: eine spaeter bearbeitete und neu veroeffentlichte
|
|
// Seite behielte sonst das Datum ihrer ersten Veroeffentlichung.
|
|
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
|
|
];
|
|
}
|
|
// Jede weitere Sprache mit ihren veröffentlichten Seiten (ADR 111), unter denselben
|
|
// Bedingungen wie die Hauptsprache. Die Zuordnung der Sprachen zueinander steht als
|
|
// hreflang in den Seiten selbst.
|
|
$extra = $this->languages->extra();
|
|
if ([] !== $extra) {
|
|
foreach ($this->db->fetchAllAssociative(
|
|
"SELECT t.locale, t.path AS translated, p.path, p.published_at, r.created_at FROM page_translation t JOIN page p ON p.id = t.page_id JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE t.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.id <> ? ORDER BY t.locale, t.path",
|
|
['default', $this->settings->load()['notFoundPageId']],
|
|
) as $row) {
|
|
if (!in_array($row['locale'], $extra, true)) {
|
|
continue;
|
|
}
|
|
$entries[] = [
|
|
'loc' => $origin.'/'.$row['locale'].($row['path'] === $home ? '' : '/'.$row['translated']),
|
|
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
|
|
];
|
|
}
|
|
}
|
|
// Archives exist only while they list something.
|
|
if ([] !== $this->posts->published(1, null, 1)['posts']) {
|
|
$entries[] = ['loc' => $origin.'/beitraege', 'lastmod' => null];
|
|
}
|
|
foreach ($this->db->fetchAllAssociative(
|
|
'SELECT DISTINCT t.kind, t.slug FROM term t JOIN page_term pt ON pt.term_id = t.id JOIN page p ON p.id = pt.page_id AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL ORDER BY t.kind, t.slug',
|
|
) as $row) {
|
|
$entries[] = ['loc' => $origin.'/'.PostRepository::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug'], 'lastmod' => null];
|
|
}
|
|
|
|
return $this->xml($this->renderView('seo/sitemap.xml.twig', ['entries' => $entries]));
|
|
}
|
|
|
|
#[Route('/robots.txt', name: 'robots', methods: ['GET'])]
|
|
public function robots(): Response
|
|
{
|
|
$settings = $this->settings->load();
|
|
// Die Verwaltung wird hier nur genannt, solange sie „admin" heisst - diese Adresse kennt
|
|
// ohnehin jeder Scanner. Wer sie umbenannt hat, will sie nicht in einer oeffentlichen
|
|
// Datei wiederfinden: robots.txt ist das erste, was ein Angreifer liest. Vom Indexieren
|
|
// haelt die Verwaltung ihr eigener Kopf `X-Robots-Tag: noindex` ab.
|
|
$lines = ['User-agent: *'];
|
|
if ('admin' === $this->adminPath) {
|
|
$lines[] = 'Disallow: /admin';
|
|
}
|
|
$lines[] = 'Disallow: /formular/';
|
|
if ('' !== $settings['robotsExtra']) {
|
|
$lines[] = $settings['robotsExtra'];
|
|
}
|
|
$lines[] = '';
|
|
$lines[] = 'Sitemap: '.rtrim($this->siteOrigin, '/').'/sitemap.xml';
|
|
|
|
return $this->text(implode("\n", $lines)."\n");
|
|
}
|
|
|
|
/**
|
|
* A machine readable summary of public content. Optional and switched off by default. It is
|
|
* no ranking factor and no requirement for any search product; it simply saves an agent the
|
|
* crawl. Only published, indexable pages appear.
|
|
*/
|
|
#[Route('/llms.txt', name: 'llms', methods: ['GET'])]
|
|
public function llms(): Response
|
|
{
|
|
$settings = $this->settings->load();
|
|
if (!$settings['llmsTxt']) {
|
|
throw $this->createNotFoundException('llms.txt ist für diese Website nicht aktiviert.');
|
|
}
|
|
$origin = rtrim($this->siteOrigin, '/');
|
|
$home = $settings['homePath'];
|
|
$name = (string) $this->db->fetchOne('SELECT name FROM website WHERE id = ?', ['default']);
|
|
$lines = ['# '.('' !== $settings['legalName'] ? $settings['legalName'] : $name), ''];
|
|
if ('' !== $settings['description']) {
|
|
$lines[] = '> '.$settings['description'];
|
|
$lines[] = '';
|
|
}
|
|
foreach (['page' => '## Seiten', 'post' => '## Beiträge'] as $type => $heading) {
|
|
$rows = $this->db->fetchAllAssociative(
|
|
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.type = ? AND p.id <> ? ORDER BY p.path",
|
|
['default', $type, $settings['notFoundPageId']],
|
|
);
|
|
if ([] === $rows) {
|
|
continue;
|
|
}
|
|
$lines[] = $heading;
|
|
foreach ($rows as $row) {
|
|
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
|
$summary = trim((string) ($row['excerpt'] ?? $document['description'] ?? ''));
|
|
$lines[] = sprintf('- [%s](%s)%s', $document['title'] ?? $row['path'], $origin.$this->settings->publicPath($row['path'], $home), '' === $summary ? '' : ': '.$summary);
|
|
}
|
|
$lines[] = '';
|
|
}
|
|
|
|
return $this->text(implode("\n", $lines));
|
|
}
|
|
|
|
private function text(string $body): Response
|
|
{
|
|
return $this->harden(new Response($body, 200, ['Content-Type' => 'text/plain; charset=UTF-8']));
|
|
}
|
|
|
|
private function xml(string $body): Response
|
|
{
|
|
return $this->harden(new Response($body, 200, ['Content-Type' => 'application/xml; charset=UTF-8']));
|
|
}
|
|
|
|
private function harden(Response $response): Response
|
|
{
|
|
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
|
$response->headers->set('Cache-Control', 'no-store');
|
|
|
|
return $response;
|
|
}
|
|
}
|