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

+28 -8
View File
@@ -6,10 +6,12 @@ use App\Content\CustomCode;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\FormRepository;
use App\Content\Languages;
use App\Content\MenuRepository;
use App\Content\PartialRepository;
use App\Content\StyleCompiler;
use App\Content\Theme;
use App\Content\Translations;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
@@ -42,6 +44,7 @@ final class PartialController extends AbstractController
private CsrfTokenManagerInterface $csrf,
private PluginRegistry $plugins,
private FormRepository $forms,
private Languages $languages,
) {
}
@@ -125,8 +128,8 @@ final class PartialController extends AbstractController
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
}
if (strlen($request->getContent()) > 270000) {
return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt).'], 413);
if (strlen($request->getContent()) > 1100000) {
return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 413);
}
$partial = $this->partial($id);
try {
@@ -137,8 +140,9 @@ final class PartialController extends AbstractController
}
if ('admin_partial_preview' === $request->attributes->get('_route')) {
$this->validator->validate($document, false, true);
$language = $body['language'] ?? null;
return $this->renderPreview($partial, $document);
return $this->renderPreview($partial, $document, $request, is_string($language) && $this->languages->isExtra($language) ? $language : null);
}
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion'])) {
throw new \InvalidArgumentException();
@@ -163,18 +167,25 @@ final class PartialController extends AbstractController
}
/** The area on its own, in the shell of a page, so the editor preview looks like the result. */
private function renderPreview(array $partial, array $document): Response
private function renderPreview(array $partial, array $document, Request $request, ?string $language = null): Response
{
// Ein Bereich in einer weiteren Sprache (ADR 111): seine Texte, die seiner Sektionen und
// die Menübeschriftungen in dieser Sprache - wie auf einer übersetzten Seite.
$localise = static fn (array $doc): array => null === $language ? Translations::strip($doc) : Translations::apply($doc, $language);
$document = $localise($document);
$locale = $language ?? $this->languages->main();
$request->attributes->set('kant_locale', $locale);
$request->attributes->set('kant_alternates', array_map(fn (string $code): array => [...$this->languages->describe($code), 'locale' => $code, 'href' => '#', 'current' => $code === $locale], $this->languages->all()));
$theme = $this->theme->load();
$navigation = [];
foreach ($this->menus->all() as $slot => $menu) {
$navigation[$slot] = $this->menus->resolve($menu['items'], '');
$navigation[$slot] = $this->menus->resolve($menu['items'], '', language: $language);
}
$sections = [];
foreach ($this->partials->referencedSlugs($document) as $slug) {
$section = $this->partials->resolveSection($slug);
if (null !== $section) {
$sections[$slug] = $section['document'];
$sections[$slug] = $localise($section['document']);
}
}
@@ -184,10 +195,13 @@ final class PartialController extends AbstractController
$styles .= $this->styles->compileDocument($section).$this->code->compileDocument($section);
}
$forms = [];
$walk = function (array $blocks) use (&$walk, &$forms): void {
$walk = function (array $blocks) use (&$walk, &$forms, $language): void {
foreach ($blocks as $block) {
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
$form = $this->forms->findBySlug((string) $block['props']['form']);
if (null !== $form && null !== $language) {
$form = FormRepository::localise($form, $language);
}
$forms[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => ['path' => ''], 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
if (null !== $form) {
$styles .= $this->forms->css($form, $theme);
@@ -202,7 +216,7 @@ final class PartialController extends AbstractController
}
return $this->render('page.html.twig', [
'page' => ['locale' => 'de', 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
'page' => ['locale' => $locale, 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
'canonical' => '',
'editing' => true,
'navigation' => $navigation,
@@ -232,6 +246,12 @@ final class PartialController extends AbstractController
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
'plugins' => $this->plugins->forEditor(),
'presets' => $this->plugins->presetsForEditor(),
// Kopf, Fuß und Sektionen werden wie Seiten übersetzt, nur ohne Adresse und Suchtitel.
'languages' => [
'main' => $this->languages->describe($this->languages->main()),
'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
'area' => true,
],
];
}