Files
deskhub/plugin-webseite/inc/i18n.php
T
2026-11-05 21:22:26 +00:00

149 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
/**
* Sprachwahl der Verwaltung (Deutsch ist die Quellsprache, English kommt aus lang/en.json).
*
* Übersetzt wird die fertige Seite, so wie bei DeskHub selbst: Textstücke zwischen den HTML-Tags und die Attribute placeholder, title,
* alt, aria-label und data-confirm. Ein Schlüssel ist der deutsche Text (Leerraum zählt nicht); "{}" steht für einen wechselnden Teil
* und "{#}" für eine Zahl; in der Übersetzung stehen "{1}", "{2}" für die gefundenen Teile (oder "{}" / "{#}" der Reihe nach).
* Was nicht übersetzt ist, bleibt deutsch. Inhalte in <code>, <pre>, <script> und <style> bleiben unverändert.
*
* Die Sprache steht in einem Cookie (site_lang); ?lang=en oder ?lang=de schaltet um.
*/
const SITE_LANGS = ['de' => 'Deutsch', 'en' => 'English'];
/** Gewählte Sprache: ?lang= (wird im Cookie gemerkt), sonst das Cookie, sonst Deutsch. */
function site_lang(): string
{
static $lang = null;
if ($lang !== null) return $lang;
$lang = 'de';
$asked = (string) ($_GET['lang'] ?? '');
if (isset(SITE_LANGS[$asked])) {
$lang = $asked;
if (!headers_sent()) {
setcookie('site_lang', $asked, ['expires' => time() + 365 * 86400, 'path' => '/', 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'httponly' => true, 'samesite' => 'Strict']);
}
} elseif (isset(SITE_LANGS[(string) ($_COOKIE['site_lang'] ?? '')])) {
$lang = (string) $_COOKIE['site_lang'];
}
return $lang;
}
/** @return array{exact:array<string,string>,patterns:list<array{0:string,1:string}>} */
function site_i18n_dictionary(string $lang): array
{
static $cache = [];
if (isset($cache[$lang])) return $cache[$lang];
$exact = [];
$patterns = [];
$file = dirname(__DIR__) . '/lang/' . $lang . '.json';
$data = $lang !== 'de' && preg_match('/^[a-z]{2}$/', $lang) && is_file($file) ? json_decode((string) file_get_contents($file), true) : null;
if (is_array($data)) {
foreach ($data as $de => $tr) {
if (!is_string($de) || !is_string($tr)) continue;
$key = site_i18n_norm($de);
if (strpos($key, '{}') === false && strpos($key, '{#}') === false) {
$exact[$key] = $tr;
continue;
}
$quoted = preg_quote($key, '#');
$regex = '#^' . str_replace(['\{\}', '\{\#\}'], ['(.+?)', '(\d+)'], $quoted) . '$#su';
$patterns[] = [$regex, $tr];
}
// Längere (genauere) Muster zuerst
usort($patterns, fn($a, $b) => strlen($b[0]) <=> strlen($a[0]));
}
return $cache[$lang] = ['exact' => $exact, 'patterns' => $patterns];
}
function site_i18n_norm(string $s): string
{
return trim((string) preg_replace('/\s+/u', ' ', $s));
}
/** Übersetzt einen (entkodierten) Text oder gibt null zurück, wenn es keine Übersetzung gibt. */
function site_i18n_lookup(string $norm, array $dict, int $depth = 0): ?string
{
if ($norm === '') return null;
if (isset($dict['exact'][$norm])) return $dict['exact'][$norm];
foreach ($dict['patterns'] as [$regex, $tr]) {
if (!preg_match($regex, $norm, $m)) continue;
array_shift($m);
// Gefundene Teile selbst übersetzen, wenn möglich (zum Beispiel der Seitenname im Titel)
if ($depth < 2) foreach ($m as $i => $part) $m[$i] = site_i18n_lookup(site_i18n_norm($part), $dict, $depth + 1) ?? $part;
if (preg_match('/\{\d+\}/', $tr)) {
return (string) preg_replace_callback('/\{(\d+)\}/', fn($x) => $m[(int) $x[1] - 1] ?? '', $tr);
}
$i = 0;
return (string) preg_replace_callback('/\{#?\}/', function () use (&$i, $m) { return $m[$i++] ?? ''; }, $tr);
}
return null;
}
/** Übersetzt eine fertige HTML-Seite. */
function site_translate_html(string $html, string $lang): string
{
$dict = site_i18n_dictionary($lang);
if (!$dict['exact'] && !$dict['patterns']) return $html;
$h = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$parts = preg_split('/(<!--.*?-->|<[^<>]*>)/su', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
$skip = 0;
$out = '';
foreach ($parts as $i => $part) {
if ($part === '') continue;
if ($i % 2 === 1) {
if (preg_match('/^<\s*(\/?)\s*(script|style|code|pre)\b/i', $part, $m)) {
$skip = max(0, $skip + ($m[1] === '/' ? -1 : (substr($part, -2) === '/>' ? 0 : 1)));
}
if ($part[1] !== '!' && $part[1] !== '/') {
$part = preg_replace_callback('/\b(placeholder|title|alt|aria-label|data-confirm)="([^"]*)"/i', function ($m) use ($dict, $h) {
$t = site_i18n_lookup(site_i18n_norm(html_entity_decode($m[2], ENT_QUOTES | ENT_HTML5, 'UTF-8')), $dict);
return $t === null ? $m[0] : $m[1] . '="' . $h($t) . '"';
}, $part) ?? $part;
}
$out .= $part;
continue;
}
if ($skip > 0) { $out .= $part; continue; }
$decoded = html_entity_decode($part, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$norm = site_i18n_norm($decoded);
$t = site_i18n_lookup($norm, $dict);
if ($t === null) { $out .= $part; continue; }
preg_match('/^\s*/u', $part, $lead);
preg_match('/\s*$/u', $part, $trail);
$out .= ($lead[0] ?? '') . htmlspecialchars($t, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8') . ($trail[0] ?? '');
}
return $out;
}
/** Startet die Übersetzung der folgenden Ausgabe (nur bei einer anderen Sprache als Deutsch). */
function site_i18n_start(): void
{
static $started = false;
if ($started) return;
$lang = site_lang();
if ($lang === 'de') return;
$started = true;
ob_start(fn(string $buffer) => site_translate_html($buffer, $lang));
}
/** Umschalter für den Seitenkopf: die jeweils andere Sprache als Link (behält die übrigen Angaben der Adresse). */
function site_lang_switcher(): string
{
$current = site_lang();
$query = $_GET;
$out = '';
foreach (SITE_LANGS as $code => $name) {
if ($code === $current) {
$out .= '<span class="lang on" aria-current="true">' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '</span>';
continue;
}
$query['lang'] = $code;
$out .= '<a class="lang" href="?' . htmlspecialchars(http_build_query($query), ENT_QUOTES, 'UTF-8') . '" hreflang="' . $code . '">' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '</a>';
}
return '<span class="langs">' . $out . '</span>';
}