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

+20
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Content\Languages;
use App\Content\PostRepository;
use App\Content\SiteSettings;
use Doctrine\DBAL\Connection;
@@ -22,6 +23,7 @@ final class SeoController extends AbstractController
private PostRepository $posts,
private string $siteOrigin,
private string $adminPath,
private Languages $languages,
) {
}
@@ -44,6 +46,24 @@ final class SeoController extends AbstractController
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
];
}
// Jede weitere Sprache mit ihren veröffentlichten Seiten (ADR 111), unter denselben
// Bedingungen wie die Hauptsprache. Die Zuordnung der Sprachen zueinander steht als
// hreflang in den Seiten selbst.
$extra = $this->languages->extra();
if ([] !== $extra) {
foreach ($this->db->fetchAllAssociative(
"SELECT t.locale, t.path AS translated, p.path, p.published_at, r.created_at 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 WHERE t.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.id <> ? ORDER BY t.locale, t.path",
['default', $this->settings->load()['notFoundPageId']],
) as $row) {
if (!in_array($row['locale'], $extra, true)) {
continue;
}
$entries[] = [
'loc' => $origin.'/'.$row['locale'].($row['path'] === $home ? '' : '/'.$row['translated']),
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
];
}
}
// Archives exist only while they list something.
if ([] !== $this->posts->published(1, null, 1)['posts']) {
$entries[] = ['loc' => $origin.'/beitraege', 'lastmod' => null];