Translate pages, areas, menus, forms and posts field by field instead of rebuilding them

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>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-09 16:56:52 +02:00
1 parent c7705b7083
commit e15b2517b1
51 files changed
+3045 -219

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
<?php
namespace App\Twig;
use App\Content\Languages;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
/** Sprachangaben für die Vorlagen (ADR 111). */
final class LanguageExtension extends AbstractExtension
{
/**
* Die festen Texte der öffentlichen Vorlagen, die kein Redakteur pflegt: Sprunglink,
* Beschriftungen von Navigationen, Einwilligung, Beitragslisten. Schlüssel ist der deutsche
* Text. Für Sprachen ohne eigene Liste gilt Englisch - auf einer französischen Seite ist
* „Skip to content“ näher am Besucher als „Zum Inhalt springen“.
*/
public const UI = [
'en' => [
'Zum Inhalt springen' => 'Skip to content',
'Menü' => 'Menu',
'Brotkrumen' => 'Breadcrumb',
'Start' => 'Home',
'Soziale Profile' => 'Social profiles',
'Zum Blättern seitwärts scrollen' => 'Scroll sideways to browse',
'Anbieter:' => 'Provider:',
'Inhalt laden und einwilligen' => 'Load content and consent',
'Einwilligung ändern' => 'Change consent',
'Bildergalerie' => 'Image gallery',
'Bild' => 'Image',
'vergrößern' => 'enlarge',
'Ansehen' => 'View',
'Weiterlesen' => 'Read more',
'Weitere Beiträge' => 'More posts',
'Vorheriger Beitrag' => 'Previous post',
'Nächster Beitrag' => 'Next post',
'Alle akzeptieren' => 'Accept all',
'Nur notwendige' => 'Necessary only',
'Einzeln wählen' => 'Choose individually',
'Auswahl speichern' => 'Save selection',
'Datenschutz' => 'Privacy',
'Impressum' => 'Legal notice',
'Hauptnavigation' => 'Main navigation',
'Zweite Navigation' => 'Secondary navigation',
'Fußzeile' => 'Footer',
'Rechtliches' => 'Legal',
'Navigation' => 'Navigation',
'Nach oben' => 'Back to top',
'Sprache' => 'Language',
// Formulare
'Dieses Formular ist nicht mehr vorhanden.' => 'This form is no longer available.',
'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' => 'Please check 1 entry. The field concerned is marked below.',
'Bitte prüfe %d Eingaben. Die betroffenen Felder sind unten gekennzeichnet.' => 'Please check %d entries. The fields concerned are marked below.',
'Pflichtfeld' => 'required',
'in' => 'in',
'bitte wählen' => 'please choose',
'Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.' => 'Sending this form requires JavaScript. Otherwise, feel free to email us directly.',
'Zusammenfassung' => 'Summary',
'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.' => 'Please check your details. “Change” takes you back to the step.',
'Schritte des Formulars' => 'Form steps',
'Schritt' => 'Step',
'Zurück' => 'Back',
'Weiter' => 'Next',
'Ändern' => 'Change',
'Ja' => 'Yes',
'Bitte wähle mindestens eine Option.' => 'Please choose at least one option.',
'Absenden' => 'Send',
'Vielen Dank. Deine Nachricht ist angekommen.' => 'Thank you. Your message has arrived.',
// Beiträge und Archive
'Beiträge' => 'Posts',
'Beitrag' => 'post',
'Kategorie' => 'Category',
'Schlagwort' => 'Tag',
'Alle Beiträge, die neuesten zuerst.' => 'All posts, newest first.',
'Beiträge in der Kategorie „%s“.' => 'Posts in the category “%s”.',
'Beiträge zum Schlagwort „%s“.' => 'Posts tagged “%s”.',
'von' => 'by',
'← Neuere' => '← Newer',
'Ältere →' => 'Older →',
'Seite %d von %d' => 'Page %d of %d',
'Externe Inhalte und Einwilligung' => 'External content and consent',
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'This website only loads certain content once you agree. Without consent the page is shown in full; only embedded content from other providers stays empty.',
'Statistik' => 'Statistics',
'Externe Inhalte' => 'External content',
'Messung, wie die Website benutzt wird.' => 'Measuring how the website is used.',
'Inhalte von anderen Anbietern, etwa Karten, Videos, Bewertungen oder ein Chat. Sie sehen die Adresse dieser Seite und deine IP-Adresse, sobald sie geladen werden.' => 'Content from other providers, such as maps, videos, reviews or a chat. They see the address of this page and your IP address as soon as they load.',
// 404 und Einwilligung
'Seite nicht gefunden' => 'Page not found',
'Diese Seite ist nicht mehr verfügbar.' => 'This page is no longer available.',
'Die gesuchte Seite ist nicht verfügbar. Über die Startseite findest du wieder zum richtigen Inhalt.' => 'The page you are looking for is not available. The home page will take you back to the right content.',
'Die Adresse ist möglicherweise veraltet oder wurde falsch eingegeben. Über die Startseite findest du wieder zum richtigen Inhalt.' => 'The address may be out of date or mistyped. The home page will take you back to the right content.',
'Zur Startseite' => 'To the home page',
],
'fr' => [
'Zum Inhalt springen' => 'Aller au contenu',
'Menü' => 'Menu',
'Start' => 'Accueil',
'Datenschutz' => 'Confidentialité',
'Impressum' => 'Mentions légales',
'Hauptnavigation' => 'Navigation principale',
'Nach oben' => 'Haut de page',
'Weiterlesen' => 'Lire la suite',
'Alle akzeptieren' => 'Tout accepter',
'Nur notwendige' => 'Uniquement nécessaires',
'Sprache' => 'Langue',
],
'tr' => [
'Zum Inhalt springen' => 'İçeriğe geç',
'Menü' => 'Menü',
'Start' => 'Ana sayfa',
'Datenschutz' => 'Gizlilik',
'Impressum' => 'Künye',
'Hauptnavigation' => 'Ana menü',
'Nach oben' => 'Yukarı',
'Weiterlesen' => 'Devamını oku',
'Alle akzeptieren' => 'Tümünü kabul et',
'Nur notwendige' => 'Yalnızca gerekli',
'Sprache' => 'Dil',
],
];
public function __construct(private RequestStack $requests)
{
}
public function getFunctions(): array
{
return [
new TwigFunction('kant_og_locale', Languages::ogLocale(...)),
];
}
public function getFilters(): array
{
return [
new TwigFilter('kant_t', $this->translate(...)),
];
}
/**
* Ein fester Text in der Sprache der Seite. Ohne weitere Sprache - oder in der Hauptsprache -
* bleibt er, wie er ist; ebenso jeder Text, der nicht in der Liste steht.
*/
public function translate(string $text): string
{
$locale = $this->requests->getCurrentRequest()?->attributes->get('kant_locale');
if (!is_string($locale) || 'de' === $locale) {
return $text;
}
return self::UI[$locale][$text] ?? self::UI['en'][$text] ?? $text;
}
}