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

+275
View File
@@ -0,0 +1,275 @@
<?php
namespace App\Content;
/**
* Übersetzungen als Ebene über dem Blockbaum (ADR 111, Dokumentschema v27).
*
* Eine Seite wird einmal gebaut. Je weitere Sprache trägt ihr Dokument unter `translations`
* nur die Texte, die sich unterscheiden - adressiert über die stabile ID des Blocks:
*
* translations.en = {
* slug: "services", title: "…", description: "…", seoTitle: "…",
* blocks: { "blk_a1": { "text": "Our services" } },
* source: { "blk_a1.text": "1a2b3c4d" } // so sah der Text der Hauptsprache aus
* }
*
* Aufbau, Gestaltung, Bilder und Links bleiben dieselben. Ausgeliefert wird eine Sprache, indem
* die Texte auf eine Kopie des Dokuments gelegt werden (apply) - danach läuft dieselbe Prüfung
* und dieselbe Ausgabe wie für die Hauptsprache, ohne dass ein Renderer davon wissen muss. Ein
* Feld ohne Übersetzung zeigt den Text der Hauptsprache.
*
* Weil die Übersetzungen im Dokument stehen, gehören sie zu jeder Fassung: Entwurf,
* Veröffentlichung, Wiederherstellung und Undo im Editor gelten für alle Sprachen zugleich.
*/
final class Translations
{
public const SCHEMA = 27;
/**
* Welche Eigenschaften eines Blocks Text sind, den eine Übersetzung ersetzt - mit derselben
* Höchstlänge wie im DocumentValidator. Dieselbe Liste steht in translate.js.
*/
public const FIELDS = [
'heading' => ['text'],
'text' => ['text'],
'button' => ['text'],
'image' => ['alt', 'caption'],
'iconcard' => ['title', 'text'],
'stat' => ['value', 'label'],
'quote' => ['text', 'author', 'role'],
'cta' => ['title', 'text', 'buttonText'],
'contact' => ['address'],
'accordionitem' => ['title'],
'tabitem' => ['title'],
'legal' => ['label'],
'postnav' => ['previousLabel', 'nextLabel'],
'icon' => ['label'],
'progress' => ['label'],
'embed' => ['title', 'hint'],
];
/** Felder der Seite selbst und ihre Höchstlänge. */
public const PAGE_FIELDS = ['title' => 160, 'description' => 320, 'seoTitle' => 160];
/** Wie viele Einträge eine Sprache höchstens trägt - so viele, wie eine Seite Blöcke haben darf. */
public const MAX_BLOCKS = 300;
public static function supports(mixed $schemaVersion): bool
{
return is_int($schemaVersion) && $schemaVersion >= self::SCHEMA;
}
/** Die Sprachen, für die das Dokument überhaupt etwas übersetzt hat. @return list<string> */
public static function locales(array $document): array
{
$locales = [];
foreach (($document['translations'] ?? []) as $locale => $translation) {
if (is_string($locale) && is_array($translation) && self::hasContent($translation)) {
$locales[] = $locale;
}
}
return $locales;
}
/** Eine Übersetzung ohne Titel und ohne einen einzigen Text ist keine - nur ein Anfang. */
public static function hasContent(array $translation): bool
{
return '' !== trim((string) ($translation['title'] ?? '')) || [] !== array_filter($translation['blocks'] ?? [], static fn (mixed $fields): bool => is_array($fields) && [] !== $fields);
}
/** Das Dokument ohne Übersetzungen - so, wie die Hauptsprache es ausliefert. */
public static function strip(array $document): array
{
unset($document['translations']);
return $document;
}
/**
* Das Dokument in einer Sprache: die übersetzten Texte auf einer Kopie, die Übersetzungen
* selbst entfernt. Fehlt ein Text, bleibt der der Hauptsprache stehen.
*/
public static function apply(array $document, string $locale): array
{
$translation = $document['translations'][$locale] ?? [];
$document = self::strip($document);
if (!is_array($translation)) {
return $document;
}
foreach (['title', 'description'] as $field) {
if (is_string($translation[$field] ?? null) && '' !== trim($translation[$field])) {
$document[$field] = $translation[$field];
}
}
$blocks = is_array($translation['blocks'] ?? null) ? $translation['blocks'] : [];
if ([] !== $blocks) {
$document['blocks'] = self::overlay($document['blocks'] ?? [], $blocks);
}
return $document;
}
private static function overlay(array $list, array $translated): array
{
foreach ($list as $index => $block) {
if (!is_array($block)) {
continue;
}
$fields = $translated[$block['id'] ?? ''] ?? null;
$type = $block['type'] ?? '';
if (is_array($fields) && isset(self::FIELDS[$type])) {
foreach (self::FIELDS[$type] as $prop) {
$value = $fields[$prop] ?? null;
if (!is_string($value) || '' === trim($value) || !array_key_exists($prop, $block['props'] ?? [])) {
continue;
}
// Ein dekoratives Bild oder Symbol hat keinen Text - auch nicht übersetzt.
if (in_array($prop, ['alt', 'label'], true) && true === ($block['props']['decorative'] ?? false)) {
continue;
}
$list[$index]['props'][$prop] = $value;
}
}
if (is_array($block['children'] ?? null)) {
$list[$index]['children'] = self::overlay($block['children'], $translated);
}
}
return $list;
}
/**
* Räumt die Übersetzungen vor dem Speichern auf: formatierter Text geht durch denselben
* Filter wie in der Hauptsprache, leere Felder fallen weg, und Einträge für Blöcke, die es
* nicht mehr gibt, auch. Was danach noch ungültig ist, lehnt der DocumentValidator ab.
*/
public static function normalise(array $document, RichText $richText): array
{
if (!isset($document['translations']) || !is_array($document['translations'])) {
return $document;
}
$types = [];
$collect = static function (array $blocks) use (&$collect, &$types): void {
foreach ($blocks as $block) {
if (is_array($block) && is_string($block['id'] ?? null)) {
$types[$block['id']] = (string) ($block['type'] ?? '');
$collect(is_array($block['children'] ?? null) ? $block['children'] : []);
}
}
};
$collect(is_array($document['blocks'] ?? null) ? $document['blocks'] : []);
$rich = RichText::supports($document['schemaVersion'] ?? null);
$richHeadings = RichText::headings($document['schemaVersion'] ?? null);
foreach ($document['translations'] as $locale => $translation) {
if (!is_array($translation)) {
continue;
}
foreach (array_keys(self::PAGE_FIELDS) as $field) {
if (is_string($translation[$field] ?? null)) {
$translation[$field] = trim($translation[$field]);
if ('' === $translation[$field]) {
unset($translation[$field]);
}
}
}
if (is_string($translation['slug'] ?? null)) {
$translation['slug'] = strtolower(trim($translation['slug']));
if ('' === $translation['slug']) {
unset($translation['slug']);
}
}
$blocks = [];
foreach ((is_array($translation['blocks'] ?? null) ? $translation['blocks'] : []) as $id => $fields) {
$type = $types[$id] ?? null;
if (null === $type || !isset(self::FIELDS[$type]) || !is_array($fields)) {
continue;
}
foreach ($fields as $prop => $value) {
if (!in_array($prop, self::FIELDS[$type], true) || !is_string($value) || '' === trim($value)) {
continue;
}
if ('text' === $prop && (('text' === $type && $rich) || ('heading' === $type && $richHeadings))) {
$value = $richText->sanitise($value);
}
$blocks[$id][$prop] = $value;
}
}
$translation['blocks'] = $blocks;
if ([] === $blocks) {
unset($translation['blocks']);
}
// Nur Vermerke zu Texten, die es gibt; der Rest wäre ein Vermerk ohne Gegenstand.
$source = [];
foreach ((is_array($translation['source'] ?? null) ? $translation['source'] : []) as $key => $hash) {
[$id, $prop] = array_pad(explode('.', (string) $key, 2), 2, null);
$present = null === $prop ? isset($translation[$id]) : isset($blocks[$id][$prop]);
if ($present && is_string($hash)) {
$source[$key] = $hash;
}
}
$translation['source'] = $source;
if ([] === $source) {
unset($translation['source']);
}
$document['translations'][$locale] = $translation;
}
return $document;
}
/**
* Der Vermerk, wie ein Text der Hauptsprache beim Übersetzen aussah. FNV-1a, 32 Bit - kurz,
* und in PHP wie in JavaScript (translate.js) ohne Bibliothek gleich zu rechnen.
*/
public static function fingerprint(string $text): string
{
return hash('fnv1a32', $text);
}
/**
* Wie weit eine Sprache ist: übersetzbare Texte, davon übersetzt, davon seitdem in der
* Hauptsprache geändert.
*
* @return array{total: int, done: int, stale: int}
*/
public static function progress(array $document, string $locale): array
{
$translation = $document['translations'][$locale] ?? [];
$translation = is_array($translation) ? $translation : [];
$count = ['total' => 0, 'done' => 0, 'stale' => 0];
$tally = static function (string $key, string $original, mixed $value) use (&$count, $translation): void {
if ('' === trim($original)) {
return;
}
++$count['total'];
if (!is_string($value) || '' === trim($value)) {
return;
}
++$count['done'];
$hash = $translation['source'][$key] ?? null;
if (is_string($hash) && $hash !== self::fingerprint($original)) {
++$count['stale'];
}
};
foreach (['title', 'description'] as $field) {
$tally($field, (string) ($document[$field] ?? ''), $translation[$field] ?? null);
}
$walk = static function (array $blocks) use (&$walk, $tally, $translation): void {
foreach ($blocks as $block) {
foreach (self::FIELDS[$block['type'] ?? ''] ?? [] as $prop) {
if (in_array($prop, ['alt', 'label'], true) && true === ($block['props']['decorative'] ?? false)) {
continue;
}
$tally($block['id'].'.'.$prop, (string) ($block['props'][$prop] ?? ''), $translation['blocks'][$block['id']][$prop] ?? null);
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $count;
}
}