diff --git a/application/Config/App.php b/application/Config/App.php index fc34b8e2ac44..a31d323b382f 100644 --- a/application/Config/App.php +++ b/application/Config/App.php @@ -65,6 +65,31 @@ class App extends BaseConfig */ public $defaultLocale = 'en'; + /* + |-------------------------------------------------------------------------- + | Negotiate Locale + |-------------------------------------------------------------------------- + | + | If true, the current Request object will automatically determine the + | language to use based on the value of the Accept-Language header. + | + | If false, no automatic detection will be performed. + | + */ + public $negotiateLocale = false; + + /* + |-------------------------------------------------------------------------- + | Supported Locales + |-------------------------------------------------------------------------- + | + | If $negotiateLocale is true, this array lists the locales supported + | by the application in descending order of priority. If no match is + | found, the first locale will be used. + | + */ + public $supportedLocales = ['en']; + /* |-------------------------------------------------------------------------- | URI PROTOCOL diff --git a/application/Config/Services.php b/application/Config/Services.php index 1231c25c48b7..775e0e87dfe4 100644 --- a/application/Config/Services.php +++ b/application/Config/Services.php @@ -206,6 +206,23 @@ public static function iterator($getShared = true) //-------------------------------------------------------------------- + /** + * Responsible for loading the language string translations. + */ + public static function language(string $locale = null, $getShared = true) + { + if ($getShared) + { + return self::getSharedInstance('language', $locale); + } + + $locale = ! empty($locale) ? $locale : self::request()->getLocale(); + + return new \CodeIgniter\Language\Language($locale); + } + + //-------------------------------------------------------------------- + /** * The file locator provides utility methods for looking for non-classes * within namespaced folders, as well as convenience methods for diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 37f81f87f259..c0bc6c20c79f 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -482,6 +482,13 @@ protected function tryToRouteIt(RouteCollectionInterface $routes = null) $this->controller = $this->router->handle($path); $this->method = $this->router->methodName(); + // If a {locale} segment was matched in the final route, + // then we need to set the correct locale on our Request. + if ($this->router->hasLocale()) + { + $this->request->setLocale($this->router->getLocale()); + } + $this->benchmark->stop('routing'); } diff --git a/system/Common.php b/system/Common.php index a54a7f820f15..7c34f282ade7 100644 --- a/system/Common.php +++ b/system/Common.php @@ -310,7 +310,24 @@ function shared_service(string $name, ...$params) //-------------------------------------------------------------------- +if (! function_exists('lang')) +{ + /** + * A convenience method to translate a string and format it + * with the intl extension's MessageFormatter object. + * + * @param string $line + * @param array $args + * + * @return string + */ + function lang(string $line, array $args=[]) + { + return Services::language()->getLine($line, $args); + } +} +//-------------------------------------------------------------------- diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index b627a1b2bffc..0a267df5432e 100644 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -97,6 +97,29 @@ class IncomingRequest extends Request */ protected $negotiate; + /** + * The default Locale this request + * should operate under. + * + * @var string + */ + protected $defaultLocale; + + /** + * The current locale of the application. + * Default value is set in Config\App.php + * + * @var string + */ + protected $locale; + + /** + * Stores the valid locale codes. + * + * @var array + */ + protected $validLocales = []; + //-------------------------------------------------------------------- /** @@ -123,6 +146,90 @@ public function __construct($config, $uri = null, $body = 'php://input') $this->uri = $uri; $this->detectURI($config->uriProtocol, $config->baseURL); + + $this->validLocales = $config->supportedLocales; + + $this->detectLocale($config); + } + + //-------------------------------------------------------------------- + + /** + * Handles setting up the locale, perhaps auto-detecting through + * content negotiation. + * + * @param $config + */ + public function detectLocale($config) + { + $this->locale = $this->defaultLocale = $config->defaultLocale; + + if (! $config->negotiateLocale) + { + return; + } + + $this->setLocale($this->negotiate('language', $config->supportedLocales)); + } + + //-------------------------------------------------------------------- + + /** + * Returns the default locale as set in Config\App.php + * + * @return string + */ + public function getDefaultLocale(): string + { + return $this->defaultLocale; + } + + //-------------------------------------------------------------------- + + /** + * Gets the current locale, with a fallback to the default + * locale if none is set. + * + * @return string + */ + public function getLocale(): string + { + return $this->locale ?? $this->defaultLocale; + } + + //-------------------------------------------------------------------- + + /** + * Sets the locale string for this request. + * + * @param string $locale + * + * @return $this + */ + public function setLocale(string $locale) + { + // If it's not a valid locale, set it + // to the default locale for the site. + if (! in_array($locale, $this->validLocales)) + { + $locale = $this->defaultLocale; + } + + $this->locale = $locale; + + // If the intl extension is loaded, make sure + // that we set the locale for it... if not, though, + // don't worry about it. + try { + if (class_exists('\Locale', false)) + { + \Locale::setDefault($locale); + } + } + catch (\Exception $e) + {} + + return $this; } //-------------------------------------------------------------------- diff --git a/system/HTTP/Request.php b/system/HTTP/Request.php index 147b123b76ad..7e93b494edac 100644 --- a/system/HTTP/Request.php +++ b/system/HTTP/Request.php @@ -57,22 +57,6 @@ class Request extends Message implements RequestInterface */ protected $proxyIPs; - /** - * The default Locale this request - * should operate under. - * - * @var string - */ - protected $defaultLocale; - - /** - * The current locale of the application. - * Default value is set in Config\App.php - * - * @var string - */ - protected $locale; - //-------------------------------------------------------------------- /** @@ -84,49 +68,6 @@ class Request extends Message implements RequestInterface public function __construct($config, $uri=null) { $this->proxyIPs = $config->proxyIPs; - - $this->locale = $this->defaultLocale = $config->defaultLocale; - } - - //-------------------------------------------------------------------- - - /** - * Returns the default locale as set in Config\App.php - * - * @return string - */ - public function getDefaultLocale(): string - { - return $this->defaultLocale; - } - - //-------------------------------------------------------------------- - - /** - * Gets the current locale, with a fallback to the default - * locale if none is set. - * - * @return string - */ - public function getLocale(): string - { - return $this->locale ?? $this->defaultLocale; - } - - //-------------------------------------------------------------------- - - /** - * Sets the locale string for this request. - * - * @param string $locale - * - * @return $this - */ - public function setLocale(string $locale) - { - $this->locale = $locale; - - return $this; } //-------------------------------------------------------------------- diff --git a/system/HTTP/RequestInterface.php b/system/HTTP/RequestInterface.php index 9b8ddfa14441..7c060411b3aa 100644 --- a/system/HTTP/RequestInterface.php +++ b/system/HTTP/RequestInterface.php @@ -84,34 +84,4 @@ public function getMethod($upper = false): string; public function getServer($index = null, $filter = null); //-------------------------------------------------------------------- - - /** - * Returns the default locale as set in Config\App.php - * - * @return string - */ - public function getDefaultLocale(): string; - - //-------------------------------------------------------------------- - - /** - * Gets the current locale, with a fallback to the default - * locale if none is set. - * - * @return string - */ - public function getLocale(): string; - - //-------------------------------------------------------------------- - - /** - * Sets the locale string for this request. - * - * @param string $locale - * - * @return $this - */ - public function setLocale(string $locale); - - //-------------------------------------------------------------------- } diff --git a/system/Language/Language.php b/system/Language/Language.php new file mode 100644 index 000000000000..63a604c0348c --- /dev/null +++ b/system/Language/Language.php @@ -0,0 +1,175 @@ +locale = $locale; + + if (class_exists('\MessageFormatter')) + { + $this->intlSupport = true; + }; + } + + //-------------------------------------------------------------------- + + /** + * Parses the language string for a file, loads the file, if necessary, + * getting + * + * @param string $line + * @param array $args + * + * @return string + */ + public function getLine(string $line, array $args = []): string + { + // Parse out the file name and the actual alias. + // Will load the language file and strings. + $line = $this->parseLine($line); + + $output = ! empty($this->language[$line]) ? $this->language[$line] : $line; + + // Do advanced message formatting here + // if the 'intl' extension is available. + if ($this->intlSupport && count($args)) + { + $output = \MessageFormatter::formatMessage($this->locale, $line, $args); + } + + return $output; + } + + //-------------------------------------------------------------------- + + /** + * Parses the language string which should include the + * filename as the first segment (separated by period). + * + * @param string $line + * + * @return string + */ + protected function parseLine(string $line): string + { + if (strpos($line, '.') === false) + { + throw new \InvalidArgumentException('No language file specified in line: '.$line); + } + + $file = substr($line, 0, strpos($line, '.')); + $line = substr($line, strlen($file)+1); + + if (! array_key_exists($line, $this->language)) + { + $this->load($file, $this->locale); + } + + return $this->language[$line]; + } + + //-------------------------------------------------------------------- + + /** + * Loads a language file in the current locale. If $return is true, + * will return the file's contents, otherwise will merge with + * the existing language lines. + * + * @param string $file + * @param string $locale + * @param bool $return + * + * @return array|null + */ + protected function load(string $file, string $locale, bool $return = false) + { + if (in_array($file, $this->loadedFiles)) + { + return []; + } + + $lang = []; + + $path = "Language/{$locale}/{$file}.php"; + + $lang = $this->requireFile($path); + + // Don't load it more than once. + $this->loadedFiles[] = $file; + + if ($return) + { + return $lang; + } + + // Merge our string + $this->language = array_merge($this->language, $lang); + } + + //-------------------------------------------------------------------- + + /** + * A simple method for including files that can be + * overridden during testing. + * + * @todo - should look into loading from other locations, also probably... + * + * @param string $path + * + * @return array + */ + protected function requireFile(string $path): array + { + foreach ([APPPATH, BASEPATH] as $folder) + { + if (! is_file($folder.$path)) + { + continue; + } + + return require_once $folder.$path; + } + + return []; + } + + //-------------------------------------------------------------------- + +} diff --git a/system/Router/Router.php b/system/Router/Router.php index 5472fd088eb5..10c23d24a107 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -106,6 +106,12 @@ class Router implements RouterInterface */ protected $matchedRoute = null; + /** + * The locale that was detected in a route. + * @var string + */ + protected $detectedLocale = null; + //-------------------------------------------------------------------- /** @@ -298,6 +304,31 @@ public function setTranslateURIDashes($val = false): self //-------------------------------------------------------------------- + /** + * Returns true/false based on whether the current route contained + * a {locale} placeholder. + * + * @return bool + */ + public function hasLocale() + { + return (bool)$this->detectedLocale; + } + + //-------------------------------------------------------------------- + + /** + * Returns the detected locale, if any, or null. + * + * @return string + */ + public function getLocale() + { + return $this->detectedLocale; + } + + //-------------------------------------------------------------------- + /** * Compares the uri string against the routes that the * RouteCollection class defined for us, attempting to find a match. @@ -306,6 +337,7 @@ public function setTranslateURIDashes($val = false): self * @param string $uri The URI path to compare against the routes * * @return bool Whether the route was matched or not. + * @throws \CodeIgniter\Router\RedirectException */ protected function checkRoutes(string $uri): bool { @@ -320,9 +352,27 @@ protected function checkRoutes(string $uri): bool // Loop through the route array looking for wildcards foreach ($routes as $key => $val) { + // Are we dealing with a locale? + if (strpos($key, '{locale}') !== false) + { + $localeSegment = array_search('{locale}', explode('/', $key)); + + // Replace it with a regex so it + // will actually match. + $key = str_replace('{locale}', '[^/]+', $key); + } + // Does the RegEx match? if (preg_match('#^'.$key.'$#', $uri, $matches)) { + // Store our locale so CodeIgniter object can + // assign it to the Request. + if (isset($localeSegment)) + { + $this->detectedLocale = (explode('/', $uri))[$localeSegment]; + unset($localeSegment); + } + // Are we using Closures? If so, then we need // to collect the params into an array // so it can be passed to the controller method later. @@ -405,7 +455,7 @@ public function autoRoute(string $uri) $file = APPPATH.'Controllers/'.$this->directory.$this->controller.'.php'; if (file_exists($file)) { - include $file; + include_once $file; } // Ensure the controller stores the fully-qualified class name diff --git a/tests/_support/Config/MockAppConfig.php b/tests/_support/Config/MockAppConfig.php index f9bf06b3bbe8..96d4d81f6d0e 100644 --- a/tests/_support/Config/MockAppConfig.php +++ b/tests/_support/Config/MockAppConfig.php @@ -6,10 +6,10 @@ class MockAppConfig public $uriProtocol = 'REQUEST_URI'; - public $cookiePrefix = ''; - public $cookieDomain = ''; - public $cookiePath = '/'; - public $cookieSecure = false; + public $cookiePrefix = ''; + public $cookieDomain = ''; + public $cookiePath = '/'; + public $cookieSecure = false; public $cookieHTTPOnly = false; public $proxyIPs = ''; @@ -23,5 +23,7 @@ class MockAppConfig public $CSPEnabled = false; - public $defaultLocale = 'en'; + public $defaultLocale = 'en'; + public $negotiateLocale = false; + public $supportedLocales = ['en', 'es']; } diff --git a/tests/_support/Language/MockLanguage.php b/tests/_support/Language/MockLanguage.php new file mode 100644 index 000000000000..dae3c4ebd655 --- /dev/null +++ b/tests/_support/Language/MockLanguage.php @@ -0,0 +1,48 @@ +data = $data; + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Provides an override that allows us to set custom + * data to be returned easily during testing. + * + * @param string $path + * + * @return array|mixed + */ + protected function requireFile(string $path): array + { + return $this->data ?? []; + } + + //-------------------------------------------------------------------- + +} diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index 138ba07139ca..b618f4f7d729 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -187,4 +187,39 @@ public function testFetchGlobalFiltersSelectedValues() } //-------------------------------------------------------------------- + + public function testStoresDefaultLocale() + { + $config = new App(); + + $this->assertEquals($config->defaultLocale, $this->request->getDefaultLocale()); + $this->assertEquals($config->defaultLocale, $this->request->getLocale()); + } + + //-------------------------------------------------------------------- + + public function testSetLocaleSaves() + { + $this->request->setLocale('en'); + + $this->assertEquals('en', $this->request->getLocale()); + } + + //-------------------------------------------------------------------- + + public function testNegotiatesLocale() + { + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'es; q=1.0, en; q=0.5'; + + $config = new App(); + $config->negotiateLocale = true; + $config->supportedLocales = ['en', 'es']; + + $request = new IncomingRequest($config, new URI()); + + $this->assertEquals($config->defaultLocale, $request->getDefaultLocale()); + $this->assertEquals('es', $request->getLocale()); + } + + //-------------------------------------------------------------------- } diff --git a/tests/system/HTTP/RequestTest.php b/tests/system/HTTP/RequestTest.php index bc5ee88187e1..05a9a0e6e18a 100644 --- a/tests/system/HTTP/RequestTest.php +++ b/tests/system/HTTP/RequestTest.php @@ -55,24 +55,4 @@ public function testMethodReturnsRightStuff() //-------------------------------------------------------------------- - public function testStoresDefaultLocale() - { - $config = new App(); - - $this->assertEquals($config->defaultLocale, $this->request->getDefaultLocale()); - $this->assertEquals($config->defaultLocale, $this->request->getLocale()); - } - - //-------------------------------------------------------------------- - - public function testSetLocaleSaves() - { - $this->request->setLocale('en'); - - $this->assertEquals('en', $this->request->getLocale()); - } - - //-------------------------------------------------------------------- - - } diff --git a/tests/system/Language/LanguageTest.php b/tests/system/Language/LanguageTest.php new file mode 100644 index 000000000000..0c06abb4c46d --- /dev/null +++ b/tests/system/Language/LanguageTest.php @@ -0,0 +1,46 @@ +setExpectedException('\InvalidArgumentException'); + + $lang->getLine('something'); + } + + //-------------------------------------------------------------------- + + public function testGetLineReturnsLine() + { + $lang = new MockLanguage('en'); + + $lang->setData([ + 'bookSaved' => 'We kept the book free from the boogeyman', + 'booksSaved' => 'We saved some more' + ]); + + $this->assertEquals('We saved some more', $lang->getLine('books.booksSaved')); + } + + //-------------------------------------------------------------------- + + public function testGetLineFormatsMessage() + { + // No intl extension? then we can't test this - go away.... + if (! class_exists('\MessageFormatter')) return; + + $lang = new MockLanguage('en'); + + $lang->setData([ + 'books' => '{0, number, integer} books have been saved.' + ]); + + $this->assertEquals('45 books have been saved.', $lang->getLine('books.books', [91/2])); + } + + //-------------------------------------------------------------------- + +} diff --git a/tests/system/Router/RouterTest.php b/tests/system/Router/RouterTest.php index 48ec17e4e3c2..a1a8078b2ea7 100644 --- a/tests/system/Router/RouterTest.php +++ b/tests/system/Router/RouterTest.php @@ -26,6 +26,7 @@ public function setUp() 'posts/(:num)/edit' => 'Blog::edit/$1', 'books/(:num)/(:alpha)/(:num)' => 'Blog::show/$3/$1', 'closure/(:num)/(:alpha)' => function ($num, $str) { return $num.'-'.$str; }, + '{locale}/pages' => 'App\Pages::list_all', ]; $this->collection->map($routes); @@ -178,4 +179,20 @@ public function testAutoRouteFindsControllerWithSubfolder() } //-------------------------------------------------------------------- + + /** + * @group single + */ + public function testDetectsLocales() + { + $router = new Router($this->collection); + + $router->handle('fr/pages'); + + $this->assertTrue($router->hasLocale()); + $this->assertEquals('fr', $router->getLocale()); + } + + //-------------------------------------------------------------------- + } diff --git a/user_guide_src/source/general/common_functions.rst b/user_guide_src/source/general/common_functions.rst index 39b1606db1e2..1f28504fc2a8 100644 --- a/user_guide_src/source/general/common_functions.rst +++ b/user_guide_src/source/general/common_functions.rst @@ -54,6 +54,15 @@ Service Accessors For full details, see the :doc:`helpers` page. +.. php:function:: lang(string $line[, array $args]): string + + :param string $line: The line of text to retrieve + :param array $args: An array of data to substitute for placeholders. + + Retrieves a locale-specific file based on an alias string. + + For more information, see the :doc:`Localization ` page. + .. php:function:: session( [$key] ) :param string $key: The name of the session item to check for. @@ -72,7 +81,7 @@ Service Accessors A convenience method that provides quick access to the Timer class. You can pass in the name of a benchmark point as the only parameter. This will start timing from this point, or stop timing if a timer with this name is already running. - + Example:: // Get an instance diff --git a/user_guide_src/source/general/routing.rst b/user_guide_src/source/general/routing.rst index f1ff7be6aef9..89f5d3a94dcc 100644 --- a/user_guide_src/source/general/routing.rst +++ b/user_guide_src/source/general/routing.rst @@ -18,7 +18,7 @@ For example, let’s say you want your URLs to have this prototype:: example.com/product/2/ example.com/product/3/ example.com/product/4/ - + Normally the second segment of the URL is reserved for the method name, but in the example above it instead has a product ID. To overcome this, CodeIgniter allows you to remap the URI handler. @@ -46,7 +46,7 @@ Placeholders A typical route might look something like this:: $routes->add('product/:num', 'App\Catalog::productLookup'); - + In a route, the first parameter contains the URI to be matched, while the second parameter contains the destination it should be re-routed to. In the above example, if the literal word "product" is found in the first segment of the URL, and a number is found in the second segment, @@ -56,15 +56,18 @@ Placeholders are simply strings that represent a Regular Expression pattern. Dur process, these placeholders are replaced with the value of the Regular Expression. They are primarily used for readability. -The following placeholders are available for you to use in your routes: +The following placeholders are available for you to use in your routes: -* **(:any)** will match all characters from that point to the end of the URI. This may include multiple URI segments. +* **(:any)** will match all characters from that point to the end of the URI. This may include multiple URI segments. * **(:segment)** will match any character except for a forward slash (/) restricting the result to a single segment. * **(:num)** will match any integer. * **(:alpha)** will match any string of alphabetic characters * **(:alphanum)** will match any string of alphabetic characters or integers, or any combination of the two. * **(:hash)** is the same as **:segment**, but can be used to easily see which routes use hashed ids (see the :doc:`Model ` docs). +.. note:: **{locale}** cannot be used as a placeholder or other part of the route, as it is reserved for use + in :doc:`localization `. + Examples ======== @@ -81,12 +84,12 @@ A URL containing the segments "blog/joe" will be remapped to the “\Blogs” cl The ID will be set to “34”:: $routes->add('product/(:any)', 'Catalog::productLookup'); - + A URL with “product” as the first segment, and anything in the second will be remapped to the “\Catalog” class and the “productLookup” method:: $routes->add('product/(:num)', 'Catalog::productLookupByID/$1'; - + A URL with “product” as the first segment, and a number in the second will be remapped to the “\Catalog” class and the “productLookupByID” method passing in the match as a variable to the method. @@ -130,7 +133,7 @@ For example, if a user accesses a password protected area of your web applicatio redirect them back to the same page after they log in, you may find this example useful:: $routes->add('login/(.+)', 'Auth::login/$1'); - + For those of you who don’t know regular expressions and want to learn more about them, `regular-expressions.info `_ might be a good starting point. @@ -161,7 +164,7 @@ define an array of routes and then pass it as the first parameter to the `map()` $routes = []; $routes['product/(:num)'] = 'Catalog::productLookupById'; $routes['product/(:alphanum)'] = 'Catalog::productLookupByName'; - + $collection->map($routes); @@ -196,7 +199,7 @@ extensive set of routes that all share the opening string, like when building an $routes->add('users', 'Admin\Users::index'); $routes->add('blog', 'Admin\Blog::index'); }); - + This would prefix the 'users' and 'blog" URIs with "admin", handling URLs like ``/admin/users`` and ``/admin/blog``. It is possible to nest groups within groups for finer organization if you need it:: diff --git a/user_guide_src/source/intro/requirements.rst b/user_guide_src/source/intro/requirements.rst index ef27423e67bc..27270babd806 100644 --- a/user_guide_src/source/intro/requirements.rst +++ b/user_guide_src/source/intro/requirements.rst @@ -4,13 +4,16 @@ Server Requirements `PHP `_ version 7.0 or newer is required. +While not required, the *intl* extension is recommended, and some portions of the +framework will provided enhanced functionality when it's present. + A database is required for most web application programming. Currently supported databases are: - MySQL (5.1+) via the *MySQLi* driver - PostgreSQL via the *Postgre* driver -Not all of the drivers have been converted/rewritten for CodeIgniter4. +Not all of the drivers have been converted/rewritten for CodeIgniter4. The list below shows the outstanding ones. - MySQL (5.1+) via the *pdo* driver diff --git a/user_guide_src/source/libraries/index.rst b/user_guide_src/source/libraries/index.rst index 78af41551b5d..3d22070b09a2 100644 --- a/user_guide_src/source/libraries/index.rst +++ b/user_guide_src/source/libraries/index.rst @@ -9,6 +9,7 @@ Library Reference caching cli content_negotiation + localization curlrequest incomingrequest message diff --git a/user_guide_src/source/libraries/localization.rst b/user_guide_src/source/libraries/localization.rst new file mode 100644 index 000000000000..d0152267270e --- /dev/null +++ b/user_guide_src/source/libraries/localization.rst @@ -0,0 +1,228 @@ +############ +Localization +############ + +.. contents:: + :local: + +******************** +Working With Locales +******************** + +CodeIgniter provides several tools to help you localize your application for different languages. While full +localization of an application is a complex subject, it's simple to swap out strings in your application +with different supported languages. + +Language strings are stored in the **application/Language** directory, with a sub-directory for each +supported language:: + + /application + /Language + /en + app.php + /fr + app.php + +.. important:: Locale detection only works for web-based requests that use the IncomingRequest class. + Command-line requests will not have these features. + +Configuring the Locale +====================== + +Every site will have a default language/locale they operate in. This can be set in **Config/App.php**:: + + public $defaultLocale = 'en'; + +The value can be any string that your application uses to manage text strings and other formats. It is +recommended that a [BCP 47](http://www.rfc-editor.org/rfc/bcp/bcp47.txt) language code is used. This results in +language codes like en-US for American English, or fr-FR, for French/France. A more readable introduction +to this can be found on the [W3C's site](https://www.w3.org/International/articles/language-tags/). + +The system is smart enough to fallback to more generic language codes if an exact match +cannot be found. If the locale code was set to **en_US** and we only have language files setup for **en** +then those will be used since nothing exists for the more specific **en_US**. If, however, a language +directory existed at **application/Language/en_US** then that we be used first. + +Locale Detection +================ + +There are two methods supported to detect the correct locale during the request. The first is a "set and forget" +method that will automatically perform :doc:`content negotiation ` for you to +determine the correct locale to use. The second method allows you to specify a segment in your routes that +will be used to set the locale. + +Content Negotiation +------------------- + +You can setup content negotiation to happen automatically by setting two additional settings in Config/App. +The first value tells the Request class that we do want to negotiate a locale, so simply set it to true:: + + public $negotiateLocale = true; + +Once this is enabled, the system will automatically negotiate the correct language based upon an array +of locales that you have defined in ``$supportLocales``. If no match is found between the languages +that you support, and the requested language, the first item in $supportedLocales will be used. In +the following example, the **en** locale would be used if no match is found:: + + public $supportedLocales = ['en', 'es', 'fr_FR']; + +In Routes +--------- + +The second method uses a custom placeholder to detect the desired locale and set it on the Request. The +placeholder ``{locale}`` can be placed as a segment in your route. If present, the contents of the matching +segment will be your locale:: + + $routes->get('{locale}/books', 'App\Books::index'); + +In this example, if the user tried to visit ``http://example.com/fr/books``, then the locale would be +set to ``fr``, assuming it was configured as a valid locale. + +.. note:: If the value doesn't match a valid locale as defined in the App configuration file, the default + locale will be used in it's place. + +Retrieving the Current Locale +============================= + +The current locale can always be retrieved from the IncomingRequest object, through the ``getLocale()` method. +If your controller is extending ``CodeIgniter\Controller``, this will be available through ``$this->request``:: + + namespace App\Controllers; + + class UserController extends \CodeIgniter\Controller + { + public function index() + { + $locale = $this->request->getLocale(); + } + } + +Alternatively, you can use the :doc:`Services class ` to retrieve the current request:: + + $locale = service('request')->getLocale(); + +********************* +Language Localization +********************* + +Creating Language Files +======================= + +Language do not have any specific naming convention that are required. The file should be named logically to +describe the type of content it holds. For example, let's say you want to create a file containing error messages. +You might name it simply: **Errors.php**. + +Within the file you would return an array, where each element in the array has a language key and the string to return:: + + 'language_key' => 'The actual message to be shown.' + +.. note:: It's good practice to use a common prefix for all messages in a given file to avoid collisions with + similarly named items in other files. For example, if you are creating error messages you might prefix them + with error\_ + +:: + + return [ + 'errorEmailMissing' => 'You must submit an email address', + 'errorURLMissing' => 'You must submit a URL', + 'errorUsernameMissing' => 'You must submit a username', + ]; + +Basic Usage +=========== + +You can use the ``lang()`` helper function to retrieve text from any of the language files, by passing the +filename and the language key as the first paremeter, separated by a period (.). For example, to load the +``errorEmailMissing`` string from the ``Errors`` language file, you would do the following:: + + echo lang('Errors.errorEmailMissing'); + +If the requested language key doesn't exist in the file for the current locale, the string will be passed +back, unchanged. In this example, it would return 'Errors.errorEmailMissing' if it didn't exist. + +Replacing Parameters +-------------------- + +.. note:: The following functions all require the `intl `_ extension to + be loaded on your system in order to work. If the extension is not loaded, no replacement will be attempted. + A great overview can be found over at `Sitepoint `_. + +You can pass an array of values to replace placeholders in the language string as the second parameter to the +``lang()`` function. This allows for very simple number translations and formatting:: + + // The language file, Tests.php: + return [ + "apples" => "I have {0, number} apples.", + "men" => "I have {1, number} men out-performed the remaining {0, number}", + "namedApples" => "I have {number_apples, number, integer} apples.", + ]; + + // Displays "I have 3 apples." + echo lang('Tests.apples', [ 3 ]); + +The first item in the placeholder corresponds to the index of the item in the array, if it's numerical:: + + // Displays "The top 23 men out-performed the remaining 20" + echo lang('Tests.men', [20, 23]); + +You can also use named keys to make it easier to keep things straight, if you'd like:: + + // Displays "I have 3 apples." + echo lang("Tests.namedApples", ['number_apples' => 3]); + +Obviously, you can do more than just number replacement. According to the +`official ICU docs `_ for the underlying +library, the following types of data can be replaced: + +* numbers - integer, currency, percent +* dates - short, medium, long, full +* time - short, medium, long, full +* spellout - spells out numbers (i.e. 34 becomes thirty-four) +* ordinal +* duration + +Here are a few examples:: + + // The language file, Tests.php + return [ + 'shortTime' => 'The time is now {0, time, short}.', + 'mediumTime' => 'The time is now {0, time, medium}.', + 'longTime' => 'The time is now {0, time, long}.', + 'fullTime' => 'The time is now {0, time, full}.', + 'shortDate' => 'The date is now {0, date, short}.', + 'mediumDate' => 'The date is now {0, date, medium}.', + 'longDate' => 'The date is now {0, date, long}.', + 'fullDate' => 'The date is now {0, date, full}.', + 'spelledOut' => '34 is {0, spellout}', + 'ordinal' => 'The ordinal is {0, ordinal}', + 'duration' => 'It has been {0, duration}', + ]; + + // Displays "The time is now 11:18 PM" + echo lang('Tests.shortTime', [time()]); + // Displays "The time is now 11:18:50 PM" + echo lang('Tests.mediumTime', [time()]); + // Displays "The time is now 11:19:09 PM CDT" + echo lang('Tests.longTime', [time()]); + // Displays "The time is now 11:19:26 PM Central Daylight Time" + echo lang('Tests.fullTime', [time()]); + + // Displays "The date is now 8/14/16" + echo lang('Tests.shortDate', [time()]); + // Displays "The date is now Aug 14, 2016" + echo lang('Tests.mediumDate', [time()]); + // Displays "The date is now August 14, 2016" + echo lang('Tests.longDate', [time()]); + // Displays "The date is now Sunday, August 14, 2016" + echo lang('Tests.fullDate', [time()]); + + // Displays "34 is thirty-four" + echo lang('Tests.spelledOut', [34]); + + // Displays "It has been 408,676:24:35" + echo lang('Tests.ordinal', [time()]); + +You should be sure to read up on the MessageFormatter class and the underlying ICU formatting to get a better +idea on what capabilities it has, like permorming conditional replacement, pluralization, and more. Both of the links provided +earlier will give you an excellent idea as to the options available. +