diff --git a/_includes/2020/templates/Head.php b/_includes/2020/templates/Head.php index 96ac70ae..2beab1c6 100644 --- a/_includes/2020/templates/Head.php +++ b/_includes/2020/templates/Head.php @@ -31,6 +31,9 @@ static function render($fields = []) { if(!isset($fields->js)) { $fields->js = []; } + if(!isset($fields->js_i18n_domains)) { + $fields->js_i18n_domains = []; + } ?> language)) { @@ -60,6 +63,16 @@ static function render($fields = []) { js_i18n_domains as $domain => $locales) { + $localization = ''; + foreach($locales as $locale) { + if($localization != '') $localization .= ",\n"; + $localization .= "{ \"locale\": \"$locale\", \"strings\": " . file_get_contents(__DIR__ . "/../../locale/strings/$domain/$locale.json") . "}"; + } + echo "\n"; + } + array_unshift($fields->js, Util::cdn('js/jquery1-11-1.min.js'), Util::cdn('js/bowser.es5.2.9.0.min.js'), @@ -68,7 +81,7 @@ static function render($fields = []) { foreach($fields->js as $jsFile) { $jsFileType = str_ends_with($jsFile, '.mjs') ? "type='module'" : ""; - echo ""; + echo "\n"; } ?> diff --git a/_includes/locale/Locale.php b/_includes/locale/Locale.php index 8d367044..fc7f56d9 100644 --- a/_includes/locale/Locale.php +++ b/_includes/locale/Locale.php @@ -13,7 +13,7 @@ class Locale { public const DEFAULT_LOCALE = 'en'; - // array of the support locales + // array of the support locales // xx-YY locale as specified in crowdin %locale% private static $currentLocales = []; @@ -36,14 +36,16 @@ public static function currentLocales() { */ public static function setLocale($locale) { // Clear current locales - self::$currentLocales == []; + self::$currentLocales = []; if (!empty($locale)) { self::$currentLocales = self::calculateFallbackLocales($locale); } - // Push default fallback locale to the end - array_push(self::$currentLocales, Locale::DEFAULT_LOCALE); + if(!in_array(Locale::DEFAULT_LOCALE, self::$currentLocales)) { + // Push default fallback locale to the end + array_push(self::$currentLocales, Locale::DEFAULT_LOCALE); + } } /** @@ -88,10 +90,26 @@ public static function loadStrings($domain, $locale) { } } + /** + * Return an array of javascript locales available for the given domain, in + * priority order. + */ + public static function domain_js($domain) { + $root = __DIR__ . "/strings/$domain"; + $locales = []; + foreach (self::currentLocales() as $locale) { + if(file_exists("$root/$locale.json")) { + array_push($locales, $locale); + } + } + + return $locales; + } + /** * Defines a global variable for page locale strings and also * tells locale system that current page uses locales - * @param $define - + * @param $define - * @param $id - folder containing locale strings, relative to /_includes/locale/strings */ public static function definePageLocale($define, $id) { @@ -156,7 +174,7 @@ private static function getString($domain, $id) { } } - // String not found in any localization - + // String not found in any localization - if(KeymanHosts::Instance()->Tier() == KeymanHosts::TIER_DEVELOPMENT) { die('string ' . $id . ' is missing in all the l10ns'); } @@ -164,13 +182,13 @@ private static function getString($domain, $id) { } /** - * Wrapper to lookup localized string for webpage domain. + * Wrapper to lookup localized string for webpage domain. * Formatted string using optional variable args for placeholders * should escape like %1\$s * @param $domain - the PHP file using the localized strings * @param $id - the id for the string * @param $args - optional remaining args to the format string - */ + */ public static function m($domain, $id, ...$args) { $str = self::getString($domain, $id); if (count($args) == 0) { diff --git a/cdn/dev/js/i18n/search/en.json b/_includes/locale/strings/keyboard-search/en.json similarity index 100% rename from cdn/dev/js/i18n/search/en.json rename to _includes/locale/strings/keyboard-search/en.json diff --git a/cdn/dev/js/i18n/search/es.json b/_includes/locale/strings/keyboard-search/es.json similarity index 100% rename from cdn/dev/js/i18n/search/es.json rename to _includes/locale/strings/keyboard-search/es.json diff --git a/cdn/dev/js/i18n/search/fr.json b/_includes/locale/strings/keyboard-search/fr.json similarity index 100% rename from cdn/dev/js/i18n/search/fr.json rename to _includes/locale/strings/keyboard-search/fr.json diff --git a/cdn/dev/js/i18n.mjs b/cdn/dev/js/i18n.mjs new file mode 100644 index 00000000..a1bfad2e --- /dev/null +++ b/cdn/dev/js/i18n.mjs @@ -0,0 +1,125 @@ +/** + * Keyman is copyright (c) SIL Global. MIT License + * + * Vanilla JS for localizing keyboard search strings without a framework + * Reference: https://medium.com/@mihura.ian/translations-in-vanilla-javascript-c942c2095170 + * + * Domains are loaded by Head::render() with the js_i18n_domains property, e.g. + * + * 'js_i18n_domains' => [ + * 'keyboard-search' => Locale::domain_js('keyboard-search'), + * ], + * + */ + +export class I18n { + // domains member has the following structure: + // [ + // { "locale": "fr", "strings": { "key": "value", ... } }, + // { "locale": "en", "strings": { "key": "value", ... } }, + // ... + // ] + static domains = []; + + /** + * Load the strings for the given domain + * @param {string} domain + */ + static loadDomain(domain) { + + // avoid reporting domain-load errors more than once + I18n.domains[domain] = { locales: [] }; + + const json = document.getElementById('i18n_'+domain)?.text; + if(!json) { + console.error(`i18n domain '${domain}' was not loaded`); + return false; + } + + try { + I18n.domains[domain] = { + locales: JSON.parse(json) + }; + } catch(e) { + // Handle JSON parse errors so we get a functioning page, even if it has + // no localized strings visible + console.error(e); + return false; + } + + return true; + } + + /** + * Navigates inside `obj` with `path` string, + * + * Usage: + * objNavigate({a: {b: {c: 123}}}, "a.b.c") // returns 123 + * + * Fails silently. + * @param {obj} obj + * @param {String} path to navigate into obj + * @returns String or undefined if variable is not found. + */ + static objNavigate(obj, path){ + if(!obj) return undefined; + var aPath = path.split('.'); + try { + return aPath.reduce((a, v) => a[v], obj); + } catch { + return undefined; + } + }; + + /** + * Interpolates variables wrapped with `{}` in `str` with variables in `obj` + * It will replace what it can, and leave the rest untouched + * + * Usage: + * + * named variables: + * strObjInterpolation("I'm {age} years old!", { age: 29 }); + * + * ordered variables + * strObjInterpolation("The {0} says {1}, {1}, {1}!", ['cow', 'moo']); + */ + static strObjInterpolation(str, obj){ + obj = obj || []; + str = str ? str.toString() : ''; + return str.replace( + /{([^{}]*)}/g, + (a, b) => { + const r = obj[b]; + return typeof r === 'string' || typeof r === 'number' ? r : a; + }, + ); + }; + + /** + * Determine the display UI language for the keyboard search + * Navigate the translation JSON + * @param {string} domain of the localized strings + * @param {string} key for the string + * @param {obj} interpolations for optional formatted parameters + * @returns localized string + */ + static t(domain, key, interpolations) { + if (!I18n.domains[domain]) { + if(!I18n.loadDomain(domain)) { + return key; + } + } + + // Find best matching string in the available locales + for(const locale of I18n.domains[domain].locales) { + const value = I18n.objNavigate(locale.strings, key); + if(value) { + return I18n.strObjInterpolation(value, interpolations); + } + } + + console.warn(`Missing localization string in '${domain}' for '${key}' in all loaded locales`); + // TODO: log to sentry? + return key; + } +} diff --git a/cdn/dev/js/i18n/i18n.mjs b/cdn/dev/js/i18n/i18n.mjs deleted file mode 100644 index 3fdf4eed..00000000 --- a/cdn/dev/js/i18n/i18n.mjs +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Keyman is copyright (c) SIL Global. MIT License - * - * Vanilla JS for localizing keyboard search strings without a framework - * Reference: https://medium.com/@mihura.ian/translations-in-vanilla-javascript-c942c2095170 - */ - -export class I18n { - - static DEFAULT_LOCALE = 'en'; - - // Array of the supported locales - static currentLocales = []; - - static currentDomain = ''; - - // strings is an array of domains. - // Each domain is an array of locales - // Each locale is an object? with loaded flag and array of strings - static strings = []; - - - /** - * Set the current locales, with an array of fallbacks, ending in 'en' - * @param {locale} The new current locale - */ - static setLocale(locale) { - // Clean current locales - I18n.currentLocales = []; - - if (!locale) { - I18n.currentLocales = I18n.calculateFallbackLocales(locale); - } - - // Push default ballback locale to the end - I18n.currentLocales.push(I18n.DEFAULT_LOCALE); - } - - /** - * Load the strings for the given domain - * @param {string} domain - */ - static loadDomain(domain) { - if (!I18n.strings[domain]) { - I18n.strings[domain] = []; - } - I18n.strings[domain][I18n.DEFAULT_LOCALE] = { - strings: [], - loaded: false - } - } - - /** - * Defines a global variable for page locale strings and also - * tells locale system that current page uses locales - * @param $domain - - * @param $id - folder containing locale strings, relative to /cdn/dev/js/i18n - */ - static async definePageLocale(domain, id) { - I18n.currentDomain = domain; - if (!I18n.strings.hasOwnProperty(id)) { - I18n.strings[id] = []; - } - await I18n.loadStrings(domain, I18n.DEFAULT_LOCALE); - } - - /** - * Given a locale, return an array of fallback locales - * For example: es-ES --> [es, es-ES] - * TODO: Use an existing fallback algorthim like - * https://cldr.unicode.org/development/development-process/design-proposals/language-distance-data - * @param $locale - the locale to determine fallback locales - * @return array of fallback locales - */ - static calculateFallbackLocales(locale) { - // Start with the given locale - var fallback = [locale]; - - // Support other fallbacks such as es-419 -> es - var parts = locale.split('-'); - for (var i = parts.length-1; i > 0; i--) { - var lastPosition = locale.lastIndexOf(parts[i]) - 1; - // Insert language tag substring to head - fallback.unshift(locale.substr(0, lastPosition)); - } - - return fallback; - } - - /** - * Dynamically load translation for a language if not already added - * @param {String} lang - */ - static async loadStrings(domain, lang) { - var currentLocaleFilename = `./${domain}/${lang}.json`; - I18n.currentDomain = domain; - - try { - const jsModule = await import(currentLocaleFilename, { - with: { type: 'json'} - }); - I18n.strings[I18n.currentDomain][lang] = { - strings: jsModule.default, - loaded: true - }; - } catch (ex) { - // JSON localization file doesn't exist. Log to sentry? - //console.warn(`${domain}/${lang}.json doesn't exist. Not loading...`); - } - } - - /** - * Navigates inside `obj` with `path` string, - * - * Usage: - * objNavigate({a: {b: {c: 123}}}, "a.b.c") // returns 123 - * - * Fails silently. - * @param {obj} obj - * @param {String} path to navigate into obj - * @returns String or undefined if variable is not found. - */ - static objNavigate(obj, path){ - var aPath = path.split('.'); - try { - return aPath.reduce((a, v) => a[v], obj); - } catch { - return; - } - }; - - /** - * Interpolates variables wrapped with `{}` in `str` with variables in `obj` - * It will replace what it can, and leave the rest untouched - * - * Usage: - * - * named variables: - * strObjInterpolation("I'm {age} years old!", { age: 29 }); - * - * ordered variables - * strObjInterpolation("The {0} says {1}, {1}, {1}!", ['cow', 'moo']); - */ - static strObjInterpolation(str, obj){ - obj = obj || []; - str = str ? str.toString() : ''; - return str.replace( - /{([^{}]*)}/g, - (a, b) => { - const r = obj[b]; - return typeof r === 'string' || typeof r === 'number' ? r : a; - }, - ); - }; - - /** - * Determine the display UI language for the keyboard search - * Navigate the translation JSON - * @param {string} domain of the localized strings - * @param {string} key for the string - * @param {obj} interpolations for optional formatted parameters - * @returns localized string - */ - static async t(domain, key, interpolations) { - // Load the domain if it doesn't exist - if (!I18n.strings[domain]) { - loadDomain(domain); - } - - // embed_lang set by session.php - var language = embed_lang ?? I18n.DEFAULT_LOCALE; - if (I18n.currentDomain) { - if (!I18n.strings[domain][language]) { - var obj = { - strings: {}, - loaded: false - }; - I18n.strings[domain][language] = obj; - } - if (!I18n.strings[domain][language].loaded) { - // Will set -> loaded = true - await I18n.loadStrings(domain, language); - } - } - - if (!I18n.strings[I18n.currentDomain][language] || !I18n.strings[I18n.currentDomain][language].strings[key]) { - // Langage or key is missing, so fallback to "en" - // Log to Sentry? - // console.warn(`i18n for language: '${language}' for '${key}' missing, fallback to 'en'`); - language = I18n.DEFAULT_LOCALE; - } - - const value = I18n.objNavigate(I18n.strings[I18n.currentDomain][language].strings, key); - if (!value) { - // Warn if string doesn't exist - console.log(`Missing '${I18n.currentDomain}/${language}.json' string for '${key}'`); - } - return I18n.strObjInterpolation(value, interpolations); - } - -} diff --git a/cdn/dev/keyboard-search/search.mjs b/cdn/dev/keyboard-search/search.mjs index 94bf0ab0..9af50859 100644 --- a/cdn/dev/keyboard-search/search.mjs +++ b/cdn/dev/keyboard-search/search.mjs @@ -1,4 +1,7 @@ -import { I18n } from '../js/i18n/i18n.mjs'; +import { I18n } from '../js/i18n.mjs'; + +const I18N_DOMAIN = 'keyboard-search'; +const t = (key, interpolations) => I18n.t(I18N_DOMAIN, key, interpolations); // Polyfill for String.prototype.includes @@ -14,13 +17,6 @@ if (!String.prototype.includes) { }; } -await I18n.definePageLocale('search', 'search'); - -const t = async (key, interpolations) => { - const v = await I18n.t('search', key, interpolations); - return v; -} - ///////////////////////////// if(typeof embed_query == 'undefined') { @@ -93,7 +89,7 @@ function wrapSearch(localCounter, updateHistory) { $('#search-box').removeClass('searching'); return false; } - + var base = location.protocol+'//api.'+location.host; // this works on test sites as well as live, assuming we use the host pattern "keyman.com[.localhost]" var url = base+'/search/2.0?p='+page+'&q='+encodeURIComponent(stripCommonWords(q)); @@ -238,7 +234,7 @@ function process_page_match(q) { return result; } -async function process_response(q, obsolete, res) { +function process_response(q, obsolete, res) { var resultsElement = $('#search-results'); res = JSON.parse(res); resultsElement.empty(); @@ -267,18 +263,18 @@ async function process_response(q, obsolete, res) { var deprecatedElement = null; $('