Language.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. <?php
  2. /**
  3. * @package Grav\Common\Language
  4. *
  5. * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Common\Language;
  9. use Grav\Common\Debugger;
  10. use Grav\Common\Grav;
  11. use Grav\Common\Config\Config;
  12. use Negotiation\AcceptLanguage;
  13. use Negotiation\LanguageNegotiator;
  14. use function array_key_exists;
  15. use function count;
  16. use function in_array;
  17. use function is_array;
  18. use function is_string;
  19. /**
  20. * Class Language
  21. * @package Grav\Common\Language
  22. */
  23. class Language
  24. {
  25. /** @var Grav */
  26. protected $grav;
  27. /** @var Config */
  28. protected $config;
  29. /** @var bool */
  30. protected $enabled = true;
  31. /** @var array */
  32. protected $languages = [];
  33. /** @var array */
  34. protected $fallback_languages = [];
  35. /** @var array */
  36. protected $fallback_extensions = [];
  37. /** @var array */
  38. protected $page_extensions = [];
  39. /** @var string|false */
  40. protected $default;
  41. /** @var string|false */
  42. protected $active;
  43. /** @var array */
  44. protected $http_accept_language;
  45. /** @var bool */
  46. protected $lang_in_url = false;
  47. /**
  48. * Constructor
  49. *
  50. * @param Grav $grav
  51. */
  52. public function __construct(Grav $grav)
  53. {
  54. $this->grav = $grav;
  55. $this->config = $grav['config'];
  56. $languages = $this->config->get('system.languages.supported', []);
  57. foreach ($languages as &$language) {
  58. $language = (string)$language;
  59. }
  60. unset($language);
  61. $this->languages = $languages;
  62. $this->init();
  63. }
  64. /**
  65. * Initialize the default and enabled languages
  66. *
  67. * @return void
  68. */
  69. public function init()
  70. {
  71. $default = $this->config->get('system.languages.default_lang');
  72. if (null !== $default) {
  73. $default = (string)$default;
  74. }
  75. // Note that reset returns false on empty languages.
  76. $this->default = $default ?? reset($this->languages);
  77. $this->resetFallbackPageExtensions();
  78. if (empty($this->languages)) {
  79. // If no languages are set, turn of multi-language support.
  80. $this->enabled = false;
  81. } elseif ($default && !in_array($default, $this->languages, true)) {
  82. // If default language isn't in the language list, we need to add it.
  83. array_unshift($this->languages, $default);
  84. }
  85. }
  86. /**
  87. * Ensure that languages are enabled
  88. *
  89. * @return bool
  90. */
  91. public function enabled()
  92. {
  93. return $this->enabled;
  94. }
  95. /**
  96. * Returns true if language debugging is turned on.
  97. *
  98. * @return bool
  99. */
  100. public function isDebug(): bool
  101. {
  102. return !$this->config->get('system.languages.translations', true);
  103. }
  104. /**
  105. * Gets the array of supported languages
  106. *
  107. * @return array
  108. */
  109. public function getLanguages()
  110. {
  111. return $this->languages;
  112. }
  113. /**
  114. * Sets the current supported languages manually
  115. *
  116. * @param array $langs
  117. * @return void
  118. */
  119. public function setLanguages($langs)
  120. {
  121. $this->languages = $langs;
  122. $this->init();
  123. }
  124. /**
  125. * Gets a pipe-separated string of available languages
  126. *
  127. * @param string|null $delimiter Delimiter to be quoted.
  128. * @return string
  129. */
  130. public function getAvailable($delimiter = null)
  131. {
  132. $languagesArray = $this->languages; //Make local copy
  133. $languagesArray = array_map(static function ($value) use ($delimiter) {
  134. return preg_quote($value, $delimiter);
  135. }, $languagesArray);
  136. sort($languagesArray);
  137. return implode('|', array_reverse($languagesArray));
  138. }
  139. /**
  140. * Gets language, active if set, else default
  141. *
  142. * @return string|false
  143. */
  144. public function getLanguage()
  145. {
  146. return $this->active ?: $this->default;
  147. }
  148. /**
  149. * Gets current default language
  150. *
  151. * @return string|false
  152. */
  153. public function getDefault()
  154. {
  155. return $this->default;
  156. }
  157. /**
  158. * Sets default language manually
  159. *
  160. * @param string $lang
  161. * @return string|bool
  162. */
  163. public function setDefault($lang)
  164. {
  165. $lang = (string)$lang;
  166. if ($this->validate($lang)) {
  167. $this->default = $lang;
  168. return $lang;
  169. }
  170. return false;
  171. }
  172. /**
  173. * Gets current active language
  174. *
  175. * @return string|false
  176. */
  177. public function getActive()
  178. {
  179. return $this->active;
  180. }
  181. /**
  182. * Sets active language manually
  183. *
  184. * @param string|false $lang
  185. * @return string|false
  186. */
  187. public function setActive($lang)
  188. {
  189. $lang = (string)$lang;
  190. if ($this->validate($lang)) {
  191. /** @var Debugger $debugger */
  192. $debugger = $this->grav['debugger'];
  193. $debugger->addMessage('Active language set to ' . $lang, 'debug');
  194. $this->active = $lang;
  195. return $lang;
  196. }
  197. return false;
  198. }
  199. /**
  200. * Sets the active language based on the first part of the URL
  201. *
  202. * @param string $uri
  203. * @return string
  204. */
  205. public function setActiveFromUri($uri)
  206. {
  207. $regex = '/(^\/(' . $this->getAvailable() . '))(?:\/|\?|$)/i';
  208. // if languages set
  209. if ($this->enabled()) {
  210. // Try setting language from prefix of URL (/en/blah/blah).
  211. if (preg_match($regex, $uri, $matches)) {
  212. $this->lang_in_url = true;
  213. $this->setActive($matches[2]);
  214. $uri = preg_replace("/\\" . $matches[1] . '/', '', $uri, 1);
  215. // Store in session if language is different.
  216. if (isset($this->grav['session']) && $this->grav['session']->isStarted()
  217. && $this->config->get('system.languages.session_store_active', true)
  218. && $this->grav['session']->active_language != $this->active
  219. ) {
  220. $this->grav['session']->active_language = $this->active;
  221. }
  222. } else {
  223. // Try getting language from the session, else no active.
  224. if (isset($this->grav['session']) && $this->grav['session']->isStarted() &&
  225. $this->config->get('system.languages.session_store_active', true)) {
  226. $this->setActive($this->grav['session']->active_language ?: null);
  227. }
  228. // if still null, try from http_accept_language header
  229. if ($this->active === null &&
  230. $this->config->get('system.languages.http_accept_language') &&
  231. $accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? false) {
  232. $negotiator = new LanguageNegotiator();
  233. $best_language = $negotiator->getBest($accept, $this->languages);
  234. if ($best_language instanceof AcceptLanguage) {
  235. $this->setActive($best_language->getType());
  236. } else {
  237. $this->setActive($this->getDefault());
  238. }
  239. }
  240. }
  241. }
  242. return $uri;
  243. }
  244. /**
  245. * Get a URL prefix based on configuration
  246. *
  247. * @param string|null $lang
  248. * @return string
  249. */
  250. public function getLanguageURLPrefix($lang = null)
  251. {
  252. if (!$this->enabled()) {
  253. return '';
  254. }
  255. // if active lang is not passed in, use current active
  256. if (!$lang) {
  257. $lang = $this->getLanguage();
  258. }
  259. return $this->isIncludeDefaultLanguage($lang) ? '/' . $lang : '';
  260. }
  261. /**
  262. * Test to see if language is default and language should be included in the URL
  263. *
  264. * @param string|null $lang
  265. * @return bool
  266. */
  267. public function isIncludeDefaultLanguage($lang = null)
  268. {
  269. if (!$this->enabled()) {
  270. return false;
  271. }
  272. // if active lang is not passed in, use current active
  273. if (!$lang) {
  274. $lang = $this->getLanguage();
  275. }
  276. return !($this->default === $lang && $this->config->get('system.languages.include_default_lang') === false);
  277. }
  278. /**
  279. * Simple getter to tell if a language was found in the URL
  280. *
  281. * @return bool
  282. */
  283. public function isLanguageInUrl()
  284. {
  285. return (bool) $this->lang_in_url;
  286. }
  287. /**
  288. * Get full list of used language page extensions: [''=>'.md', 'en'=>'.en.md', ...]
  289. *
  290. * @param string|null $fileExtension
  291. * @return array
  292. */
  293. public function getPageExtensions($fileExtension = null)
  294. {
  295. $fileExtension = $fileExtension ?: CONTENT_EXT;
  296. if (!isset($this->fallback_extensions[$fileExtension])) {
  297. $extensions[''] = $fileExtension;
  298. foreach ($this->languages as $code) {
  299. $extensions[$code] = ".{$code}{$fileExtension}";
  300. }
  301. $this->fallback_extensions[$fileExtension] = $extensions;
  302. }
  303. return $this->fallback_extensions[$fileExtension];
  304. }
  305. /**
  306. * Gets an array of valid extensions with active first, then fallback extensions
  307. *
  308. * @param string|null $fileExtension
  309. * @param string|null $languageCode
  310. * @param bool $assoc Return values in ['en' => '.en.md', ...] format.
  311. * @return array Key is the language code, value is the file extension to be used.
  312. */
  313. public function getFallbackPageExtensions(string $fileExtension = null, string $languageCode = null, bool $assoc = false)
  314. {
  315. $fileExtension = $fileExtension ?: CONTENT_EXT;
  316. $key = $fileExtension . '-' . ($languageCode ?? 'default') . '-' . (int)$assoc;
  317. if (!isset($this->fallback_extensions[$key])) {
  318. $all = $this->getPageExtensions($fileExtension);
  319. $list = [];
  320. $fallback = $this->getFallbackLanguages($languageCode, true);
  321. foreach ($fallback as $code) {
  322. $ext = $all[$code] ?? null;
  323. if (null !== $ext) {
  324. $list[$code] = $ext;
  325. }
  326. }
  327. if (!$assoc) {
  328. $list = array_values($list);
  329. }
  330. $this->fallback_extensions[$key] = $list;
  331. }
  332. return $this->fallback_extensions[$key];
  333. }
  334. /**
  335. * Resets the fallback_languages value.
  336. *
  337. * Useful to re-initialize the pages and change site language at runtime, example:
  338. *
  339. * ```
  340. * $this->grav['language']->setActive('it');
  341. * $this->grav['language']->resetFallbackPageExtensions();
  342. * $this->grav['pages']->init();
  343. * ```
  344. *
  345. * @return void
  346. */
  347. public function resetFallbackPageExtensions()
  348. {
  349. $this->fallback_languages = [];
  350. $this->fallback_extensions = [];
  351. $this->page_extensions = [];
  352. }
  353. /**
  354. * Gets an array of languages with active first, then fallback languages.
  355. *
  356. *
  357. * @param string|null $languageCode
  358. * @param bool $includeDefault If true, list contains '', which can be used for default
  359. * @return array
  360. */
  361. public function getFallbackLanguages(string $languageCode = null, bool $includeDefault = false)
  362. {
  363. // Handle default.
  364. if ($languageCode === '' || !$this->enabled()) {
  365. return [''];
  366. }
  367. $default = $this->getDefault() ?? 'en';
  368. $active = $languageCode ?? $this->getActive() ?? $default;
  369. $key = $active . '-' . (int)$includeDefault;
  370. if (!isset($this->fallback_languages[$key])) {
  371. $fallback = $this->config->get('system.languages.content_fallback.' . $active);
  372. $fallback_languages = [];
  373. if (null === $fallback && $this->config->get('system.languages.pages_fallback_only', false)) {
  374. user_error('Configuration option `system.languages.pages_fallback_only` is deprecated since Grav 1.7, use `system.languages.content_fallback` instead', E_USER_DEPRECATED);
  375. // Special fallback list returns itself and all the previous items in reverse order:
  376. // active: 'v2', languages: ['v1', 'v2', 'v3', 'v4'] => ['v2', 'v1', '']
  377. if ($includeDefault) {
  378. $fallback_languages[''] = '';
  379. }
  380. foreach ($this->languages as $code) {
  381. $fallback_languages[$code] = $code;
  382. if ($code === $active) {
  383. break;
  384. }
  385. }
  386. $fallback_languages = array_reverse($fallback_languages);
  387. } else {
  388. if (null === $fallback) {
  389. $fallback = [$default];
  390. } elseif (!is_array($fallback)) {
  391. $fallback = is_string($fallback) && $fallback !== '' ? explode(',', $fallback) : [];
  392. }
  393. array_unshift($fallback, $active);
  394. $fallback = array_unique($fallback);
  395. foreach ($fallback as $code) {
  396. // Default fallback list has active language followed by default language and extensionless file:
  397. // active: 'fi', default: 'en', languages: ['sv', 'en', 'de', 'fi'] => ['fi', 'en', '']
  398. $fallback_languages[$code] = $code;
  399. if ($includeDefault && $code === $default) {
  400. $fallback_languages[''] = '';
  401. }
  402. }
  403. }
  404. $fallback_languages = array_values($fallback_languages);
  405. $this->fallback_languages[$key] = $fallback_languages;
  406. }
  407. return $this->fallback_languages[$key];
  408. }
  409. /**
  410. * Ensures the language is valid and supported
  411. *
  412. * @param string $lang
  413. * @return bool
  414. */
  415. public function validate($lang)
  416. {
  417. return in_array($lang, $this->languages, true);
  418. }
  419. /**
  420. * Translate a key and possibly arguments into a string using current lang and fallbacks
  421. *
  422. * @param string|array $args The first argument is the lookup key value
  423. * Other arguments can be passed and replaced in the translation with sprintf syntax
  424. * @param array|null $languages
  425. * @param bool $array_support
  426. * @param bool $html_out
  427. * @return string|string[]
  428. */
  429. public function translate($args, array $languages = null, $array_support = false, $html_out = false)
  430. {
  431. if (is_array($args)) {
  432. $lookup = array_shift($args);
  433. } else {
  434. $lookup = $args;
  435. $args = [];
  436. }
  437. if (!$this->isDebug()) {
  438. if ($lookup && $this->enabled() && empty($languages)) {
  439. $languages = $this->getTranslatedLanguages();
  440. }
  441. $languages = $languages ?: ['en'];
  442. foreach ((array)$languages as $lang) {
  443. $translation = $this->getTranslation($lang, $lookup, $array_support);
  444. if ($translation) {
  445. if (is_string($translation) && count($args) >= 1) {
  446. return vsprintf($translation, $args);
  447. }
  448. return $translation;
  449. }
  450. }
  451. } elseif ($array_support) {
  452. return [$lookup];
  453. }
  454. if ($html_out) {
  455. return '<span class="untranslated">' . $lookup . '</span>';
  456. }
  457. return $lookup;
  458. }
  459. /**
  460. * Translate Array
  461. *
  462. * @param string $key
  463. * @param string $index
  464. * @param array|null $languages
  465. * @param bool $html_out
  466. * @return string
  467. */
  468. public function translateArray($key, $index, $languages = null, $html_out = false)
  469. {
  470. if ($this->isDebug()) {
  471. return $key . '[' . $index . ']';
  472. }
  473. if ($key && empty($languages) && $this->enabled()) {
  474. $languages = $this->getTranslatedLanguages();
  475. }
  476. $languages = $languages ?: ['en'];
  477. foreach ((array)$languages as $lang) {
  478. $translation_array = (array)Grav::instance()['languages']->get($lang . '.' . $key, null);
  479. if ($translation_array && array_key_exists($index, $translation_array)) {
  480. return $translation_array[$index];
  481. }
  482. }
  483. if ($html_out) {
  484. return '<span class="untranslated">' . $key . '[' . $index . ']</span>';
  485. }
  486. return $key . '[' . $index . ']';
  487. }
  488. /**
  489. * Lookup the translation text for a given lang and key
  490. *
  491. * @param string $lang lang code
  492. * @param string $key key to lookup with
  493. * @param bool $array_support
  494. * @return string|string[]
  495. */
  496. public function getTranslation($lang, $key, $array_support = false)
  497. {
  498. if ($this->isDebug()) {
  499. return $key;
  500. }
  501. $translation = Grav::instance()['languages']->get($lang . '.' . $key, null);
  502. if (!$array_support && is_array($translation)) {
  503. return (string)array_shift($translation);
  504. }
  505. return $translation;
  506. }
  507. /**
  508. * Get the browser accepted languages
  509. *
  510. * @param array $accept_langs
  511. * @return array
  512. * @deprecated 1.6 No longer used - using content negotiation.
  513. */
  514. public function getBrowserLanguages($accept_langs = [])
  515. {
  516. user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, no longer used', E_USER_DEPRECATED);
  517. if (empty($this->http_accept_language)) {
  518. if (empty($accept_langs) && isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
  519. $accept_langs = $_SERVER['HTTP_ACCEPT_LANGUAGE'];
  520. } else {
  521. return $accept_langs;
  522. }
  523. $langs = [];
  524. foreach (explode(',', $accept_langs) as $k => $pref) {
  525. // split $pref again by ';q='
  526. // and decorate the language entries by inverted position
  527. if (false !== ($i = strpos($pref, ';q='))) {
  528. $langs[substr($pref, 0, $i)] = [(float)substr($pref, $i + 3), -$k];
  529. } else {
  530. $langs[$pref] = [1, -$k];
  531. }
  532. }
  533. arsort($langs);
  534. // no need to undecorate, because we're only interested in the keys
  535. $this->http_accept_language = array_keys($langs);
  536. }
  537. return $this->http_accept_language;
  538. }
  539. /**
  540. * Accessible wrapper to LanguageCodes
  541. *
  542. * @param string $code
  543. * @param string $type
  544. * @return string|false
  545. */
  546. public function getLanguageCode($code, $type = 'name')
  547. {
  548. return LanguageCodes::get($code, $type);
  549. }
  550. /**
  551. * @return array
  552. */
  553. public function __debugInfo()
  554. {
  555. $vars = get_object_vars($this);
  556. unset($vars['grav'], $vars['config']);
  557. return $vars;
  558. }
  559. /**
  560. * @return array
  561. */
  562. protected function getTranslatedLanguages(): array
  563. {
  564. if ($this->config->get('system.languages.translations_fallback', true)) {
  565. $languages = $this->getFallbackLanguages();
  566. } else {
  567. $languages = [$this->getLanguage()];
  568. }
  569. $languages[] = 'en';
  570. return array_values(array_unique($languages));
  571. }
  572. }