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

+243 -2
View File
@@ -50,6 +50,8 @@ final class PageRepository
$path = $parent['path'].'/'.$slug;
} elseif (in_array($slug, self::RESERVED_SLUGS, true)) {
throw new InvalidPage('Diese Adresse ist für das CMS reserviert.');
} elseif (in_array($slug, $this->languageCodes(), true)) {
throw new InvalidPage(sprintf('Unter /%s erscheint die Website in einer weiteren Sprache. Wähle eine andere Adresse.', $slug));
}
if (false !== $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ?', ['default', $locale, $path])) {
throw new InvalidPage(sprintf('Die Adresse /%s ist bereits vergeben.', $path));
@@ -119,6 +121,8 @@ final class PageRepository
throw new InvalidPage('Eine Seite kann nicht unter eine ihrer eigenen Unterseiten verschoben werden.');
}
$newPath = $parent['path'].'/'.$slug;
} elseif (in_array($slug, $this->languageCodes(), true)) {
throw new InvalidPage(sprintf('Unter /%s erscheint die Website in einer weiteren Sprache. Wähle eine andere Adresse.', $slug));
}
if ($newPath === $page['path']) {
return 0;
@@ -148,6 +152,11 @@ final class PageRepository
$redirects->remember($row['path'], $moved);
}
$this->db->executeStatement('UPDATE page SET slug = ?, parent_id = ?, version = version + 1 WHERE id = ?', [$slug, $parentId, $pageId]);
// Die Übersetzungen hängen an derselben Hierarchie: unter einer verschobenen Seite
// ändern sich auch die Adressen der anderen Sprachen.
if ($this->multilingual()) {
$this->writeTranslationPaths($this->translationPaths(), $redirects);
}
return count($affected);
});
@@ -324,6 +333,9 @@ final class PageRepository
// Detach both pointers first: the revision rows are referenced by the page row.
$this->db->executeStatement('UPDATE page SET draft_revision_id = NULL, published_revision_id = NULL WHERE id = ?', [$pageId]);
$this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]);
if ($this->multilingual() || $this->db->createSchemaManager()->tablesExist(['page_translation'])) {
$this->db->executeStatement('DELETE FROM page_translation WHERE page_id = ?', [$pageId]);
}
$this->db->executeStatement('DELETE FROM revision WHERE page_id = ?', [$pageId]);
$this->db->executeStatement('DELETE FROM page WHERE id = ?', [$pageId]);
});
@@ -333,10 +345,15 @@ final class PageRepository
{
// Sanitising happens before validating and before storing, so the database never holds
// markup the allowlist would reject and no renderer has to remember to filter.
$document = $this->richText->normaliseDocument($document);
$document = Translations::normalise($this->richText->normaliseDocument($document), $this->richText);
$this->validator->validate($document, writing: true);
return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string {
// Eine übersetzte Adresse, die schon vergeben ist, fällt hier auf und nicht erst beim
// Veröffentlichen. Geschrieben wird sie erst dann - bis dahin ist sie Entwurf.
if ($this->multilingual($document)) {
$this->translationPaths($pageId, $this->translatedSlugs($pageId, $document));
}
$id = bin2hex(random_bytes(16));
$changed = $this->db->executeStatement(
'UPDATE page SET version = version + 1, draft_revision_id = ? WHERE id = ? AND version = ?',
@@ -364,7 +381,8 @@ final class PageRepository
if (false === $raw) {
throw new \InvalidArgumentException('Revision does not belong to this page.');
}
$this->validator->validate(json_decode($raw, true, 512, JSON_THROW_ON_ERROR));
$document = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
$this->validator->validate($document);
$changed = $this->db->executeStatement(
'UPDATE page SET published_revision_id = ?, version = version + 1 WHERE id = ? AND version = ?',
[$revisionId, $pageId, $expectedVersion],
@@ -372,6 +390,9 @@ final class PageRepository
if (1 !== $changed) {
throw new StaleWrite('The page changed. Reload before publishing.');
}
// Mit der Fassung gehen auch ihre Übersetzungen online - oder offline, wenn sie keine
// mehr hat. Die Adressen spiegeln damit genau den veröffentlichten Stand.
$this->publishTranslations($pageId, $document);
// A post that goes public without its own date gets one, so the archive can order it.
$this->db->executeStatement(
"UPDATE page SET published_at = ? WHERE id = ? AND type = 'post' AND published_at IS NULL",
@@ -391,10 +412,230 @@ final class PageRepository
}
$page['document'] = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR);
$this->validator->validate($page['document']);
$page['document'] = Translations::strip($page['document']);
return $page;
}
/**
* Eine veröffentlichte Seite in einer weiteren Sprache, gefunden über ihre übersetzte
* Adresse. Das Dokument kommt mit den übersetzten Texten; `path` bleibt die Adresse der
* Hauptsprache, weil Navigation, Brotkrumen und Formulare an ihr hängen.
*/
public function findPublishedTranslation(string $path, string $locale): ?array
{
$page = $this->db->fetchAssociative(
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.seo_title, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name, t.path AS translated_path 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 JOIN website w ON w.id = p.website_id WHERE t.path = ? AND t.locale = ? AND t.website_id = ? AND p.deleted_at IS NULL',
[$path, $locale, 'default'],
);
if (false === $page) {
return null;
}
$document = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR);
$this->validator->validate($document);
if (!in_array($locale, Translations::locales($document), true)) {
return null;
}
$translation = $document['translations'][$locale];
$page['document'] = Translations::apply($document, $locale);
$page['main_locale'] = $page['locale'];
$page['locale'] = $locale;
// Ohne eigenen Suchtitel nimmt die Vorlage den übersetzten Seitentitel - nie den Suchtitel
// der Hauptsprache, der in der falschen Sprache in den Suchergebnissen stünde.
$page['seo_title'] = $translation['seoTitle'] ?? '';
return $page;
}
/**
* Unter welchen Adressen eine Seite in den weiteren Sprachen veröffentlicht ist, als Pfad
* ohne Schrägstrich vorn.
*
* @return array<string, string> Sprache => Pfad
*/
public function translatedPaths(string $pageId): array
{
$paths = [];
foreach ($this->db->fetchAllAssociative('SELECT locale, path FROM page_translation WHERE page_id = ? AND website_id = ? ORDER BY locale', [$pageId, 'default']) as $row) {
$paths[$row['locale']] = $row['path'];
}
return $paths;
}
/**
* Alle veröffentlichten Seiten einer Sprache: Pfad der Hauptsprache => übersetzter Pfad.
* Damit zeigen Links auf einer übersetzten Seite auf die übersetzten Fassungen.
*
* @return array<string, string>
*/
public function translationMap(string $locale): array
{
$map = [];
foreach ($this->db->fetchAllAssociative(
'SELECT p.path AS main_path, t.path FROM page_translation t JOIN page p ON p.id = t.page_id WHERE t.locale = ? AND t.website_id = ? AND p.deleted_at IS NULL AND p.published_revision_id IS NOT NULL',
[$locale, 'default'],
) as $row) {
$map[$row['main_path']] = $row['path'];
}
return $map;
}
/**
* Die weiteren Sprachen, direkt gelesen: über SiteSettings::load() bliebe sonst eine ältere
* Startseite für den Rest der Anfrage zwischengespeichert.
*
* @return list<string>
*/
private function languageCodes(): array
{
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
$settings = is_string($raw) ? json_decode($raw, true) : null;
return is_array($settings['languages'] ?? null) ? $settings['languages'] : [];
}
/**
* Ob die übersetzten Adressen überhaupt gepflegt werden müssen: nur bei einer Website mit
* weiteren Sprachen oder einem Dokument mit Übersetzungen. Eine einsprachige Website fasst
* die Tabelle so nie an - auch nicht, wenn nach einem Update die Migration noch aussteht.
* Abgewählte Sprachen holt das nächste Veröffentlichen nach: es rechnet alle Pfade neu.
*/
private function multilingual(?array $document = null): bool
{
return [] !== $this->languageCodes() || (null !== $document && [] !== Translations::locales($document));
}
/** @return array<string, string> Sprache => übersetzte Adresse, aus dem Dokument */
private function translatedSlugs(string $pageId, array $document): array
{
$slug = (string) $this->db->fetchOne('SELECT slug FROM page WHERE id = ?', [$pageId]);
$slugs = [];
foreach (Translations::locales($document) as $locale) {
$slugs[$locale] = (string) ($document['translations'][$locale]['slug'] ?? $slug);
}
return $slugs;
}
/**
* Schreibt die übersetzten Adressen einer gerade veröffentlichten Seite: neue Sprachen kommen
* dazu, entfernte fallen weg, und eine geänderte Adresse bekommt eine Weiterleitung.
*/
private function publishTranslations(string $pageId, array $document): void
{
if ($this->multilingual($document)) {
$this->writeTranslationPaths($this->translationPaths($pageId, $this->translatedSlugs($pageId, $document)), new RedirectRepository($this->db));
}
}
/**
* Rechnet die übersetzten Adressen aller Seiten aus. Eine Seite steht in einer Sprache unter
* dem übersetzten Pfad ihrer Elternseite - oder, wenn die nicht übersetzt ist, unter deren
* Pfad in der Hauptsprache - und dahinter ihre übersetzte Adresse.
*
* Mit $pageId ersetzt $slugs die Sprachen dieser einen Seite (Entwurf oder Veröffentlichung);
* sonst gilt der gespeicherte Stand (nach dem Verschieben). Doppelte Adressen werden
* abgelehnt, bevor irgendetwas geschrieben ist.
*
* @param array<string, string> $slugs Sprache => Adresse
*
* @return array<string, array<string, array{slug: string, path: string, old: ?string}>> Sprache => Seite => Zeile
*/
private function translationPaths(?string $pageId = null, array $slugs = []): array
{
$pages = [];
foreach ($this->db->fetchAllAssociative('SELECT id, slug, parent_id FROM page WHERE website_id = ?', ['default']) as $row) {
$pages[$row['id']] = $row;
}
$wanted = [];
$old = [];
foreach ($this->db->fetchAllAssociative('SELECT page_id, locale, slug, path FROM page_translation WHERE website_id = ?', ['default']) as $row) {
$wanted[$row['locale']][$row['page_id']] = $row['slug'];
$old[$row['locale']][$row['page_id']] = $row['path'];
}
if (null !== $pageId) {
foreach (array_keys($wanted) as $locale) {
unset($wanted[$locale][$pageId]);
}
foreach ($slugs as $locale => $slug) {
$wanted[$locale][$pageId] = $slug;
}
}
$result = [];
foreach ($wanted as $locale => $rows) {
$memo = [];
$pathOf = function (string $id, int $depth = 0) use (&$pathOf, &$memo, $pages, $rows): string {
if (isset($memo[$id])) {
return $memo[$id];
}
$page = $pages[$id] ?? null;
if (null === $page || $depth > self::MAX_DEPTH) {
return '';
}
$segment = $rows[$id] ?? $page['slug'];
$parent = null === $page['parent_id'] ? '' : $pathOf($page['parent_id'], $depth + 1);
return $memo[$id] = '' === $parent ? $segment : $parent.'/'.$segment;
};
$taken = [];
foreach ($rows as $id => $slug) {
$id = (string) $id;
if (!isset($pages[$id])) {
continue;
}
$path = $pathOf($id);
if (isset($taken[$path])) {
throw new InvalidPage(sprintf('Die Adresse /%s/%s ist in %s bereits vergeben. Wähle für die Übersetzung eine andere Adresse.', $locale, $path, Languages::CATALOG[$locale]['name'] ?? $locale));
}
$taken[$path] = true;
$result[$locale][$id] = ['slug' => $slug, 'path' => $path, 'old' => $old[$locale][$id] ?? null];
}
}
return $result;
}
/**
* Bringt die Tabelle auf den ausgerechneten Stand. Geänderte Pfade wandern in zwei Schritten,
* damit zwei Seiten, die ihre Adressen tauschen, nicht am eindeutigen Index scheitern.
*/
private function writeTranslationPaths(array $paths, RedirectRepository $redirects): void
{
$keep = [];
foreach ($paths as $locale => $rows) {
foreach (array_keys($rows) as $id) {
$keep[$id.'|'.$locale] = true;
}
}
foreach ($this->db->fetchAllAssociative('SELECT page_id, locale FROM page_translation WHERE website_id = ?', ['default']) as $row) {
if (!isset($keep[$row['page_id'].'|'.$row['locale']])) {
$this->db->executeStatement('DELETE FROM page_translation WHERE page_id = ? AND locale = ?', [$row['page_id'], $row['locale']]);
}
}
$moved = [];
foreach ($paths as $locale => $rows) {
foreach ($rows as $id => $row) {
if (null === $row['old']) {
$this->db->insert('page_translation', ['page_id' => $id, 'website_id' => 'default', 'locale' => $locale, 'slug' => $row['slug'], 'path' => '~'.$id]);
$moved[] = [$id, $locale, $row];
} elseif ($row['old'] !== $row['path']) {
$this->db->executeStatement('UPDATE page_translation SET path = ? WHERE page_id = ? AND locale = ?', ['~'.$id, $id, $locale]);
$moved[] = [$id, $locale, $row];
} else {
$this->db->executeStatement('UPDATE page_translation SET slug = ? WHERE page_id = ? AND locale = ?', [$row['slug'], $id, $locale]);
}
}
}
foreach ($moved as [$id, $locale, $row]) {
$this->db->executeStatement('UPDATE page_translation SET slug = ?, path = ? WHERE page_id = ? AND locale = ?', [$row['slug'], $row['path'], $id, $locale]);
if (null !== $row['old']) {
$redirects->remember($locale.'/'.$row['old'], $locale.'/'.$row['path']);
}
}
}
/**
* Which header and footer a page wants: null for the standard one, 'none' for no area at all,
* or the address of another one.