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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user