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:
1 parent
c7705b7083
commit
e15b2517b1
51 files changed
+3045
-219
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user