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

+40 -11
View File
@@ -7,15 +7,18 @@ use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\FormRepository;
use App\Content\Languages;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostLayouts;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\StyleCompiler;
use App\Content\StyleSchema;
use App\Content\Theme;
use App\Content\Translations;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
@@ -31,7 +34,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/%kant.admin_path%')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests, private PluginRegistry $plugins, private FormRepository $forms, private PostRepository $postsForPreview)
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests, private PluginRegistry $plugins, private FormRepository $forms, private PostRepository $postsForPreview, private Languages $languages, private SiteSettings $siteSettings)
{
}
@@ -440,8 +443,9 @@ final class EditorController 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' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt).'], 413);
// Eine Seite darf 256 KiB haben, mit ihren Übersetzungen zusammen 1 MiB (ADR 111).
if (strlen($request->getContent()) > 1100000) {
return $this->json(['error' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 413);
}
$page = $this->page($id);
try {
@@ -456,20 +460,30 @@ final class EditorController extends AbstractController
throw new \InvalidArgumentException();
}
$this->validator->validate($document, writing: true);
// Die Vorschau einer Übersetzung ist dieselbe Seite mit den übersetzten Texten -
// genau so, wie die Website sie in dieser Sprache ausliefert.
$language = $body['language'] ?? null;
$locale = is_string($language) && $this->languages->isExtra($language) ? $language : $this->languages->main();
$seoTitle = $locale === $this->languages->main() ? ($page['seo_title'] ?? null) : ($document['translations'][$locale]['seoTitle'] ?? '');
$document = $locale === $this->languages->main() ? Translations::strip($document) : Translations::apply($document, $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()));
$request->attributes->set('kant_locale', $locale);
$localise = fn (array $doc): array => $locale === $this->languages->main() ? Translations::strip($doc) : Translations::apply($doc, $locale);
$theme = $this->theme->load();
$areas = [];
foreach (['header', 'footer'] as $kind) {
$area = $this->partials->findByKind($kind);
if (null !== $area) {
$areas[$kind] = $area['document'];
$areas[$kind] = $localise($area['document']);
}
}
$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']);
}
}
// Wie auf der Website: die zweite Verknüpfung derselben Sektion bekommt eigene Kennungen.
@@ -485,7 +499,7 @@ final class EditorController extends AbstractController
}
$navigation = [];
foreach ($this->menus->all() as $slot => $menu) {
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path'], language: $locale === $this->languages->main() ? null : $locale);
}
// Die Vorschau zeigt dieselbe Ausgabe wie die Website - einschliesslich eigenem
@@ -496,14 +510,14 @@ final class EditorController extends AbstractController
foreach ($documents as $doc) {
$styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc);
}
$forms = $this->previewForms($documents, $page);
$forms = $this->previewForms($documents, $page, $locale === $this->languages->main() ? null : $locale);
foreach ($forms as $state) {
if (null !== $state['form']) {
$styles .= $this->forms->css($state['form'], $theme);
}
}
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'forms' => $forms, 'pluginStyles' => $this->plugins->assetsFor($documents)['styles'], 'pageStyles' => $styles, 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]);
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document, 'locale' => $locale, 'seo_title' => $seoTitle], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'forms' => $forms, 'pluginStyles' => $this->plugins->assetsFor($documents)['styles'], 'pageStyles' => $styles, 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]);
}
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
throw new \InvalidArgumentException();
@@ -536,6 +550,9 @@ final class EditorController extends AbstractController
return $this->json(['error' => 'Inzwischen wurde ein neuerer Stand gespeichert. Deine Änderungen bleiben im Editor. Lade sie als JSON herunter, bevor du den aktuellen Serverstand lädst.', 'conflict' => true], 409);
} catch (InvalidDocument $e) {
return $this->json(['error' => 'Bitte prüfe die Inhalte: '.$e->getMessage()], 422);
} catch (InvalidPage $e) {
// Etwa eine übersetzte Adresse, die eine andere Seite schon hat.
return $this->json(['error' => $e->getMessage()], 422);
} catch (\JsonException|\InvalidArgumentException) {
return $this->json(['error' => 'Die Anfrage enthält kein gültiges Seitendokument oder keine gültige Version.'], 422);
}
@@ -549,13 +566,16 @@ final class EditorController extends AbstractController
*
* @return array<string, array>
*/
private function previewForms(array $documents, array $page): array
private function previewForms(array $documents, array $page, ?string $language = null): array
{
$states = [];
$walk = function (array $blocks) use (&$walk, &$states, $page): void {
$walk = function (array $blocks) use (&$walk, &$states, $page, $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);
}
$states[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => $page, 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
}
$walk($block['children'] ?? []);
@@ -575,6 +595,15 @@ final class EditorController extends AbstractController
private function payload(array $page): array
{
return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor()];
return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor(),
// Für den Sprachumschalter (ADR 111). Ohne weitere Sprache leer - dann gibt es ihn nicht.
'languages' => [
'main' => $this->languages->describe($this->languages->main()),
'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
'slug' => $page['slug'],
'home' => $page['path'] === $this->siteSettings->homePath(),
'published' => [] === $this->languages->extra() ? [] : $this->pages->translatedPaths($page['id']),
],
];
}
}