diff --git a/_includes/2020/templates/Head.php b/_includes/2020/templates/Head.php
index 9c19663d..8d1da67e 100644
--- a/_includes/2020/templates/Head.php
+++ b/_includes/2020/templates/Head.php
@@ -32,6 +32,9 @@ static function render($fields = []) {
if(!isset($fields->js)) {
$fields->js = [];
}
+ if(!isset($fields->js_i18n_domains)) {
+ $fields->js_i18n_domains = [];
+ }
?>
lang)) {
@@ -61,15 +64,27 @@ 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(_KEYMANCOM_INCLUDES . "/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'),
Util::cdn('js/kmlive.js')
);
- foreach($fields->js as $jsFile) { ?>
-
-
+ foreach($fields->js as $jsFile) {
+ $jsFileType = str_ends_with($jsFile, '.mjs') ? "type='module'" : "";
+ echo "\n";
+ }
+ ?>
Tier() == KeymanHosts::TIER_DEVELOPMENT) {
die('string ' . $id . ' is missing in all the l10ns');
}
@@ -172,13 +190,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/_includes/locale/strings/keyboards/details/en.php b/_includes/locale/strings/keyboards/details/en.php
index 22ccde87..3a3b148a 100644
--- a/_includes/locale/strings/keyboards/details/en.php
+++ b/_includes/locale/strings/keyboards/details/en.php
@@ -137,9 +137,6 @@
"new_search" => "New search",
- ## TODO: Previous/Next pagination handled in search.js
-
-
## Errors
# Failed to load keyboard package [ID]
diff --git a/_includes/locale/strings/keyboards/details/km.php b/_includes/locale/strings/keyboards/details/km.php
index 380b946a..49807a6a 100644
--- a/_includes/locale/strings/keyboards/details/km.php
+++ b/_includes/locale/strings/keyboards/details/km.php
@@ -89,7 +89,7 @@
"downloads_since" => "ដំឡើងចាប់តាំងពី %1\$s",
# Date to start counting downloads
- "date_counting" => "October 2019",
+ "date_counting" => "តុលា ២០១៩",
"encoding" => "ការអ៊ីនកូដ",
diff --git a/_includes/locale/strings/keyboards/en.json b/_includes/locale/strings/keyboards/en.json
new file mode 100644
index 00000000..c2bcd24a
--- /dev/null
+++ b/_includes/locale/strings/keyboards/en.json
@@ -0,0 +1,15 @@
+{
+ "resultOne": "result",
+ "resultMore": "results",
+ "pageNumberOfTotalPages": "page {pageNumber} of {totalPages}.",
+ "keyboardSearchTitle": "- Keyboard search",
+ "obsoleteKeyboards": "Obsolete keyboards",
+ "monthlyDownloadZero": "No recent downloads",
+ "monthlyDownloadOne": "monthly download",
+ "monthlyDownloadMore": "monthly downloads",
+ "notUnicode": "Note: Not a Unicode keyboard",
+ "designedForPlatform": "Designed for {platform}",
+ "noMatchesFoundForKeyboard": "No matches found for '{keyboard}'",
+ "previousPager": "< Previous",
+ "nextPager": "Next >"
+}
diff --git a/_includes/locale/strings/keyboards/es.json b/_includes/locale/strings/keyboards/es.json
new file mode 100644
index 00000000..70a65bbd
--- /dev/null
+++ b/_includes/locale/strings/keyboards/es.json
@@ -0,0 +1,15 @@
+{
+ "resultOne": "resultado",
+ "resultMore": "resultados",
+ "pageNumberOfTotalPages": "página {pageNumber} de {totalPages}.",
+ "keyboardSearchTitle": "- Búsqueda por teclado",
+ "obsoleteKeyboards": "Teclados obsoletos",
+ "monthlyDownloadZero": "No hay descargas recientes",
+ "monthlyDownloadOne": "descarga mensual",
+ "monthlyDownloadMore": "descargas mensuales",
+ "notUnicode": "Nota: No es un teclado Unicode",
+ "designedForPlatform": "Diseñado para {platform}",
+ "noMatchesFoundForKeyboard": "No se encontraron coincidencias para '{keyboard}'",
+ "previousPager": "< Anterior",
+ "nextPager": "Siguente >"
+}
diff --git a/_includes/locale/strings/keyboards/fr.json b/_includes/locale/strings/keyboards/fr.json
new file mode 100644
index 00000000..880a0f99
--- /dev/null
+++ b/_includes/locale/strings/keyboards/fr.json
@@ -0,0 +1,15 @@
+{
+ "resultOne": "résultat",
+ "resultMore": "résultats",
+ "pageNumberOfTotalPages": "page {pageNumber} sur {totalPages}.",
+ "keyboardSearchTitle": "- Recherche au clavier",
+ "obsoleteKeyboards": "Claviers obsolètes",
+ "monthlyDownloadZero": "Aucun téléchargement récent",
+ "monthlyDownloadOne": "téléchargement mensuel",
+ "monthlyDownloadMore": "téléchargements mensuels",
+ "notUnicode": "Remarque: Ce n'est pas un clavier Unicode.",
+ "designedForPlatform": "Conçu pour {platform}",
+ "noMatchesFoundForKeyboard": "Aucun résultat trouvé pour '{keyboard}'",
+ "previousPager": "< Précédentes",
+ "nextPager": "Plus >"
+}
diff --git a/_includes/locale/strings/keyboards/install/km.php b/_includes/locale/strings/keyboards/install/km.php
index 27f64dd4..03d7460f 100644
--- a/_includes/locale/strings/keyboards/install/km.php
+++ b/_includes/locale/strings/keyboards/install/km.php
@@ -4,14 +4,14 @@
* Keyman is copyright (C) SIL Global. MIT License.
*
* Default English strings for keyboards/keyboard-install.php
- * Don't escape $s when uploading source to crowdin because exports will escape \$s to \\$s
+ * When exporting strings from crowdin, convert \\$s to \$s
*/
declare(strict_types=1);
return [
# Page Title
- "install_page_title" => "%1\$s ក្តារចុច",
+ "install_page_title" => "%1\$s ក្ដារចុច",
# {Keyboard} download should start shortly
"download_start_shortly" =>
diff --git a/_includes/locale/strings/keyboards/km.json b/_includes/locale/strings/keyboards/km.json
new file mode 100644
index 00000000..cbe861fb
--- /dev/null
+++ b/_includes/locale/strings/keyboards/km.json
@@ -0,0 +1,15 @@
+{
+ "resultOne": "លទ្ធផល",
+ "resultMore": "លទ្ធផល",
+ "pageNumberOfTotalPages": "ទំព័រទី {pageNumber} នៃ {totalPages}។",
+ "keyboardSearchTitle": "- ស្វែងរកក្ដារចុច",
+ "obsoleteKeyboards": "ក្ដារចុចបោះបង់ចោល",
+ "monthlyDownloadZero": "គ្មានការទាញយកថ្មីៗ",
+ "monthlyDownloadOne": "ចំនួនទាញយកប្រចាំខែ",
+ "monthlyDownloadMore": "ចំនួនទាញយកទាំងឡាយប្រចាំខែ",
+ "notUnicode": "ចំណាំ៖ មិនមែនជាក្ដារចុចយូនីកូដ",
+ "designedForPlatform": "រចនាសម្រាប់ {platform}",
+ "noMatchesFoundForKeyboard": "គ្មានឃើញចម្លើយសម្រាប់ {keyboard}",
+ "previousPager": "< មុន",
+ "nextPager": "បន្ទាប់ >"
+}
diff --git a/_includes/locale/strings/keyboards/km.php b/_includes/locale/strings/keyboards/km.php
index 8fedcca4..d665723f 100644
--- a/_includes/locale/strings/keyboards/km.php
+++ b/_includes/locale/strings/keyboards/km.php
@@ -3,7 +3,8 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
- * Default Khmer strings for keyboards/index.php
+ * Default English strings for keyboards/index.php
+ * When exporting strings from crowdin, convert \\$s to \$s
*/
declare(strict_types=1);
@@ -42,14 +43,14 @@
# Search box hint: Description
"searchbox_description" =>
"លទ្ធផលស្វែងរកតែងតែបង្ហាញជាបញ្ជីក្ដារចុច ។ វាស្វែងរកអំពីឈ្មោះ និងព័ត៌មានលម្អិតរបស់ក្ដារចុច, ឈ្មោះភាសា, ឈ្មោះប្រទេស, និង ឈ្មោះចារឹកនៃក្ដារចុច ។",
-
+
# Search box hint (line 2):
"searchbox_hint_2" =>
"អ្នកអាចភ្ជាប់បុព្វបទទាំងនេះ %1\$s (ឈ្មោះក្ដារចុច) %2\$s (ឈ្មោះភាសា) %3\$s (អក្សរចារឹក, ប្រព័ន្ធសរសេរ) ឫ
%4\$s (ឈ្មោះប្រទេស) ដើម្បីធ្វើឱ្យលទ្ធផលស្វែងរកកាន់តែច្បាស់លាស់។ ឧទាហរណ៍ %5\$s ស្វែងរកនូវក្ដារចុចសម្រាប់ភាសាទាំងឡាយដែលត្រូវបានប្រើប្រាស់នៅក្នុងប្រទេសថៃ។",
-
+
# Search box hint (line 3):
- "searchbox_hint_3" =>
- "ការប្រើបុព្វបទ %1\$s ក្នុងការស្វែងរកស្លាកភាសា BCP 47, ឧទាហរណ៍ %2\$s ស្វែងរកភាសា Tigrigna (អេត្យូពី)។"
+ "searchbox_hint_3" =>
+ "ការប្រើបុព្វបទ %1\$s ក្នុងការស្វែងរកស្លាកភាសា BCP 47, ឧទាហរណ៍ %2\$s ស្វែងរកភាសា Tigrigna (អេត្យូពី)។",
];
diff --git a/_includes/locale/strings/keyboards/share/km.php b/_includes/locale/strings/keyboards/share/km.php
index 503caf04..0d11884a 100644
--- a/_includes/locale/strings/keyboards/share/km.php
+++ b/_includes/locale/strings/keyboards/share/km.php
@@ -4,7 +4,7 @@
* Keyman is copyright (C) SIL Global. MIT License.
*
* Default English strings for keyboards/keyboard-share.php
- * Don't escape $s when uploading source to crowdin because exports will escape \$s to \\$s
+ * When exporting strings from crowdin, convert \\$s to \$s
*/
declare(strict_types=1);
diff --git a/cdn/dev/js/i18n.mjs b/cdn/dev/js/i18n.mjs
new file mode 100644
index 00000000..69c4001e
--- /dev/null
+++ b/cdn/dev/js/i18n.mjs
@@ -0,0 +1,129 @@
+/**
+ * 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' => [
+ * 'keyboards' => Locale::domain_js('keyboards'),
+ * ],
+ *
+ */
+
+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
+ * @return {boolean} Status if domain was successfully loaded
+ */
+ 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(`Invalid JSON for 'i18n_${domain}': ${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 {Object} obj
+ * @param {string} path to navigate into obj
+ * @return {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']);
+ * @param {string} str containing interpolated variables wrapped in `{}`
+ * @param {Object} JSON containing values for the variables
+ * @return {string} Resulting string where variables are replaced
+ */
+ 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 {Object} interpolations for optional formatted parameters
+ * @return {string} 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/keyboard-search/search.js b/cdn/dev/keyboard-search/search.mjs
similarity index 87%
rename from cdn/dev/keyboard-search/search.js
rename to cdn/dev/keyboard-search/search.mjs
index e4242fb9..b260d247 100644
--- a/cdn/dev/keyboard-search/search.js
+++ b/cdn/dev/keyboard-search/search.mjs
@@ -1,3 +1,8 @@
+import { I18n } from '../js/i18n.mjs';
+
+const I18N_DOMAIN = 'keyboards';
+const t = (key, interpolations) => I18n.t(I18N_DOMAIN, key, interpolations);
+
// Polyfill for String.prototype.includes
if (!String.prototype.includes) {
@@ -21,6 +26,10 @@ if(typeof embed_query == 'undefined') {
var embed_query_q = embed_query == '' ? '' : '?'+embed_query;
var embed_query_x = embed_query == '' ? '' : '&'+embed_query;
+// TODO: Validate BCP-47 triplet?
+var langMatch = location.pathname.match(/(\/(.+))?\/keyboards(.*)$/);
+var embed_lang = (langMatch) ? langMatch[2] : 'en';
+
var dynamic_search_timeout = 0;
/////////////////////////////////////////////////////////////////////////////////////
@@ -36,13 +45,13 @@ function getCurrentPath(q, page, obsolete) {
page = page > 1 ? 'page='+page : '';
var path = '';
if(r && r[1].charAt(0) == 'c') {
- path = '/keyboards/countries/';
+ path = '/' + embed_lang + '/keyboards/countries/';
} else if(r && r[1].charAt(0) == 'l') {
- path = '/keyboards/languages/'+r[3];
+ path = '/' + embed_lang + '/keyboards/languages/'+r[3];
} else if(q == '') {
- path = '/keyboards'
+ path = '/' + embed_lang + '/keyboards'
} else {
- path = '/keyboards?q='+encodeURIComponent(q);
+ path = '/' + embed_lang + '/keyboards?q='+encodeURIComponent(q);
}
if(page + obsolete == '') {
@@ -84,7 +93,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));
@@ -258,25 +267,25 @@ function process_response(q, obsolete, res) {
var deprecatedElement = null;
$('
').text(
- res.context.totalRows + (res.context.totalRows == 1 ? ' result' : ' results') +
- (res.context.totalPages < 2 ? '' : '; page '+res.context.pageNumber + ' of '+res.context.totalPages+'.')
+ res.context.totalRows + ' ' + (res.context.totalRows == 1 ? t('resultOne') : t('resultMore') )+ ' ' +
+ (res.context.totalPages < 2 ? '' : t('pageNumberOfTotalPages', {pageNumber: res.context.pageNumber, totalPages: res.context.totalPages}))
).appendTo(resultsElement);
- document.title = q + ' - Keyboard search';
+ document.title = q + ' ' + t('keyboardSearchTitle');
- res.keyboards.forEach(function(kbd) {
+ for (const kbd of res.keyboards) {
if(isKeyboardObsolete(kbd) && !deprecatedElement) {
// TODO: make title change depending on whether deprecated keyboards are shown or hidden
deprecatedElement = $(
- '
Obsolete keyboards
');
+ '
' + t('obsoleteKeyboards') + '
');
resultsElement.append(deprecatedElement);
}
- $keyboardClass = kbd.isDedicatedLandingPage ? 'keyboard keyboardLandingPage' : 'keyboard';
+ const keyboardClass = kbd.isDedicatedLandingPage ? 'keyboard keyboardLandingPage' : 'keyboard';
var k = $(
- "
"+
+ "
"+
"
"+
"
"+
"
"+
@@ -288,22 +297,22 @@ function process_response(q, obsolete, res) {
"
");
if(kbd.isDedicatedLandingPage) {
- $('.title a', k).text(kbd.name).attr('href', '/keyboards/h'+kbd.id+embed_query_q);
+ $('.title a', k).text(kbd.name).attr('href', '/' + embed_lang + '/keyboards/h'+kbd.id+embed_query_q);
} else {
- $('.title a', k).text(kbd.name).attr('href', '/keyboards/'+kbd.id+(kbd.match.tag ? '?bcp47='+kbd.match.tag+embed_query_x : embed_query_q));
+ $('.title a', k).text(kbd.name).attr('href', '/' + embed_lang + '/keyboards/'+kbd.id+(kbd.match.tag ? '?bcp47='+kbd.match.tag+embed_query_x : embed_query_q));
}
if(kbd.isDedicatedLandingPage) {
// We won't show the downloads text
} else if(kbd.match.downloads == 0)
- $('.downloads', k).text('No recent downloads');
+ $('.downloads', k).text(t('monthlyDownloadZero'));
else if(kbd.match.downloads == 1)
- $('.downloads', k).text(kbd.match.downloads+' monthly download');
+ $('.downloads', k).text(kbd.match.downloads+' ' + t('monthlyDownloadOne'));
else
- $('.downloads', k).text(kbd.match.downloads+' monthly downloads');
+ $('.downloads', k).text(kbd.match.downloads+' ' + t('monthlyDownloadMore'));
if(!kbd.encodings.toString().match(/unicode/)) {
- $('.encoding', k).text('Note: Not a Unicode keyboard');
+ $('.encoding', k).text(t('notUnicode'));
}
$('.id', k).text(kbd.id);
@@ -331,20 +340,21 @@ function process_response(q, obsolete, res) {
// icon-ios
// icon-linux
// icon-windows
- var img = $('
![]()
').attr('src', '/cdn/dev/keyboard-search/icon-'+i+'.png').attr('title', 'Designed for '+i);
+ var img = $('
![]()
').attr('src', '/cdn/dev/keyboard-search/icon-'+i+'.png').attr('title', t('designedForPlatform', {platform: i}));
$('.platforms', k).append(img);
}
}
}
$('.platforms', k).text();
(deprecatedElement ? deprecatedElement : resultsElement).append(k);
- });
+ };
if(res.context.totalPages > 1) {
- buildPager(res, q, obsolete).appendTo(resultsElement);
+ const p = buildPager(res, q, obsolete);
+ p.appendTo(resultsElement);
}
} else {
- $('
').addClass('red').text("No matches found for '"+qq+"'").appendTo(resultsElement);
+ $('').addClass('red').text(t('noMatchesFoundForKeyboard', {keyboard: qq})).appendTo(resultsElement);
}
}
@@ -358,7 +368,7 @@ function buildPager(res, q, obsolete) {
}
}
- appendPager(pager, '< Previous', res.context.pageNumber-1);
+ appendPager(pager, t('previousPager'), res.context.pageNumber-1);
if(res.context.pageNumber > 5) {
$('...').appendTo(pager);
}
@@ -368,7 +378,7 @@ function buildPager(res, q, obsolete) {
if(res.context.pageNumber < res.context.totalPages - 4) {
$('...').appendTo(pager);
}
- appendPager(pager, 'Next >', res.context.pageNumber+1);
+ appendPager(pager, t('nextPager'), res.context.pageNumber+1);
return pager;
}
@@ -384,7 +394,7 @@ function goToPage(event, q, page) {
return false;
}
-function do_search() {
+export function do_search() {
document.f.page.value = 1;
search(true);
return false; // always return false from search box
diff --git a/crowdin.yml b/crowdin.yml
index 20c8a423..d7946f58 100644
--- a/crowdin.yml
+++ b/crowdin.yml
@@ -27,6 +27,20 @@ files:
# Keyboard search files
+ # JS files
+ - source: /_includes/locale/strings/keyboards/en.json
+ dest: /keyboards/keyboards/en.json
+ translation: /_includes/locale/strings/keyboards/%locale%.json
+ languages_mapping:
+ locale:
+ # Canonical locales as needed
+ es-ES: es
+ de: de
+ fr: fr
+ km: km
+
+ # PHP files
+
- source: /_includes/locale/strings/keyboards/en.php
dest: /keyboards/keyboards/en.php
translation: /_includes/locale/strings/keyboards/%locale%.php