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

+46 -1
View File
@@ -4,8 +4,10 @@ namespace App\Controller;
use App\Content\Consent;
use App\Content\DemoContent;
use App\Content\Languages;
use App\Content\NotFoundPage;
use App\Content\PageRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\SocialProfiles;
@@ -19,7 +21,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class SettingsController extends AbstractController
{
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage)
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage, private Languages $languages, private PostRepository $posts)
{
}
@@ -67,9 +69,52 @@ final class SettingsController extends AbstractController
'socialNetworks' => SocialProfiles::NETWORKS,
'weekdays' => SiteSettings::DAYS,
'socialPlaceholders' => SocialProfiles::PLACEHOLDERS,
'mainLanguage' => $this->languages->describe($this->languages->main()),
'extraLanguages' => $this->languages->extra(),
'terms' => [] === $this->languages->extra() ? [] : $this->posts->allTerms(),
'languageCatalog' => array_map(fn (string $code): array => $this->languages->describe($code), array_values(array_diff(array_keys(Languages::CATALOG), [$this->languages->main()]))),
]);
}
/**
* Die weiteren Sprachen (ADR 111). Eine Sprache dazunehmen ändert an der Website nichts,
* solange keine Seite übersetzt und veröffentlicht ist; eine Sprache wegnehmen nimmt ihre
* Seiten vom Netz, lässt die Übersetzungen aber in den Seiten liegen.
*/
/** Kategorien, Schlagwörter und Einwilligungshinweis in den weiteren Sprachen (ADR 111). */
#[Route('/%kant.admin_path%/settings/sprachen/texte', name: 'admin_language_texts_save', methods: ['POST'])]
public function saveLanguageTexts(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$all = $request->request->all();
$this->settings->saveLanguageTexts(is_array($all['termNames'] ?? null) ? $all['termNames'] : [], is_array($all['consentTexts'] ?? null) ? $all['consentTexts'] : []);
$this->addFlash('success', 'Die Übersetzungen wurden gespeichert.');
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
}
#[Route('/%kant.admin_path%/settings/sprachen', name: 'admin_languages_save', methods: ['POST'])]
public function saveLanguages(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$this->languages->save(array_values(array_map('strval', (array) ($request->request->all()['languages'] ?? []))));
} catch (InvalidPage $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
}
$this->addFlash('success', [] === $this->languages->extra() ? 'Die Website ist einsprachig.' : 'Die Sprachen wurden gespeichert. Übersetzt wird im Editor jeder Seite über den Sprachumschalter oben.');
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
}
#[Route('/%kant.admin_path%/settings/company', name: 'admin_settings_save', methods: ['POST'])]
public function save(Request $request): Response
{