diff --git a/Dockerfile b/Dockerfile index 71f0b396..1a69fbff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,5 +23,21 @@ COPY resources/keyman-site.conf /etc/apache2/conf-available/ RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini RUN chown -R www-data:www-data /var/www/html/ +# Because the base Docker image doesn't include locales, install these to generate locale files. +# gettext needed to compile .po files to .mo with msgfmt +RUN apt-get update && apt-get install -y \ + locales \ + gettext + +# Install PHP-extension gettext for localization at runtime +RUN docker-php-ext-install gettext +RUN docker-php-ext-enable gettext + +# Only enable en_US locale in /etc/locale.gen +# PHP will use textdomain() to specify "localization" .mo files +RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && dpkg-reconfigure --frontend=noninteractive locales \ + && update-locale + COPY --from=composer-builder /composer/vendor /var/www/vendor RUN a2enmod rewrite headers; a2enconf keyman-site diff --git a/_includes/includes/template.php b/_includes/includes/template.php index 57e87a81..27dd1361 100644 --- a/_includes/includes/template.php +++ b/_includes/includes/template.php @@ -3,6 +3,7 @@ // *Don't* use autoloader here because of potential side-effects in older pages require_once(__DIR__ . '/../2020/Util.php'); + require_once(__DIR__ . '/../locale/Locale.php'); require_once(__DIR__ . '/../../_common/KeymanVersion.php'); require_once(__DIR__ . '/../2020/templates/Head.php'); diff --git a/_includes/includes/ui/keyboard-details.php b/_includes/includes/ui/keyboard-details.php index cc4e91b6..e12a2fda 100644 --- a/_includes/includes/ui/keyboard-details.php +++ b/_includes/includes/ui/keyboard-details.php @@ -4,6 +4,7 @@ require_once('includes/template.php'); require_once('includes/playstore.php'); require_once('includes/appstore.php'); + require_once __DIR__ . '/../../../keyboards/session.php'; use \DateTime; use \Keyman\Site\com\keyman\KeymanWebHost; @@ -203,7 +204,7 @@ protected static function LoadData() { self::$minVersion = isset(self::$keyboard->minKeymanVersion) ? self::$keyboard->minKeymanVersion : $stable_version; self::$license = self::map_license(isset(self::$keyboard->license) ? self::$keyboard->license : 'Unknown'); } else { - self::$error .= "Error returned from ".KeymanHosts::Instance()->api_keyman_com.": $s\n"; + self::$error .= Locale::_s('Error returned from %1$s: %2$s\n', $KeymanHosts::Instance()->api_keyman_com, $s); self::$title = 'Failed to load keyboard package ' . self::$id; header('HTTP/1.0 500 Internal Server Error'); } @@ -608,7 +609,9 @@ protected static function WriteKeyboardDetails() { $s = @file_get_contents(KeymanHosts::Instance()->SERVER_api_keyman_com.'/keyboard/' . rawurlencode($name)); if ($s === FALSE) { echo "<span class='keyboard-unavailable' title='This keyboard is not available on ". - KeymanHosts::Instance()->keyman_com_host."'>$hname</span> "; + Locale::_s('This keyboard is not available on %1$s', + KeymanHosts::Instance()->keyman_com_host) . + "'>$hname</span> "; } else { echo "<a href='/keyboards/$hname$session_query_q'>$hname</a> "; } diff --git a/_includes/locale/Locale.php b/_includes/locale/Locale.php new file mode 100644 index 00000000..eaf6cade --- /dev/null +++ b/_includes/locale/Locale.php @@ -0,0 +1,98 @@ +<?php + declare(strict_types=1); + + namespace Keyman\Site\com\keyman; + + class Locale { + public const DEFAULT_LOCALE = 'en'; + + public const CROWDIN_LOCALES = array( + 'en', + 'es-ES', + 'fr-FR' + ); + + // xx-YY locale as specified in crowdin %locale% + private static $currentLocale = Locale::DEFAULT_LOCALE; + + /** + * Return the current locale. Fallback to 'en' + * @return $currentLocale + */ + public static function currentLocale() { + return Locale::$currentLocale; + } + + /** + * Validate and override the current locale + * @param $locale - the new current locale (xx-YY as specified in crowdin %locale%) + */ + public static function overrideCurrentLocale($locale) { + if (Locale::validateLocale($locale)) { + Locale::$currentLocale = $locale; + } + } + + /** + * Validate $locale is an acceptable locale. + * Using xx-YY as specified in crowdin %locale% + * @param $locale - the locale to validate + * @return true if valid locale + */ + public static function validateLocale($locale) { + return in_array($locale, Locale::CROWDIN_LOCALES); + } + + /** + * Use textdomain to specify the localization file for "localization". + * Ignore if locale is "en" or the filename doesn't exist + * Filename expected to be "$basename-$locale.mo" + * @param $basename - base name of the .mo file to use + * @return current message domain + */ + public static function setTextDomain($basename) { + // Container uses English locale, and then we use textdomain to change "localization" files + setLocale(LC_ALL, 'en_US.UTF-8'); + + if (Locale::$currentLocale == Locale::DEFAULT_LOCALE) { + return; + } + + $filename = sprintf("%s-%s", $basename, Locale::$currentLocale); + $fullPath = sprintf("%s/en/LC_MESSAGES/%s.mo", __DIR__, $filename); + if(file_exists($fullPath)) { + return textdomain($filename); + } else { + //echo "textdomain $fullPath doesn't exist"; + return; + } + } + + /** + * Reads localized strings from the specified $domain-locale.po file + * for the current locale. + * @param $domain - base filename of the .po files (not including -xx-YY locale) + */ + public static function localize($domain) { + foreach(Locale::CROWDIN_LOCALES as $l) { + if ($l == Locale::DEFAULT_LOCALE) { + // Skip English + continue; + } + + bindtextdomain("$domain-$l", __DIR__); + } + + $previousTextDomain = textdomain(NULL); + Locale::setTextDomain($domain); + } + + /** + * Wrapper to format string with gettext '_(' alias and variable args + * @param $s - the format string + * @param $args - optional remaining args to the format string + */ + public static function _s($s, ...$args) { + return vsprintf(_($s), $args); + } + } diff --git a/_includes/locale/README.md b/_includes/locale/README.md new file mode 100644 index 00000000..8fabb137 --- /dev/null +++ b/_includes/locale/README.md @@ -0,0 +1,45 @@ +### Setup for Localization + +[init-container.sh](../../resources/init-container.sh) contains steps for the Docker container to compile .po files to .mo files which PHP uses for `gettext()`. + +If you want to compile the files on your host machine, install `gettext`. + +```bash +sudo apt-get install gettext +``` + +### Adding locales + +The Docker image has the "en_US.UTF-8" locale enabled in `/etc/locale.gen` +We'll use `textdomain` to specify filenames for "switching" localization. +The filenames will include the `%locale%` as defined in the [crowdin.com project](https://crowdin.com/project/keymancom). + +Note: the details below will get refactored to use a Locale.php class + +In the example below, the English file `keyboards-en.po` is copied to `keyboards-fr-FR.po` for French. + +1. In `/_includes/locale/en/LC_MESSAGES/` + * Copy `keyboards-en.po` file and rename to the `keyboards-fr-FR.po`. + * Translate/upload the new .po file to crowdin + * Convert .po file to .mo with the following + +```bash +msgfmt keyboards-fr-FR.po --output-file=keyboards-fr-FR.mo +``` + +(The container handles the msgfmt step in init-container.sh) + +2. Add the file to the PHP (path is relative the PHP file) + +```php +bindtextdomain("keyboards-fr-FR", "../_includes/locale"); +``` + +3. To use French, +```php +textdomain('keyboards-fr-FR'); +``` + +---- + +For formatted string, use the PHP wrapper [`Locale::_s(msgstr, $args)`](./Locale.php). diff --git a/_includes/locale/en/LC_MESSAGES/.gitignore b/_includes/locale/en/LC_MESSAGES/.gitignore new file mode 100644 index 00000000..e1b0b2c1 --- /dev/null +++ b/_includes/locale/en/LC_MESSAGES/.gitignore @@ -0,0 +1,2 @@ +# Ignore generated files from msgfmt +*.mo diff --git a/_includes/locale/en/LC_MESSAGES/keyboards-en.po b/_includes/locale/en/LC_MESSAGES/keyboards-en.po new file mode 100644 index 00000000..d2465b03 --- /dev/null +++ b/_includes/locale/en/LC_MESSAGES/keyboards-en.po @@ -0,0 +1,85 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" + +# Default English strings for keyboards/index.php + +# Page Title +msgid "Keyboard Search" +msgstr "Keyboard Search" + +# Page Description +msgid "Keyman Keyboard Search" +msgstr "Keyman Keyboard Search" + +# Keyboard search bar +msgid "Keyboard search:" +msgstr "Keyboard search:" + +# Search bar placeholder +msgid "Enter language or keyboard" +msgstr "Enter language or keyboard" + +# Search Button Value +msgid "Search" +msgstr "Search" + +# Link to start a new keyboard search +msgid "New search" +msgstr "New search" + +# Search box instruction (Popular keyboards | All keyboards) +msgid "Enter the name of a keyboard or language to search for" +msgstr "Enter the name of a keyboard or language to search for" + +# Search box link for popular keyboards +msgid "Popular keyboards" +msgstr "Popular keyboards" + +# Search box link for all Keyman keyboards +msgid "All keyboards" +msgstr "All keyboards" + +# Search box hint: List header +msgid "Hints" +msgstr "Hints" + +# Search box hint: Description +msgid "The search always returns a list of keyboards. It searches for keyboard names and details, language names, country names and script names." +msgstr "The search always returns a list of keyboards. It searches for keyboard names and details, language names, country names and script names." + +# Search box hint: available prefixes to use in the search +msgid "You can apply prefixes" +msgstr "You can apply prefixes" + +# (keyboards) +msgid "(keyboards)" +msgstr "(keyboards)" + +# (languages) +msgid "(languages)" +msgstr "(languages)" + +# (scripts, writing systems) or... +msgid "(scripts, writing systems) or" +msgstr "(scripts, writing systems) or" + +# (countries) to filter your search results... +msgid "(countries) to filter your search results. For example" +msgstr "(countries) to filter your search results. For example" + +# Search box hint: example of country search +msgid "searches for keyboards for languages used in Thailand." +msgstr "searches for keyboards for languages used in Thailand." + +# Search box hint: BCP 47 prefix +msgid "Use prefix" +msgstr "Use prefix" + +# Seach box hint: BCP 47 language example +msgid "to search for a BCP 47 language tag, for example" +msgstr "to search for a BCP 47 language tag, for example" + +# Search box hint: BCP 47 language example +msgid "searches for Tigrigna (Ethiopia)" +msgstr "searches for Tigrigna (Ethiopia)" diff --git a/_includes/locale/en/LC_MESSAGES/keyboards-es-ES.po b/_includes/locale/en/LC_MESSAGES/keyboards-es-ES.po new file mode 100644 index 00000000..ef6416a2 --- /dev/null +++ b/_includes/locale/en/LC_MESSAGES/keyboards-es-ES.po @@ -0,0 +1,94 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: keymancom\n" +"X-Crowdin-Project-ID: 740839\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: /master/keyboards/keyboards.po\n" +"X-Crowdin-File-ID: 2\n" +"Project-Id-Version: keymancom\n" +"Language-Team: Spanish\n" +"Language: es_ES\n" +"PO-Revision-Date: 2024-11-11 08:20\n" + +# Page Title +msgid "Keyboard Search" +msgstr "Búsqueda por Teclado" + +# Page Description +msgid "Keyman Keyboard Search" +msgstr "Keyman Búsqueda por Teclado" + +# Keyboard search bar +msgid "Keyboard search:" +msgstr "Búsqueda por teclado:" + +# Search bar placeholder +msgid "Enter language or keyboard" +msgstr "Ingresar idioma o teclado" + +# Search Button Value +msgid "Search" +msgstr "Buscar" + +# Link to start a new keyboard search +msgid "New search" +msgstr "Nueva buscar" + +# Search box instruction (Popular keyboards | All keyboards) +msgid "Enter the name of a keyboard or language to search for" +msgstr "Introduzca el nombre de un teclado o idioma para buscar" + +# Search box link for popular keyboards +msgid "Popular keyboards" +msgstr "Teclados populares" + +# Search box link for all Keyman keyboards +msgid "All keyboards" +msgstr "Todos los teclados" + +# Search box hint: List header +msgid "Hints" +msgstr "Consejos" + +# Search box hint: Description +msgid "The search always returns a list of keyboards. It searches for keyboard names and details, language names, country names and script names." +msgstr "La búsqueda siempre devuelve una lista de teclados. Busca nombres de teclados y detalles, nombres de idiomas, nombres de países y nombres de alfabetos." + +# Search box hint: available prefixes to use in the search +msgid "You can apply prefixes" +msgstr "Puedes aplicar prefijos" + +# (keyboards) +msgid "(keyboards)" +msgstr "(tescados)" + +# (languages) +msgid "(languages)" +msgstr "(idiomas)" + +# (scripts, writing systems) or... +msgid "(scripts, writing systems) or" +msgstr "(guiones, sistemas de escritura) o" + +# (countries) to filter your search results... +msgid "(countries) to filter your search results. For example" +msgstr "(países) para filtrar los resultados de búsqueda. Por ejemplo" + +# Search box hint: example of country search +msgid "searches for keyboards for languages used in Thailand." +msgstr "busca teclados para los idiomas utilizados en Tailandia." + +# Search box hint: BCP 47 prefix +msgid "Use prefix" +msgstr "Utilice prefijo" + +# Seach box hint: BCP 47 language example +msgid "to search for a BCP 47 language tag, for example" +msgstr "para buscar una etiqueta de idioma BCP 47, por ejemplo" + +# Search box hint: BCP 47 language example +msgid "searches for Tigrigna (Ethiopia)" +msgstr "busca Tigrigna (Etiopía)" + diff --git a/_includes/locale/en/LC_MESSAGES/keyboards-fr-FR.po b/_includes/locale/en/LC_MESSAGES/keyboards-fr-FR.po new file mode 100644 index 00000000..ce36e072 --- /dev/null +++ b/_includes/locale/en/LC_MESSAGES/keyboards-fr-FR.po @@ -0,0 +1,94 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: keymancom\n" +"X-Crowdin-Project-ID: 740839\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /master/keyboards/keyboards.po\n" +"X-Crowdin-File-ID: 2\n" +"Project-Id-Version: keymancom\n" +"Language-Team: French\n" +"Language: fr_FR\n" +"PO-Revision-Date: 2024-11-11 08:15\n" + +# Page Title +msgid "Keyboard Search" +msgstr "Recherche au clavier" + +# Page Description +msgid "Keyman Keyboard Search" +msgstr "Recherche de clavier Keyman" + +# Keyboard search bar +msgid "Keyboard search:" +msgstr "Recherche au clavier:" + +# Search bar placeholder +msgid "Enter language or keyboard" +msgstr "Entrez la langue ou le clavier" + +# Search Button Value +msgid "Search" +msgstr "Recherche" + +# Link to start a new keyboard search +msgid "New search" +msgstr "Nouvelle recherche" + +# Search box instruction (Popular keyboards | All keyboards) +msgid "Enter the name of a keyboard or language to search for" +msgstr "Saisissez le nom d'un clavier ou d'une langue à rechercher" + +# Search box link for popular keyboards +msgid "Popular keyboards" +msgstr "Claviers populaires" + +# Search box link for all Keyman keyboards +msgid "All keyboards" +msgstr "Tous les claviers" + +# Search box hint: List header +msgid "Hints" +msgstr "Conseils" + +# Search box hint: Description +msgid "The search always returns a list of keyboards. It searches for keyboard names and details, language names, country names and script names." +msgstr "La recherche renvoie toujours une liste de claviers. Elle recherche les noms et les détails des claviers, les noms de langues, les noms de pays et les noms d'écritures." + +# Search box hint: available prefixes to use in the search +msgid "You can apply prefixes" +msgstr "Vous pouvez appliquer des préfixes" + +# (keyboards) +msgid "(keyboards)" +msgstr "(claviers)" + +# (languages) +msgid "(languages)" +msgstr "(langues)" + +# (scripts, writing systems) or... +msgid "(scripts, writing systems) or" +msgstr "(scripts, systèmes d'écriture) ou" + +# (countries) to filter your search results... +msgid "(countries) to filter your search results. For example" +msgstr "(pays) pour filtrer vos résultats de recherche. Par exemple" + +# Search box hint: example of country search +msgid "searches for keyboards for languages used in Thailand." +msgstr "recherche des claviers pour les langues utilisées en Thaïlande." + +# Search box hint: BCP 47 prefix +msgid "Use prefix" +msgstr "Utiliser le préfixe" + +# Seach box hint: BCP 47 language example +msgid "to search for a BCP 47 language tag, for example" +msgstr "pour rechercher une balise de langue BCP 47, par exemple" + +# Search box hint: BCP 47 language example +msgid "searches for Tigrigna (Ethiopia)" +msgstr "busca Tigrigna (Etiopía)" + diff --git a/keyboards/index.php b/keyboards/index.php index c188fa85..41e61918 100644 --- a/keyboards/index.php +++ b/keyboards/index.php @@ -7,10 +7,14 @@ use Keyman\Site\com\keyman\templates\Menu; use Keyman\Site\com\keyman\templates\Body; use Keyman\Site\com\keyman\templates\Foot; + use Keyman\Site\com\keyman\Locale; + + Locale::localize('keyboards'); $head_options = [ - 'title' =>'Keyboard Search', - 'description' => 'Keyman Keyboard Search', + 'title' => _('Keyboard Search'), + 'description' => _('Keyman Keyboard Search'), + 'language' => Locale::currentLocale(), 'css' => [Util::cdn('css/template.css'), Util::cdn('keyboard-search/search.css')], 'js' => [Util::cdn('keyboard-search/jquery.mark.js'), Util::cdn('keyboard-search/dedicated-landing-pages.js'), Util::cdn('keyboard-search/search.js')] @@ -46,14 +50,14 @@ <div class='<?= $embed == 'none' ? '' : 'embed embed-'.$embed ?>'> - <h2 class="red underline"><a href='/keyboards'>Keyboard Search</a></h2> + <h2 class="red underline"><a href='/keyboards'><?= _('Keyboard Search') ?></a></h2> <div id='search-box'> <form method='get' action='/keyboards' name='f'> - <label for="search-q">Keyboard search:</label><input id="search-q" type="text" placeholder="Enter language or keyboard" name="q" + <label for="search-q"><?= _('Keyboard search:') ?></label><input id="search-q" type="text" placeholder="<?= _('Enter language or keyboard') ?>" name="q" <?php if($embed == 'none') echo 'autofocus'; ?>> - <input id="search-f" type="image" src="<?= cdn('img/search-button.png"') ?>" value="Search" onclick="return do_search()"> - <label id="search-new"><a href='/keyboards<?=$session_query_q?>'>New search</a></label> + <input id="search-f" type="image" src="<?= cdn('img/search-button.png"') ?>" value="<?= _('Search') ?>" onclick="return do_search()"> + <label id="search-new"><a href='/keyboards<?=$session_query_q?>'><?= _('New search') ?></a></label> <input id="search-obsolete" type="hidden" name="obsolete" value="0"> <input id="search-page" type="hidden" name="page" value="1"> </form> @@ -62,14 +66,31 @@ <div id='search-results-container' class=''> <div id='search-results'></div> <div id='search-results-empty'> - <p>Enter the name of a keyboard or language to search for. (<a href="?q=p:popular">Popular keyboards</a> | <a href="?q=p:alphabetical">All keyboards</a>)</p> + <p> + <?= _('Enter the name of a keyboard or language to search for') ?> ( + <a href="?q=p:popular"><?= _('Popular keyboards') ?></a> | + <a href="?q=p:alphabetical"><?= _('All keyboards') ?></a>) + </p> <br /> - <p>Hints</p> + <p><?= _('Hints') ?></p> <ul> - <li>The search always returns a list of keyboards. It searches for keyboard names and details, language names, country names and script names.</li> - <li>You can apply prefixes <code>k:</code> (keyboards), <code>l:</code> (languages), <code>s:</code> (scripts, writing systems) - or <code>c:</code> (countries) to filter your search results. For example <code>c:thailand</code> searches for keyboards for languages used in Thailand.</li> - <li>Use prefix <code>l:id:</code> to search for a BCP 47 language tag, for example <code>l:id:ti-et</code> searches for Tigrigna (Ethiopia).</li> + <li> + <?= _('The search always returns a list of keyboards. ' . + 'It searches for keyboard names and details, language names, country names and script names.') ?> + </li> + <li> + <?= _('You can apply prefixes') ?> + <code>k:</code> <?= _('(keyboards)') ?> + <code>l:</code> <?= _('(languages)') ?> + <code>s:</code> <?= _('(scripts, writing systems) or') ?> + <code>c:</code> <?= _('(countries) to filter your search results. For example') ?> + <code>c:thailand</code> <?= _('searches for keyboards for languages used in Thailand.') ?> + </li> + <li> + <?= _('Use prefix') ?> + <code>l:id:</code> <?= _('to search for a BCP 47 language tag, for example') ?> + <code>l:id:ti-et</code> <?= _('searches for Tigrigna (Ethiopia)') ?> + </li> </ul> </div> </div> diff --git a/keyboards/session.php b/keyboards/session.php index b2153876..0a01f2f8 100644 --- a/keyboards/session.php +++ b/keyboards/session.php @@ -32,6 +32,13 @@ $embed_ios = $embed == 'ios'; $embed_developer = $embed == 'developer'; + if(isset($_REQUEST['lang'])) { + \Keyman\Site\com\keyman\Locale::overrideCurrentLocale($_REQUEST['lang']); + } else if (isset($_SESSION['lang'])) { + \Keyman\Site\com\keyman\Locale::overrideCurrentLocale($_SESSION['lang']); + } + $_SESSION['lang'] = \Keyman\Site\com\keyman\Locale::currentLocale(); + if($embed != 'none') { // Poor man's session control because IE embedded in downlevel Windows destroys cookie support by // default, including in existing versions of Keyman. diff --git a/resources/init-container.sh b/resources/init-container.sh index f1cb5c18..efcd94d7 100755 --- a/resources/init-container.sh +++ b/resources/init-container.sh @@ -13,3 +13,22 @@ else echo "Skip Generating CDN and clean CDN cache" rm -rf "$THIS_SCRIPT_PATH/../cdn/deploy" fi + +echo "---- Generating .mo localization files ----" +cd _includes/locale/en/LC_MESSAGES/ + +# cleanup previous .mo files +find . -type f -name '*.mo' -delete + +# Compile .po to .mo localization files +for filename in `find . -type f -name "*.po"`; do + # Remove the .po extension + base_name="${filename%.po}" + msgfmt "${base_name}.po" --output-file="${base_name}".mo + retVal=$? + if [[ ${retVal} -ne 0 ]]; then + exit 1 + fi +done + +cd ../../../../