UNPKG

i18n-behavior

Version:

Instant and Modular I18N engine for lit-html and Polymer

1,190 lines (1,138 loc) 41.5 kB
/** @license https://github.com/t2ym/i18n-behavior/blob/master/LICENSE.md Copyright (c) 2019, Tetsuya Mori <t2y3141592@gmail.com>. All rights reserved. */ import 'i18n-format/i18n-format.js'; import { html, defaultLang } from './i18n-preference.js'; import { attributesRepository } from './i18n-attr-repo.js'; import deepcopy from 'deepcopy/dist/deepcopy.js'; export { html, defaultLang, attributesRepository }; const isXhrNativeJsonResponseType = (() => { try { new XMLHttpRequest().responseType = 'json'; return true; } catch (e) { return false; } })(); // app global bundle storage export const bundles = { '': {} }; // with an empty default bundle // shared fetching instances for bundles const bundleFetchingInstances = {}; // cached enumerated fallback languages const enumeratedFallbackLanguageCache = new Map(); // cached formatter functions for this.i18nFormat() const i18nFormatterCache = new Map(); const isTemplateLiteralSupported = (() => { try { new Function('return ``;')(); return true; } catch (e) { return false; } })(); // path for start URL const startUrl = (function () { let path = window.location.pathname; if (document.querySelector('meta[name=app-root]') && document.querySelector('meta[name=app-root]').getAttribute('content')) { // <meta name="app-root" content="/"> to customize application root path = document.querySelector('meta[name=app-root]').getAttribute('content'); } else if (document.querySelector('link[rel=manifest]') && document.querySelector('link[rel=manifest]').getAttribute('href') && document.querySelector('link[rel=manifest]').getAttribute('href').match(/^\//)) { // assume manifest is located at the application root folder path = document.querySelector('link[rel=manifest]').getAttribute('href'); } return path.replace(/\/[^\/]*$/,'/'); })(); // path for locales from <html locales-path="locales"> const localesPath = html.hasAttribute('locales-path') ? html.getAttribute('locales-path') : 'locales'; // Support ShadowDOM V1 export const paramAttribute = 'slot'; // set up userPreference let userPreference = document.querySelector('i18n-preference'); if (!userPreference) { userPreference = document.createElement('i18n-preference'); // append to body addEventListener('load', function (event) { if (!document.querySelector('i18n-preference')) { document.querySelector('body').appendChild(userPreference); } }); setTimeout(function () { if (!document.querySelector('i18n-preference')) { document.querySelector('body').appendChild(userPreference); } }, 0); } // debug log when <html debug> attribute exists export const debuglog = html.hasAttribute('debug') ? function (arg) { console.log(arg); } : function () {}; /** * Apply `BehaviorsStore.I18nControllerBehavior` to manipulate internal variables for I18N * * Note: This behavior is not for normal custom elements to apply I18N. UI is not expected. * * @polymerBehavior I18nControllerBehavior * @memberof BehaviorsStore */ export const I18nControllerBehavior = { properties: { /** * Flag for detection of `I18nControllerBehavior` * * `true` if I18nControllerBehavior is applied * * Note: Module-specific JSON resources are NOT fetched for `I18nControllerBehavior` */ isI18nController: { type: Boolean, value: true, readOnly: true }, /** * HTML element object for the current document */ html: { type: Object, value: html }, /** * Master bundles object for storing all the localized and default resources */ masterBundles: { type: Object, value: bundles }, /** * Default lang for the document, i.e., the initial value of `<html lang>` attribute */ defaultLang: { type: String, value: defaultLang, readOnly: true }, /** * List of elements which are fetching bundles */ bundleFetchingInstances: { type: Object, value: bundleFetchingInstances }, /** * Root URL path of the application ends with '/' to fetch bundles */ startUrl: { type: String, value: startUrl, readOnly: true }, /** * Path for locales * * Default value is `'locales'` */ localesPath: { type: String, value: localesPath, readOnly: true }, /** * <i18n-attr-repo> element to store attributes repository */ attributesRepository: { type: Object, value: attributesRepository, readOnly: true }, /** * <i18n-preference> element */ userPreference: { type: Object, value: userPreference, readOnly: true } } }; /** * I18nControllerCoreMixin: Polymer-independent core parts of `BehaviorsStore.I18nBehavior` * * <dom-module id="custom-element"> * <template> * <span>Hard-coded UI texts are automatically made localizable</span> * </template> * <script> * Polymer({ * is: 'custom-element', * behaviors: [ * BehaviorsStore.I18nBehavior // Add this behavior * ] * }); * </script> * </dom-module> * * `I18nBehavior` automatically extracts UI texts from `template` and * binds them to localizable variables in `this.text` object. * * According to the `lang` attribute value, `this.text`, and thus the bound UI texts, * dynamically mutates by loading localized values from a JSON file in the `locales` directory. * By default, `lang` attribute values of all the localizable elements with `I18nBehavior` are * automatically updated according to `<html lang>` attribute value. * * The UI text externalization can be processed at build time as well by `gulp-*` task * so that `I18nBehavior` can immediately recognize the extracted texts in JSON and * skip run-time externalization. * * Run-time externalization is suitable for development and debugging * since the code changes are immediately reflected at reloading without build-time preprocesses. * In contrast, build-time externalization is suitable for production builds * since it eliminates run-time externalization overheads. * * ### Steps to localize a custom element * * 1. [JavaScript] Add `BehaviorsStore.I18nBehavior` to `behaviors` * 1. [gulp] Add `gulp-*` filter for `custom-element.html` and generate `custom-element.json` * 1. [locales] Put `custom-element.lang.json` in `locales` directory * 1. [translation] Translate `locales/custom-element.lang.json` * * - - - * * ### Directory structure of bundle files * * Normal bundles (`/element-root/locales/element-name.*.json`) for elements * are stored under their root directories. * * Shared bundles (`/locales/bundle.*.json`) are generated at build time * by merging all the targeted bundles of the localizable elements. * * Once the shared bundles are loaded, there should be no need to search for * normal bundles per element unless the element is intentionally excluded * from the shared bundles. * * ``` * /bundle.json * /locales/bundle.ja.json * /bundle.fr.json * /bundle.zh-Hans.json * * /elements/my-list/my-list.json * /locales/my-list.ja.json * /my-list.zh-Hans.json * * /google-chart-demo/google-chart-demo.json * /locales/google-chart-demo.ja.json * /google-chart-demo.fr.json * ``` * * - - - * * ### Localizable `<template is="i18n-dom-bind" id="app">` element * * `<template is="i18n-dom-bind">` template element extends * `<template is="dom-bind">` template element with all the capabilities of * `I18nBehavior`. * * The `id` attribute value is used for naming bundle files instead of the element name. * * The bundle files are stored at the locales directory under the application root. * * ``` * /app.json * /locales/app.ja.json * /app.fr.json * /app.zh-Hans.json * ``` */ export const I18nControllerCoreMixin = { /* bundles = { "": {}, "en": { "my-list": { "p_2": "You now have:", "model": { "list": { "items": [ "item 1", "item 2" ] } } }, "google-chart-demo": { "simple-chart-desc": [ "template {1} string", "param 1" ] "model": { "simple-chart": { "options": { "title": "Simple Chart" }, "rows": [] } } } }, "ja": { "my-list": {}, "google-chart-demo": {} } } bundles[lang] /bundle.json - fallback /locales/bundle.en.json /bundle.ja.json /bundle.fr.json /bundle.zh-Hans.json bundles[lang][is] /elements/my-list/my-list.json - fallback /locales/my-list.en.json /my-list.ja.json /my-list.zh-Hans.json /google-chart-demo/google-chart-demo.json - fallback /locales/google-chart-demo.en.json /google-chart-demo.ja.json /google-chart-demo.zh-Hans.json app/elements/my-list/my-list.json /locales/my-list.fr.json /my-list.ja.json /my-list.zh-Hans.json dist/elements/my-list/my-list.json /locales/my-list.fr.json /my-list.ja.json /my-list.zh-Hans.json */ /** * The backend logic for `this.text` object. * * @param {string} lang Locale for the text message bundle. * @return {Object} Text message bundle for the locale. */ _getBundle: function (lang) { //console.log('_getBundle called for ' + this.is + ' with lang = ' + lang); var resolved; var id = this.constructor.is === 'i18n-dom-bind' ? this.id : this.is; if (lang && lang.length > 0) { var fallbackLanguageList = this._enumerateFallbackLanguages(lang); var tryLang; while ((tryLang = fallbackLanguageList.shift())) { if (!bundles[tryLang]) { // set up an empty bundle for the language if missing bundles[tryLang] = {}; } if (bundles[tryLang][id]) { // bundle found resolved = bundles[tryLang][id]; break; } } } else { // lang is not specified lang = ''; resolved = bundles[lang][id]; } // Fallback priorities: last > app default > element default > fallback > {} // TODO: need more research on fallback priorities if (!resolved) { if (this._fetchStatus && bundles[this._fetchStatus.lastLang] && bundles[this._fetchStatus.lastLang][id]) { // old bundle for now (no changes should be shown) resolved = bundles[this._fetchStatus.lastLang][id]; } else if (defaultLang && defaultLang.length > 0 && bundles[defaultLang] && bundles[defaultLang][id]) { // app default language for now resolved = bundles[defaultLang][id]; } else if (this.templateDefaultLang && this.templateDefaultLang.length > 0 && bundles[this.templateDefaultLang] && bundles[this.templateDefaultLang][id]) { // element default language for now resolved = bundles[this.templateDefaultLang][id]; } /* no more fallback should happen */ /* istanbul ignore else */ else if (bundles[''][id]) { // fallback language for now (this should be the same as element default) resolved = bundles[''][id]; } else { // give up providing a bundle (this should not happen) resolved = {}; } } return resolved; }, /** * Enumerate fallback locales for the target locale. * * Subset implementation of BCP47 (https://tools.ietf.org/html/bcp47). * * ### Examples: * *| Target Locale | Fallback 1 | Fallback 2 | Fallback 3 | *|:--------------|:-----------|:-----------|:-----------| *| ru | N/A | N/A | N/A | *| en-GB | en | N/A | N/A | *| en-Latn-GB | en-GB | en-Latn | en | *| fr-CA | fr | N/A | N/A | *| zh-Hans-CN | zh-Hans | zh | N/A | *| zh-CN | zh-Hans | zh | N/A | *| zh-TW | zh-Hant | zh | N/A | * * #### Note: * * For zh language, the script Hans or Hant is supplied as its default script when a country/region code is supplied. * * @param {string} lang Target locale. * @return {Array} List of fallback locales including the target locale at the index 0. */ _enumerateFallbackLanguages: function (lang) { var result = enumeratedFallbackLanguageCache.get(lang); if (result) { return [...result]; } result = []; var parts; var match; var isExtLangCode = 0; var extLangCode; var isScriptCode = 0; var scriptCode; var isCountryCode = 0; var countryCode; var n; if (!lang || lang.length === 0) { result.push(''); } else { parts = lang.split(/[-_]/); // normalize ISO-639-1 language codes if (parts.length > 0 && parts[0].match(/^[A-Za-z]{2,3}$/)) { // language codes have to be lowercased // e.g. JA -> ja, FR -> fr // TODO: normalize 3-letter codes to 2-letter codes parts[0] = parts[0].toLowerCase(); } // normalize ISO-639-3 extension language codes if (parts.length >= 2 && parts[1].match(/^[A-Za-z]{3}$/) && !parts[1].match(/^[Cc][Hh][SsTt]$/)) { // exclude CHS,CHT // extension language codes have to be lowercased // e.g. YUE -> yue isExtLangCode = 1; extLangCode = parts[1] = parts[1].toLowerCase(); } // normalize ISO-15924 script codes if (parts.length >= isExtLangCode + 2 && (match = parts[isExtLangCode + 1].match(/^([A-Za-z])([A-Za-z]{3})$/))) { // script codes have to be capitalized only at the first character // e.g. HANs -> Hans, lAtN -> Latn isScriptCode = 1; scriptCode = parts[isExtLangCode + 1] = match[1].toUpperCase() + match[2].toLowerCase(); } // normalize ISO-3166-1 country/region codes if (parts.length >= isExtLangCode + isScriptCode + 2 && (match = parts[isExtLangCode + isScriptCode + 1].match(/^[A-Za-z0-9]{2,3}$/))) { // country/region codes have to be capitalized // e.g. cn -> CN, jP -> JP isCountryCode = 1; countryCode = parts[isExtLangCode + isScriptCode + 1] = match[0].toUpperCase(); } // extensions have to be in lowercases // e.g. U-cA-Buddhist -> u-ca-buddhist, X-LiNux -> x-linux if (parts.length >= isExtLangCode + isScriptCode + isCountryCode + 2) { for (n = isExtLangCode + isScriptCode + isCountryCode + 1; n < parts.length; n++) { parts[n] = parts[n].toLowerCase(); } } // enumerate fallback languages while (parts.length > 0) { // normalize delimiters as - // e.g. ja_JP -> ja-JP result.push(parts.join('-')); if (isScriptCode && isCountryCode && parts.length == isExtLangCode + isScriptCode + 2) { // script code can be omitted to default // e.g. en-Latn-GB -> en-GB, zh-Hans-CN -> zh-CN parts.splice(isExtLangCode + isScriptCode, 1); result.push(parts.join('-')); parts.splice(isExtLangCode + isScriptCode, 0, scriptCode); } if (isExtLangCode && isCountryCode && parts.length == isExtLangCode + isScriptCode + 2) { // ext lang code can be omitted to default // e.g. zh-yue-Hans-CN -> zh-Hans-CN parts.splice(isExtLangCode, 1); result.push(parts.join('-')); parts.splice(isExtLangCode, 0, extLangCode); } if (isExtLangCode && isScriptCode && parts.length == isExtLangCode + isScriptCode + 1) { // ext lang code can be omitted to default // e.g. zh-yue-Hans -> zh-Hans parts.splice(isExtLangCode, 1); result.push(parts.join('-')); parts.splice(isExtLangCode, 0, extLangCode); } if (!isScriptCode && !isExtLangCode && isCountryCode && parts.length == 2) { // default script code can be added in certain cases with country codes // e.g. zh-CN -> zh-Hans-CN, zh-TW -> zh-Hant-TW switch (result[result.length - 1]) { case 'zh-CN': case 'zh-CHS': result.push('zh-Hans'); break; case 'zh-TW': case 'zh-SG': case 'zh-HK': case 'zh-CHT': result.push('zh-Hant'); break; default: break; } } parts.pop(); } } enumeratedFallbackLanguageCache.set(lang, [...result]); return result; }, /** * Observer of `this.lang` changes. * * Update `this.text` object if the text message bundle of the new `lang` is locally available. * * Trigger fetching of the text message bundle of the new `lang` if the bundle is not locally available. * * @param {string} lang New value of `lang`. * @param {string} oldLang Old value of `lang`. */ _langChanged: function (lang, oldLang) { //console.log(this.id + ':_langChanged lang = ' + lang + ' oldLang = ' + oldLang); var id = (this.is || this.getAttribute('is')) === 'i18n-dom-bind' ? this.id : this.is; lang = lang || ''; // undefined and null are treated as default '' oldLang = oldLang || ''; if (!this._fetchStatus) { this.constructor.prototype._fetchStatus = deepcopy({ // per custom element fetchingInstance: null, ajax: null, ajaxLang: null, lastLang: null, fallbackLanguageList: null, targetLang: null, lastResponse: {}, rawResponses: {} }); } if (lang !== oldLang && bundles[oldLang] && bundles[oldLang][id]) { this._fetchStatus.lastLang = oldLang; } if (bundles[lang] && bundles[lang][id]) { // bundle available for the new language if (this._fetchStatus && lang !== this._fetchStatus.ajaxLang) { // reset error status this._fetchStatus.error = null; } if (this.__data) { this.notifyPath('text', this._getBundle(this.lang)); } this.effectiveLang = lang; this.fire('lang-updated', { lang: this.lang, oldLang: oldLang, lastLang: this._fetchStatus.lastLang }); } else { // fetch the missing bundle this._fetchLanguage(lang); } }, /** * Trigger fetching of the appropriate text message bundle of the target locale. * * ### Two Layers of Fallbacks: * * 1. Missing bundles fall back to those of their fallback locales. * 1. Missing texts in the non-default bundles fall back to those in the default bundle. * * ### Fallback Examples: * *| Locale | Bundle Status | *|:------------|:---------------------------------| *| fr-CA | existent with sparse texts | *| fr | existent with full texts | *| ja | existent with some missing texts | *| zh-Hans-CN | missing | *| zh-Hans | existent with some missing texts | *| zh | missing | *| en | existent with full texts | *| ''(default) | existent with full texts | * *| Target | Fallback bundle | Resolved locale | *|:------------|:----------------------|:----------------| *| en | en | en | *| ja | ja + ''(default) | ja | *| fr-CA | fr-CA + fr | fr-CA | *| zh-Hans-CN | zh-Hans + ''(default) | zh-Hans | * * @param {string} lang Target locale. */ _fetchLanguage: function (lang) { if (this._fetchStatus) { this._fetchStatus.fallbackLanguageList = this._enumerateFallbackLanguages(lang); this._fetchStatus.fallbackLanguageList.push(''); this._fetchStatus.targetLang = this._fetchStatus.fallbackLanguageList.shift(); this._fetchBundle(this._fetchStatus.targetLang); } }, /** * Fetch the text message bundle of the target locale * cooperatively with other instances. * * @param {string} lang Target locale. */ _fetchBundle: function (lang) { //console.log('_fetchBundle lang = ' + lang); var id = this.is === 'i18n-dom-bind' || this.constructor.is === 'i18n-dom-bind' ? this.id : this.is; if (!lang || lang.length === 0) { // handle empty cases if (defaultLang && defaultLang.length > 0 && bundles[defaultLang] && bundles[defaultLang][id]) { lang = defaultLang; // app default language } else if (this.templateDefaultLang && this.templateDefaultLang.length > 0) { lang = this.templateDefaultLang; // element default language } else { lang = ''; // fallback default language } } // set up an empty bundle if inexistent bundles[lang] = bundles[lang] || {}; if (bundles[lang][id]) { // bundle is available; no need to fetch if (this._fetchStatus.targetLang === lang) { // reset error status this._fetchStatus.error = null; if (this.lang === lang) { this.notifyPath('text', this._getBundle(this.lang)); this.fire('lang-updated', { lang: this.lang, lastLang: this._fetchStatus.lastLang }); } else { this.lang = lang; // trigger lang-updated event } } else { var nextFallbackLanguage = this._fetchStatus.fallbackLanguageList.shift(); // bundle is available; no need to fetch this._fetchStatus.fetchingInstance = null; if (nextFallbackLanguage) { this._fetchBundle(nextFallbackLanguage); } else { this._constructBundle(this._fetchStatus.targetLang); // reset error status this._fetchStatus.error = null; if (this.lang === this._fetchStatus.targetLang) { this.notifyPath('text', this._getBundle(this.lang)); this.fire('lang-updated', { lang: this.lang, lastLang: this._fetchStatus.lastLang }); } else { this.lang = this._fetchStatus.targetLang; // trigger lang-updated event } } } } else if (this._fetchStatus.fetchingInstance) { if (this._fetchStatus.fetchingInstance !== this) { // fetching in progress by another instance // TODO: redundant addEventListener multiple times this._forwardLangEventBindThis = this._forwardLangEventBindThis || this._forwardLangEvent.bind(this); this._fetchStatus.fetchingInstance .addEventListener('lang-updated', this._forwardLangEventBindThis); } } else if (bundleFetchingInstances[lang]) { // fetching bundle.lang.json in progress by an instance of another element this._fetchStatus.fetchingInstance = this; this._fetchStatus.ajaxLang = lang; this._handleBundleFetchedBindThis = this._handleBundleFetchedBindThis || this._handleBundleFetched.bind(this); bundleFetchingInstances[lang] .addEventListener('bundle-fetched', this._handleBundleFetchedBindThis); //console.log(this.is + ' addEventListener bundle-fetched'); } else { // proceed to fetch this._fetchStatus.fetchingInstance = this; if (!this._fetchStatus.ajax) { // set up ajax client this._fetchStatus.ajax = new XMLHttpRequest(); this._fetchStatus.ajax[ isXhrNativeJsonResponseType ? 'responseType' : '_responseType' ] = 'json'; this._fetchStatus._handleResponseBindFetchingInstance = this._handleResponse.bind(this); this._fetchStatus._handleErrorBindFetchingInstance = this._handleError.bind(this); this._fetchStatus.ajax.addEventListener('load', this._fetchStatus._handleResponseBindFetchingInstance); this._fetchStatus.ajax.addEventListener('error', this._fetchStatus._handleErrorBindFetchingInstance); } else { if (this._fetchStatus._handleResponseBindFetchingInstance) { this._fetchStatus.ajax.removeEventListener('load', this._fetchStatus._handleResponseBindFetchingInstance); } if (this._fetchStatus._handleErrorBindFetchingInstance) { this._fetchStatus.ajax.removeEventListener('error', this._fetchStatus._handleErrorBindFetchingInstance); } this._fetchStatus._handleResponseBindFetchingInstance = this._handleResponse.bind(this); this._fetchStatus._handleErrorBindFetchingInstance = this._handleError.bind(this); this._fetchStatus.ajax.addEventListener('load', this._fetchStatus._handleResponseBindFetchingInstance); this._fetchStatus.ajax.addEventListener('error', this._fetchStatus._handleErrorBindFetchingInstance); } // TODO: app global bundles have to be handled var url; var skipFetching = false; var importBaseURI = this.constructor.importMeta ? this.constructor.importMeta.url : location.href; if (lang === '') { url = this.resolveUrl(id + '.json', importBaseURI); } else { if (bundles[lang] && bundles[lang].bundle) { // missing in the bundle url = this.resolveUrl(localesPath + '/' + id + '.' + lang + '.json', importBaseURI); skipFetching = !!this.isI18nController; } else { // fetch the bundle bundleFetchingInstances[lang] = this; url = this.resolveUrl(startUrl + localesPath + '/bundle.' + lang + '.json', importBaseURI); } } this._fetchStatus.ajax.url = url; this._fetchStatus.ajaxLang = lang; try { this._fetchStatus.error = null; if (skipFetching) { this._handleError({ detail: { error: 'skip fetching for I18nController' }}); } else { this._fetchStatus.ajax.open('GET', this._fetchStatus.ajax.url); this._fetchStatus.ajax.setRequestHeader('Accept', 'application/json'); this._fetchStatus.ajax.send(); } } catch (e) { if (this._fetchStatus.ajax.readyState !== 0 /* UNSENT */ && this._fetchStatus.ajax.readyState !== 4 /* DONE */) { this._fetchStatus.ajax.onabort = function onAbort(event) { this._fetchStatus.ajax.onabort = null; this._handleError({ detail: { error: 'ajax request failed: ' + e }}); }.bind(this); this._fetchStatus.ajax.abort(); } else { // TODO: extract error message from the exception e this._handleError({ detail: { error: 'ajax request failed: ' + e }}); } } } }, /** * Handles Ajax load event for a bundle * * @param {Object} event XMLHttpRequest `load` event. */ _handleResponse: function (event) { //console.log('_handleResponse ajaxLang = ' + this._fetchStatus.ajaxLang); let response; if (this._fetchStatus.ajax.status >= 200 && this._fetchStatus.ajax.status < 300) { if (isXhrNativeJsonResponseType) { response = this._fetchStatus.ajax.response; } else { try { response = JSON.parse(this._fetchStatus.ajax.responseText); } catch (e) { response = null; } } } else { // Typically HTTP 404 event.detail = event.detail || {}; event.detail.error = this._fetchStatus.ajax.status + ' ' + this._fetchStatus.ajax.statusText + ' for ' + this._fetchStatus.ajax.url; this._handleError(event); // Forwarding to _handleError() return; } if (this._fetchStatus.ajax.url.indexOf('/' + localesPath + '/bundle.') >= 0) { bundles[this._fetchStatus.ajaxLang] = bundles[this._fetchStatus.ajaxLang] || {}; this._deepMap(bundles[this._fetchStatus.ajaxLang], response, function (text) { return text; }); bundles[this._fetchStatus.ajaxLang].bundle = true; bundleFetchingInstances[this._fetchStatus.ajaxLang] = null; //console.log('bundle-fetched ' + this.is + ' ' + this._fetchStatus.ajaxLang); this.fire('bundle-fetched', { success: true, lang: this._fetchStatus.ajaxLang }); var id = this.is === 'i18n-dom-bind' ? this.id : this.is; if (bundles[this._fetchStatus.ajaxLang][id]) { this._fetchStatus.lastResponse = bundles[this._fetchStatus.ajaxLang][id]; } else { // bundle does not contain text for this.is this._fetchStatus.fetchingInstance = null; this._fetchBundle(this._fetchStatus.ajaxLang); return; } } else { this._fetchStatus.lastResponse = response; } if (this._fetchStatus.lastResponse) { var nextFallbackLanguage = this._fetchStatus.fallbackLanguageList.shift(); // store the raw response this._fetchStatus.rawResponses[this._fetchStatus.ajaxLang] = this._fetchStatus.lastResponse; this._fetchStatus.fetchingInstance = null; if (nextFallbackLanguage) { this._fetchBundle(nextFallbackLanguage); } else { this._fetchBundle(''); } } else { event.detail = event.detail || {}; event.detail.error = 'empty response for ' + this._fetchStatus.ajax.url; this._handleError(event); } }, /** * Handles Ajax error event or forwarded load event for a bundle. * * @param {Object} event `error` event or forwarded `load` event */ _handleError: function (event) { event.detail = event.detail || {}; event.detail.error = event.detail.error || this._fetchStatus.ajax.statusText; var nextFallbackLanguage; this._fetchStatus.fetchingInstance = null; if (this._fetchStatus.ajax.url.indexOf('/' + localesPath + '/bundle.') >= 0) { bundles[this._fetchStatus.ajaxLang] = bundles[this._fetchStatus.ajaxLang] || {}; bundles[this._fetchStatus.ajaxLang].bundle = true; bundleFetchingInstances[this._fetchStatus.ajaxLang] = null; // falls back to its element-specific bundle this._fetchBundle(this._fetchStatus.ajaxLang); //console.log('bundle-fetched ' + this.is + ' ' + this._fetchStatus.ajaxLang); this.fire('bundle-fetched', { success: false, lang: this._fetchStatus.ajaxLang }); return; } nextFallbackLanguage = this._fetchStatus.fallbackLanguageList.shift(); if (this._fetchStatus.ajaxLang === this._fetchStatus.targetLang) { if (nextFallbackLanguage) { //console.log(this.is + ': ' + this._fetchStatus.ajaxLang + // ' falls back to ' + nextFallbackLanguage); this._fetchStatus.targetLang = nextFallbackLanguage; this._fetchBundle(nextFallbackLanguage); } else { this._fetchStatus.error = event.detail.error; //console.log(this._fetchStatus.error); // falls back to default this.lang = ''; } } else { // fetching dependent fallback languages if (nextFallbackLanguage) { //console.log(this.is + ': ' + this._fetchStatus.ajaxLang + // ' is missing and skipped'); //console.log(this.is + ': step to the next dependent fallback ' + // nextFallbackLanguage); this._fetchBundle(nextFallbackLanguage); } else { this._fetchBundle(''); } } }, /** * Forward `lang-updated` event to other instances of the same element. * * @param {Object} event `lang-updated` event object. */ _forwardLangEvent: function (event) { //console.log('_forwardLangEvent ' + this.is + ' ' + event.detail.lang); event.target.removeEventListener(event.type, this._forwardLangEventBindThis); if (this.lang === event.detail.lang) { this.notifyPath('text', this._getBundle(this.lang)); this.fire(event.type, event.detail); } else { this.lang = event.detail.lang; this.notifyPath('text', this._getBundle(this.lang)); } }, /** * Handle `bundle-fetched` event. * * @param {Object} event `bundle-fetched` event object. */ _handleBundleFetched: function (event) { var detail = event.detail; //console.log('_handleBundleFetched ' + this.is + ' ' + detail.lang); event.target.removeEventListener(event.type, this._handleBundleFetchedBindThis); if (this._fetchStatus.ajaxLang === detail.lang) { this._fetchStatus.fetchingInstance = null; this._fetchBundle(this._fetchStatus.ajaxLang); } }, /** * Construct the text message bundle of the target locale with fallback of missing texts. * * @param {strings} lang Target locale. */ _constructBundle: function (lang) { var fallbackLanguageList = this._enumerateFallbackLanguages(lang); var bundle = {}; var raw; var baseLang; var id = this.is === 'i18n-dom-bind' ? this.id : this.is; var i; fallbackLanguageList.push(''); for (i = 0; i < fallbackLanguageList.length; i++) { if (bundles[fallbackLanguageList[i]] && bundles[fallbackLanguageList[i]][id]) { break; } } fallbackLanguageList.splice(i + 1, fallbackLanguageList.length); while ((baseLang = fallbackLanguageList.pop()) !== undefined) { if (bundles[baseLang][id]) { bundle = deepcopy(bundles[baseLang][id]); } else { raw = this._fetchStatus.rawResponses[baseLang]; if (raw) { this._deepMap(bundle, raw, function (text) { return text; }); } } } // store the constructed bundle if (!bundles[lang]) { bundles[lang] = {}; } bundles[lang][id] = bundle; }, /** * Recursively map the source object onto the target object with the specified map function. * * The method is used to merge a bundle into its fallback bundle. * * @param {Object} target Target object. * @param {Object} source Source object. * @param {Function} map Mapping function. */ _deepMap: function (target, source, map) { var value; for (var prop in source) { value = source[prop]; switch (typeof value) { case 'string': case 'number': case 'boolean': if (typeof target === 'object') { target[prop] = map(value, prop); } break; case 'object': if (typeof target === 'object') { if (Array.isArray(value)) { // TODO: cannot handle deep objects properly target[prop] = target[prop] || []; this._deepMap(target[prop], value, map); } else { target[prop] = target[prop] || {}; this._deepMap(target[prop], value, map); } } break; default: if (typeof target === 'object') { target[prop] = value; } break; } } }, /** * Return the first non-null argument. * * Utility method for use in annotations. * * ### Example Usage: * ``` * <input is="iron-input" class="flex" * type="search" id="query" bind-value="{{query}}" * autocomplete="off" * placeholder="{{or(placeholder,text.search)}}"> * ``` * * @param {*} arguments List of arguments. */ or: function () { var result = arguments[0]; var i = 1; while (!result && i < arguments.length) { result = arguments[i++]; } return result; }, /** * Translate a string by a message table. * * Utility method for use in annotations. * * ### Example Usage: * ``` * <span>{{tr(status,text.statusMsgs)}}</span> * <span>{{tr(errorId,text)}}</span> * <template> * <json-data text-id="statusMsgs">{ * "signed-in": "Authenticated", * "signed-out": "Not Authenticated", * "error": "Error in Authentication", * "default": "Unknown Status in Authentication" * }</json-data> * <span text-id="http-404">File Not Found</span> * <span text-id="http-301">Moved Permanently</span> * </template> * ``` * * Note: The second `table` parameter should always be specified in order * to trigger automatic updates on `this.text` mutations, i.e., updates of `this.effectiveLang`. * * @param {string} key Key of the message. * @param {Object} table The message table object or this.text itself if omitted * @return {string} Translated string, `table.default` if `table[key]` is undefined, or key string if table.default is undefined. */ tr: function (key, table) { if (table) { if (typeof table === 'object') { if (typeof table[key] !== 'undefined') { return table[key]; } else if (typeof table['default'] !== 'undefined') { return table['default']; } else { return key; } } else { return key; } } else { return (typeof this.text === 'object') && (typeof key !== 'undefined') && (typeof this.text[key] !== 'undefined') ? this.text[key] : key; } }, /** * Format a parameterized string. * * Utility method for use in annotations. * * ### Example Usage: * ``` * <span attr="{{i18nFormat(text.param.0,text.textparam1,text.textparam2)}}"></span> * <template> * <json-data text-id="param">[ * "String with {1} and {2} are formetted", * "[[text.textparam1]]", * "[[text.textparam2]]" * ]</json-data> * <span text-id="textparam1">Parameter 1</span> * <span text-id="textparam2">Parameter 2</span> * </template> * ``` * * Notes: * - Compound bindings in attributes are automatically converted to {{i18nFormat()}} in preprocessing. * - Cached i18n formatter functions are constructed as follows * ``` * // formatter function for "format string with params {1} and {2}." * function anonymous (a) { return `format string with params ${a[1]} and ${a[2]}.`; } * function anonymous (a) { return 'format string with params ' + a[1] + ' and ' + a[2] + '.'; } // for IE 11 * ``` * - Backslashes, backquotes, and dollars in format strings are escaped * * @param {*} arguments List of arguments. * @return {string} Formatted string */ i18nFormat: function () { let formatted = ''; if (arguments.length > 0) { let formatter = i18nFormatterCache.get(arguments[0]); if (!formatter) { // interpreter for the first rendering formatted = arguments[0] || ''; for (var n = 1; n < arguments.length; n++) { formatted = formatted.replace('{' + n + '}', arguments[n]); } i18nFormatterCache.set(arguments[0], 1); } else { if (formatter === 1) { // compiler for the second rendering let template = arguments[0] || ''; if (isTemplateLiteralSupported) { template = template.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/[$]/g, '\\$'); // escape special characters for template literals for (let n = 1; n < arguments.length; n++) { template = template.replace('{' + n + '}', '${a[' + n + ']}'); // replace parameters with corresponding values of arguments } formatter = new Function('a', 'return `' + template + '`;'); // convert to a formetter function with a template literal } else { // IE 11 without native template literal support let strings = template.split(/{[1-9][0-9]*}/).map(s => '\'' + s.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n').replace(/\t/g, '\\t') + '\''); let params = (template.match(/{[1-9][0-9]*}/g) || []).map(p => p.replace(/^{([1-9][0-9]*)}$/, 'a[$1]')); let merged = []; let i; for (i = 0; i < params.length; i++) { merged.push(strings[i]); merged.push(params[i]); } merged.push(strings[i]); formatter = new Function('a', 'return ' + merged.join(' + ') + ';'); // convert to a formatter function with string concatenation } i18nFormatterCache.set(arguments[0], formatter); } formatted = formatter(arguments); } } return formatted; } };