Files
2026-11-04 07:55:19 +00:00

412 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* includes/i18n.php
*
* Mehrsprachigkeit. Der Quelltext von DeskHub ist deutsch; jede weitere Sprache kommt als Plugin ("Sprachpaket"), das nur Daten enthält:
*
* plugin.json {"name":"English","version":"1.0.0","entry":"plugin.php","language":{"code":"en","name":"English","flag":"flag.svg","strings":"strings.json","htmlLang":"en"}}
* plugin.php (leer; wird nur verlangt, weil jedes Plugin eine Startdatei hat)
* flag.svg Flagge (SVG oder PNG, höchstens 40 KB) – sie erscheint im Sprachwechsler
* strings.json {"Neues Ticket": "New ticket", "Willkommen, {}": "Welcome, {}", ...}
*
* Übersetzt wird die fertige Seite: Textstücke zwischen den HTML-Tags, Attribute wie placeholder/title/alt, Beschriftungen von Knöpfen und
* Textliteralen in confirm()/alert(). Ein Schlüssel muss genau dem deutschen Text entsprechen (Leerraum zählt nicht). "{}" steht für
* wechselnde Teile ("{#}" nur für Zahlen) ("Willkommen, {}"); "{1}", "{2}" in der Übersetzung erlauben eine andere Reihenfolge. Was nicht übersetzt ist, bleibt deutsch.
*
* Sprache wählen: Cookie "deskhub_lang" (gesetzt über /lang.php, dem Klick auf eine Flagge), sonst die Standardsprache (Einstellung
* "default_language", sonst Deutsch). Ist Deutsch gewählt und der Sammelmodus aus, läuft nichts davon: die Seiten werden unverändert ausgeliefert.
*
* Sammelmodus (Adminportal → Sprachen): merkt sich die deutschen Texte der besuchten Seiten, daraus entsteht eine Vorlage für Übersetzer.
*/
const I18N_COOKIE = 'deskhub_lang';
const I18N_FLAG_MAX_BYTES = 40960;
const I18N_MAX_STRINGS_BYTES = 2 * 1024 * 1024;
/** Eingebaute Flagge für Deutsch (Quellsprache; sie ist immer verfügbar, auch ohne Plugin). */
function i18n_builtin_flag_de(): string
{
return 'data:image/svg+xml;base64,' . base64_encode('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 3"><rect width="5" height="3" fill="#FFCE00"/><rect width="5" height="2" fill="#DD0000"/><rect width="5" height="1"/></svg>');
}
/**
* Verfügbare Sprachen: Deutsch (eingebaut) und alle aktiven Sprachpakete.
* @return array<string,array{code:string,name:string,flag:string,htmlLang:string,slug:string,strings:string}> Schlüssel = Sprachcode
*/
function i18n_languages(bool $refresh = false): array
{
static $cache = null;
if ($cache !== null && !$refresh) return $cache;
$langs = ['de' => ['code' => 'de', 'name' => 'Deutsch', 'flag' => i18n_builtin_flag_de(), 'htmlLang' => 'de', 'slug' => '', 'strings' => '']];
try {
if (function_exists('plugins_globally_disabled') && plugins_globally_disabled()) return $cache = $langs;
$rows = get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable $e) {
return $cache = $langs;
}
foreach ($rows as $slug) {
$slug = (string) $slug;
if (!plugin_valid_slug($slug)) continue;
$dir = plugins_dir() . '/' . $slug;
if (!is_file($dir . '/plugin.json')) continue;
$raw = json_decode((string) file_get_contents($dir . '/plugin.json'), true);
$l = is_array($raw) && is_array($raw['language'] ?? null) ? $raw['language'] : null;
if (!$l) continue;
[$manifest] = plugin_read_manifest($dir);
if (!$manifest) continue;
$code = strtolower((string) ($l['code'] ?? ''));
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code)) continue;
$flag = '';
$flagFile = (string) ($l['flag'] ?? '');
if ($flagFile !== '' && plugin_package_path_ok($flagFile) && is_file($dir . '/' . $flagFile) && filesize($dir . '/' . $flagFile) <= I18N_FLAG_MAX_BYTES) {
$ext = strtolower(pathinfo($flagFile, PATHINFO_EXTENSION));
$mime = ['svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp'][$ext] ?? '';
if ($mime !== '') $flag = 'data:' . $mime . ';base64,' . base64_encode((string) file_get_contents($dir . '/' . $flagFile));
}
$stringsFile = (string) ($l['strings'] ?? 'strings.json');
if (!plugin_package_path_ok($stringsFile) || strtolower(pathinfo($stringsFile, PATHINFO_EXTENSION)) !== 'json') $stringsFile = '';
$langs[$code] = [
'code' => $code,
'name' => mb_substr(trim((string) ($l['name'] ?? $code)), 0, 40) ?: $code,
'flag' => $flag !== '' ? $flag : ($code === 'de' ? i18n_builtin_flag_de() : ''),
'htmlLang' => preg_match('/^[A-Za-z\-]{2,12}$/', (string) ($l['htmlLang'] ?? '')) ? (string) $l['htmlLang'] : $code,
'slug' => $slug,
'strings' => $stringsFile !== '' ? $dir . '/' . $stringsFile : '',
];
}
return $cache = $langs;
}
/** Standardsprache der Seite (Einstellung), sonst Deutsch. */
function i18n_default(): string
{
$d = strtolower((string) get_setting('default_language', 'de'));
return isset(i18n_languages()[$d]) ? $d : 'de';
}
/** Sprache dieser Anfrage: Cookie, sonst Standardsprache. */
function i18n_current(): string
{
$c = strtolower((string) ($_COOKIE[I18N_COOKIE] ?? ''));
return $c !== '' && isset(i18n_languages()[$c]) ? $c : i18n_default();
}
/**
* Wörterbuch einer Sprache: exakte Texte und Muster mit "{}".
* @return array{exact:array<string,string>,patterns:list<array{0:string,1:string}>,count:int}
*/
function i18n_dictionary(string $code, bool $reload = false): array
{
static $cache = [];
if ($reload) unset($cache[$code]);
if (isset($cache[$code])) return $cache[$code];
$dict = ['exact' => [], 'patterns' => [], 'count' => 0];
$files = [];
// Zuerst die eigenen Übersetzungen der aktiven Plugins (lang/<code>.json), danach das Sprachpaket: Es gewinnt bei gleichem Text,
// damit sich ein Plugin-Text im Sprachpaket verbessern lässt.
foreach (i18n_plugin_string_files($code) as $f) $files[] = $f;
$packFile = i18n_languages()[$code]['strings'] ?? '';
if ($packFile !== '') $files[] = $packFile;
foreach ($files as $file) i18n_load_strings_file($file, $dict);
// Genauere (längere) Muster zuerst: Sonst würde ein allgemeines wie „Ticket #{}“ ein spezielleres wie „Ticket #{} wurde nicht gefunden.“ überdecken
if (count($dict['patterns']) > 1) {
$order = array_keys($dict['patterns']);
usort($order, fn($x, $y) => strlen((string) ($dict['pkeys'][$y] ?? '')) <=> strlen((string) ($dict['pkeys'][$x] ?? '')));
$dict['patterns'] = array_map(fn($i) => $dict['patterns'][$i], $order);
$dict['pkeys'] = array_map(fn($i) => $dict['pkeys'][$i] ?? '', $order);
}
return $cache[$code] = $dict;
}
/**
* Übersetzungsdateien der aktiven Plugins für eine Sprache: <Plugin>/lang/<code>.json (gleiches Format wie strings.json eines Sprachpakets).
* So bringt jedes Plugin seine Texte selbst mit; eine neue Sprache braucht dann nur die Datei lang/<code>.json im Plugin.
* @return array<string,string> Plugin-Kennung => Dateipfad
*/
function i18n_plugin_string_files(string $code): array
{
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code) || $code === 'de') return [];
$slugs = [];
try {
if (function_exists('plugins_globally_disabled') && plugins_globally_disabled()) return [];
foreach (get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN) as $slug) {
if (plugin_valid_slug((string) $slug)) $slugs[] = (string) $slug;
}
} catch (Throwable $e) {
return [];
}
$out = [];
foreach ($slugs as $slug) {
$file = plugins_dir() . '/' . $slug . '/lang/' . $code . '.json';
if (is_file($file)) $out[$slug] = $file;
}
return $out;
}
/**
* Welche Plugins bringen für welche Sprachen eigene Übersetzungen mit? (Anzeige unter Sprachen)
* @return array<string,array<string,int>> Plugin-Kennung => [Sprachcode => Anzahl Texte]
*/
function i18n_plugin_language_overview(): array
{
$out = [];
try {
$slugs = get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable $e) {
return [];
}
foreach ($slugs as $slug) {
$slug = (string) $slug;
if (!plugin_valid_slug($slug)) continue;
foreach (glob(plugins_dir() . '/' . $slug . '/lang/*.json') ?: [] as $f) {
$code = strtolower(basename($f, '.json'));
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code) || filesize($f) > I18N_MAX_STRINGS_BYTES) continue;
$data = json_decode((string) file_get_contents($f), true);
$n = 0;
if (is_array($data)) foreach ($data as $k => $v) if (is_string($v) && $v !== '' && (string) $k !== '' && ((string) $k)[0] !== '_') $n++;
$out[$slug][$code] = $n;
}
}
return $out;
}
/** Liest eine strings.json (Schlüssel = deutscher Text) in ein Wörterbuch ein; spätere Dateien überschreiben frühere. */
function i18n_load_strings_file(string $file, array &$dict): void
{
if ($file === '' || !is_file($file) || filesize($file) > I18N_MAX_STRINGS_BYTES) return;
$data = json_decode((string) file_get_contents($file), true);
if (!is_array($data)) return;
foreach ($data as $key => $value) {
$key = (string) $key;
if (!is_string($value) || $value === '' || $key === '' || $key[0] === '_') continue;
$norm = i18n_normalize($key);
if (str_contains($norm, '{}') || str_contains($norm, '{#}')) {
// {} = beliebiger Text, {#} = nur Ziffern
$pieces = preg_split('/(\{#\}|\{\})/', $norm, -1, PREG_SPLIT_DELIM_CAPTURE);
$regex = '/^';
foreach ($pieces as $k => $piece) $regex .= $k % 2 === 0 ? preg_quote($piece, '/') : ($piece === '{#}' ? '(\d+)' : '(.+?)');
$regex .= '$/su';
// gleicher Schlüssel in einer späteren Datei ersetzt das frühere Muster
foreach ($dict['patterns'] as $i => $p) if (($dict['pkeys'][$i] ?? null) === $norm) { unset($dict['patterns'][$i], $dict['pkeys'][$i]); $dict['count']--; }
$dict['patterns'] = array_values($dict['patterns']);
$dict['pkeys'] = array_values($dict['pkeys'] ?? []);
$dict['patterns'][] = [$regex, $value];
$dict['pkeys'][] = $norm;
$dict['count']++;
} else {
if (!isset($dict['exact'][$norm])) $dict['count']++;
$dict['exact'][$norm] = $value;
}
}
}
/** Entities auflösen, Leerraum zusammenfassen: so werden Schlüssel und Seitentexte verglichen. */
function i18n_normalize(string $text): string
{
return trim((string) preg_replace('/\s+/u', ' ', html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
}
/** Übersetzt einen (normalisierten) Text; null = keine Übersetzung bekannt. */
function i18n_lookup(string $norm, array $dict): ?string
{
if ($norm === '') return null;
if (isset($dict['exact'][$norm])) return $dict['exact'][$norm];
foreach ($dict['patterns'] as [$regex, $value]) {
if (preg_match($regex, $norm, $m)) {
$value = preg_replace_callback('/\{(\d+)\}/', fn($x) => $m[(int) $x[1]] ?? '', $value);
$i = 0;
return preg_replace_callback('/\{\}/', function () use ($m, &$i) { $i++; return $m[$i] ?? ''; }, (string) $value);
}
}
return null;
}
/** Texte, die nicht übersetzt werden müssen: Zahlen, Adressen, Zeiten, reine Zeichen. */
function i18n_translatable(string $norm): bool
{
if (mb_strlen($norm) < 2 || mb_strlen($norm) > 400) return false;
if (!preg_match('/\p{L}{2}/u', $norm)) return false;
if (preg_match('#^(https?://|/|[\w.+-]+@[\w-]+\.)#i', $norm)) return false;
return true;
}
/**
* Übersetzt eine HTML-Seite. $seen sammelt (wenn übergeben) die deutschen Texte, für die es keine Übersetzung gab.
* @param array<string,bool>|null $seen
*/
function i18n_translate_html(string $html, string $code, ?array &$seen = null): string
{
$dict = $code === 'de' ? ['exact' => [], 'patterns' => [], 'count' => 0] : i18n_dictionary($code);
$collect = $seen !== null;
if (!$collect && !$dict['count']) return $html;
$note = function (string $norm) use (&$seen, $collect): void {
if ($collect && i18n_translatable($norm)) $seen[$norm] = true;
};
$tr = function (string $norm) use ($dict, $note): ?string {
$t = i18n_lookup($norm, $dict);
if ($t === null) $note($norm);
return $t;
};
// JavaScript: Textliterale, die genau einem Schlüssel entsprechen
$jsLiterals = function (string $js, bool $collectCalls) use ($dict, $note): string {
return (string) preg_replace_callback('/(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', function ($m) use ($dict, $note) {
$raw = str_replace(["\\'", '\\"', '\\\\', '\\n'], ["'", '"', '\\', ' '], $m[2]);
$norm = trim((string) preg_replace('/\s+/u', ' ', $raw));
if ($norm === '') return $m[0];
$t = i18n_lookup($norm, $dict);
return $t === null ? $m[0] : json_encode($t, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}, $js);
};
$jsCollect = function (string $js) use ($note, $dict): void {
if (preg_match_all('/(?:confirm|alert|prompt|toast)\(\s*(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', $js, $mm)) {
foreach ($mm[2] as $lit) {
$norm = trim((string) preg_replace('/\s+/u', ' ', str_replace(["\\'", '\\"', '\\\\'], ["'", '"', '\\'], $lit)));
if (i18n_lookup($norm, $dict) === null) $note($norm); // schon Übersetztes nicht noch einmal sammeln
}
}
};
// Attribute eines Tags
$tag = function (string $t) use ($tr, $dict, $note, $jsCollect): string {
$isButton = (bool) preg_match('/^<input\b[^>]*\btype\s*=\s*["\']?(submit|button|reset)\b/i', $t);
return (string) preg_replace_callback('/\b(placeholder|title|alt|aria-label|value|on[a-z]+)\s*=\s*(")([^"]*)"|\b(placeholder|title|alt|aria-label|value|on[a-z]+)\s*=\s*(\')([^\']*)\'/i', function ($m) use ($tr, $dict, $isButton, $jsCollect) {
$name = strtolower($m[1] !== '' ? $m[1] : $m[4]);
$quote = $m[1] !== '' ? $m[2] : $m[5];
$val = $m[1] !== '' ? $m[3] : $m[6];
if ($name === 'value' && !$isButton) return $m[0];
if (str_starts_with($name, 'on')) {
$js = html_entity_decode($val, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$jsCollect($js);
$new = preg_replace_callback('/(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', function ($x) use ($dict) {
$raw = str_replace(["\\'", '\\"', '\\\\'], ["'", '"', '\\'], $x[2]);
$t = i18n_lookup(trim((string) preg_replace('/\s+/u', ' ', $raw)), $dict);
return $t === null ? $x[0] : "'" . addcslashes($t, "'\\") . "'";
}, $js);
return $new === $js ? $m[0] : substr($m[0], 0, strpos($m[0], '=') + 1) . $quote . htmlspecialchars((string) $new, ENT_COMPAT | ENT_SUBSTITUTE, 'UTF-8') . $quote;
}
$norm = i18n_normalize($val);
$t = $tr($norm);
return $t === null ? $m[0] : substr($m[0], 0, strpos($m[0], '=') + 1) . $quote . htmlspecialchars($t, ENT_COMPAT | ENT_SUBSTITUTE, 'UTF-8') . $quote;
}, $t);
};
$parts = preg_split('/(<!--.*?-->|<script\b.*?<\/script>|<style\b.*?<\/style>|<[^>]+>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
if ($parts === false) return $html;
$out = '';
$skip = false; // innerhalb <textarea> stehen Eingaben, keine Beschriftungen
foreach ($parts as $i => $part) {
if ($part === '') continue;
if ($i % 2 === 0) { // Text
if ($skip || trim($part) === '') { $out .= $part; continue; }
if (!preg_match('/^(\s*)(.*?)(\s*)$/su', $part, $m)) { $out .= $part; continue; }
$t = $tr(i18n_normalize($m[2]));
$out .= $t === null ? $part : $m[1] . htmlspecialchars($t, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8') . $m[3];
continue;
}
if ($part[0] === '<' && $part[1] === '!') { $out .= $part; continue; } // Kommentar / doctype
if (stripos($part, '<script') === 0) {
if (preg_match('/^(<script\b[^>]*>)(.*)(<\/script>)$/is', $part, $m) && !preg_match('/\bsrc\s*=/i', $m[1])) {
if ($collect) $jsCollect($m[2]);
$out .= $m[1] . ($dict['count'] ? $jsLiterals($m[2], $collect) : $m[2]) . $m[3];
} else {
$out .= $part;
}
continue;
}
if (stripos($part, '<style') === 0) { $out .= $part; continue; }
if (stripos($part, '<textarea') === 0) $skip = true;
elseif (stripos($part, '</textarea') === 0) $skip = false;
if (stripos($part, '<html') === 0 && $code !== 'de') {
$lang = i18n_languages()[$code]['htmlLang'] ?? $code;
$part = preg_match('/\blang\s*=/i', $part) ? (string) preg_replace('/\blang\s*=\s*(["\'])[^"\']*\1/i', 'lang="' . $lang . '"', $part, 1) : (string) preg_replace('/^<html/i', '<html lang="' . $lang . '"', $part, 1);
}
$out .= $tag($part);
}
return $out;
}
/** Ausgabe-Filter der Seite (ob_start-Callback): nur HTML-Antworten werden angefasst. */
function i18n_output_filter(string $buffer, int $phase = 0)
{
try {
foreach (headers_list() as $h) {
if (stripos($h, 'content-type:') === 0 && stripos($h, 'text/html') === false) return false;
}
if (strlen($buffer) < 20 || !preg_match('/<[a-z!]/i', $buffer) || strlen($buffer) > 6 * 1024 * 1024) return false;
$code = $GLOBALS['__i18n_code'] ?? 'de';
$seen = null;
if (get_setting('i18n_collect', '0') === '1') $seen = [];
$html = i18n_translate_html($buffer, $code, $seen);
if ($seen) i18n_remember(array_keys($seen));
return $html;
} catch (Throwable $e) {
return false;
}
}
/** Merkt sich unübersetzte Texte (Sammelmodus). Höchstens 6000 Einträge. */
function i18n_remember(array $texts): void
{
$pdo = get_db();
$texts = array_slice(array_values(array_unique(array_map(fn($t) => mb_substr((string) $t, 0, 400), $texts))), 0, 400);
if (!$texts) return;
$hashes = array_map('md5', $texts);
$in = implode(',', array_fill(0, count($hashes), '?'));
$have = $pdo->prepare("SELECT text_hash FROM i18n_missing WHERE text_hash IN ($in)");
$have->execute($hashes);
$known = array_flip($have->fetchAll(PDO::FETCH_COLUMN));
$room = 6000 - (int) $pdo->query('SELECT COUNT(*) FROM i18n_missing')->fetchColumn();
$ins = $pdo->prepare('INSERT INTO i18n_missing (text_hash, text, hits, last_seen) VALUES (?,?,1,?)');
$upd = $pdo->prepare('UPDATE i18n_missing SET hits = hits + 1, last_seen = ? WHERE text_hash = ?');
foreach ($texts as $k => $t) {
if (isset($known[$hashes[$k]])) { $upd->execute([db_now(), $hashes[$k]]); continue; }
if ($room-- <= 0) break;
try { $ins->execute([$hashes[$k], $t, db_now()]); } catch (Throwable $e) { }
}
}
/** Startet die Übersetzung dieser Anfrage (wird von bootstrap.php aufgerufen). */
function i18n_start(): void
{
if (PHP_SAPI === 'cli' || defined('DESKHUB_NO_I18N')) return;
$path = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
if (str_starts_with($path, '/api/') || str_ends_with($path, '/lang.php')) return;
$code = i18n_current();
$GLOBALS['__i18n_code'] = $code;
if ($code === 'de' && get_setting('i18n_collect', '0') !== '1') return;
ob_start('i18n_output_filter');
}
/** Pfad für „zurück“ nach dem Sprachwechsel: nur Adressen dieser Seite. */
function i18n_safe_back(string $back): string
{
if ($back === '' || $back[0] !== '/' || str_starts_with($back, '//') || str_contains($back, '\\') || preg_match('/[\r\n\0]/', $back)) return '/';
return $back;
}
/**
* Sprachwechsler mit Flaggen. Leer, solange es nur eine Sprache gibt.
* $variant: "inline" (helle Leiste), "dark" (dunkle Seitenleiste) oder "floating" (feste Position oben rechts, für Anmeldeseiten).
*/
function i18n_switcher_html(string $variant = 'inline'): string
{
$langs = i18n_languages();
if (count($langs) < 2) return '';
$cur = $GLOBALS['__i18n_code'] ?? i18n_current();
$back = rawurlencode((string) ($_SERVER['REQUEST_URI'] ?? '/'));
$wrapStyle = 'display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap;' . ($variant === 'floating' ? 'position:fixed;bottom:14px;right:14px;z-index:9999;background:rgba(255,255,255,.88);padding:5px 8px;border-radius:20px;box-shadow:0 1px 4px rgba(0,0,0,.18)' : '');
$html = '<span class="lang-switch" role="group" aria-label="Language" style="' . $wrapStyle . '">';
foreach ($langs as $code => $l) {
$on = $code === $cur;
$inner = $l['flag'] !== ''
? '<img src="' . htmlspecialchars($l['flag'], ENT_QUOTES) . '" alt="' . htmlspecialchars($l['name'], ENT_QUOTES) . '" width="24" height="16" style="display:block;width:24px;height:16px;object-fit:cover;border-radius:3px">'
: '<span style="font-size:.75rem;font-weight:700">' . htmlspecialchars(strtoupper($code)) . '</span>';
$html .= '<a href="/lang.php?l=' . rawurlencode($code) . '&amp;back=' . $back . '" hreflang="' . htmlspecialchars($l['htmlLang'], ENT_QUOTES) . '" title="' . htmlspecialchars($l['name'], ENT_QUOTES) . '"'
. ' style="display:inline-block;line-height:0;padding:2px;border-radius:5px;text-decoration:none;opacity:' . ($on ? '1' : '.6') . ';outline:' . ($on ? '2px solid ' . ($variant === 'dark' ? '#8bc4c2' : 'var(--primary,#0f6f6a)') : 'none') . ';outline-offset:1px"'
. ($on ? ' aria-current="true"' : '') . '>' . $inner . '</a>';
}
return $html . '</span>';
}