code = $this->detect($registry); // Русский — исходный язык шаблонов, словарь ему не нужен. if ($this->code !== 'ru') { $file = APPLICATION_DIR . 'lang/' . $this->code . '.php'; if (is_readable($file)) { $map = include($file); if (is_array($map)) { // пустые переводы отбрасываем, иначе текст пропал бы со страницы foreach ($map as $from => $to) { if (is_string($from) && is_string($to) && $from !== '' && $to !== '') { $this->map[$from] = $to; } } } } } self::$instance = $this; } public static function instance() { return self::$instance; } public function code() { return $this->code; } public function available() { return self::AVAILABLE; } /** Человеческое имя языка для переключателя. */ public function name($code) { $names = array('ru' => 'Русский', 'en' => 'English'); return isset($names[$code]) ? $names[$code] : strtoupper($code); } /** Адрес текущей страницы с другим языком — для ссылок переключателя. */ public function switchUrl($code) { $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/'; $parts = explode('#', $uri, 2); $uri = $parts[0]; $parts = explode('?', $uri, 2); $path = $parts[0]; $query = array(); if (isset($parts[1]) && $parts[1] !== '') { parse_str($parts[1], $query); } $query['lang'] = $code; return $path . '?' . http_build_query($query); } private function detect($registry) { // 1. явный выбор if (isset($_GET['lang']) && in_array($_GET['lang'], self::AVAILABLE, true)) { $code = $_GET['lang']; if (!headers_sent()) { setcookie('lang', $code, time() + 31536000, '/'); } $_COOKIE['lang'] = $code; return $code; } // 2. прошлый выбор if (isset($_COOKIE['lang']) && in_array($_COOKIE['lang'], self::AVAILABLE, true)) { return $_COOKIE['lang']; } // 3. автоопределение по браузеру if (!empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { foreach (self::parseAccept($_SERVER['HTTP_ACCEPT_LANGUAGE']) as $tag) { $short = strtolower(substr($tag, 0, 2)); if (in_array($short, self::$ruFamily, true)) { return 'ru'; } if (in_array($short, self::AVAILABLE, true)) { return $short; } } // браузер просит язык, которого у нас нет и который не русскоязычный return 'en'; } // 4. значение по умолчанию из конфигурации if ($registry && isset($registry->config)) { $default = $registry->config->lang; if ($default && in_array($default, self::AVAILABLE, true)) { return $default; } } return 'ru'; } /** Разбор Accept-Language в список тегов по убыванию веса q. */ private static function parseAccept($header) { $tags = array(); foreach (explode(',', $header) as $chunk) { $chunk = trim($chunk); if ($chunk === '') { continue; } $q = 1.0; if (strpos($chunk, ';') !== false) { list($chunk, $params) = explode(';', $chunk, 2); if (preg_match('~q\s*=\s*([0-9.]+)~i', $params, $m)) { $q = (float)$m[1]; } $chunk = trim($chunk); } if ($chunk !== '' && $chunk !== '*') { $tags[] = array($chunk, $q); } } usort($tags, function($a, $b) { if ($a[1] == $b[1]) { return 0; } return ($a[1] < $b[1]) ? 1 : -1; }); $out = array(); foreach ($tags as $t) { $out[] = $t[0]; } return $out; } /** Перевод одной строки. Нет в словаре — возвращаем как есть. */ public function get($text) { return isset($this->map[$text]) ? $this->map[$text] : $text; } /** * Коллбэк для ob_start: переводит весь ответ перед отправкой. * Ответы AJAX обрабатываются отдельно: json_encode экранирует кириллицу * в \uXXXX, и словарь по сырому UTF-8 туда бы не попал. */ public function apply($buffer) { if ($this->code === 'ru' || !$this->map || !is_string($buffer) || $buffer === '') { return $buffer; } $head = ltrim($buffer); if ($head !== '' && ($head[0] === '{' || $head[0] === '[')) { $data = json_decode($head, true); if (json_last_error() === JSON_ERROR_NONE && is_array($data)) { $json = json_encode($this->walk($data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json !== false) { return $json; } } } return $this->replace($buffer); } private function walk($value) { if (is_array($value)) { $out = array(); foreach ($value as $k => $v) { $out[$k] = $this->walk($v); } return $out; } return is_string($value) ? $this->replace($value) : $value; } /** * Замена по словарю ТОЛЬКО на границах слов. * * Простой strtr() здесь не годится: короткий ключ подменяется внутри более * длинного слова и получается каша из двух языков — 'Мод' превращал * «Модуль» в «Modуль», а 'день' делал из «деньги» «dayги». Поэтому вокруг * каждого ключа требуем отсутствие буквы. Цифра слева разрешена намеренно: * иначе «0руб.» осталось бы без перевода. * * Ключи отсортированы от длинных к коротким, чтобы «Сменить пароль» * срабатывало раньше, чем «Пароль». */ private function replace($text) { if ($this->patterns === null) { $this->buildPatterns(); } foreach ($this->patterns as $pattern) { $out = preg_replace_callback($pattern, array($this, 'hit'), $text); if ($out !== null) { $text = $out; } } return $text; } private function hit($m) { return isset($this->map[$m[1]]) ? $this->map[$m[1]] : $m[1]; } private function buildPatterns() { $keys = array_keys($this->map); // длинные первыми: иначе короткий ключ перехватит часть длинной фразы usort($keys, function($a, $b) { $la = mb_strlen($a, 'UTF-8'); $lb = mb_strlen($b, 'UTF-8'); if ($la === $lb) { return strcmp($a, $b); } return ($la < $lb) ? 1 : -1; }); $this->patterns = array(); // режем на группы, чтобы не упереться в предел размера шаблона PCRE foreach (array_chunk($keys, 150) as $chunk) { $quoted = array(); foreach ($chunk as $k) { $quoted[] = preg_quote($k, '~'); } $this->patterns[] = '~(?get($text) : $text; if (func_num_args() > 1) { $args = func_get_args(); $args[0] = $out; $formatted = @call_user_func_array('sprintf', $args); if (is_string($formatted) && $formatted !== '') { return $formatted; } } return $out; }