['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 */ 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; } }