import {Func} from "mocha"; type PluralFunc = (n: number) => string export class Html10n { public language?: string private rtl: string[] private _pluralRules?: PluralFunc public mt: MicroEvent private loader: Loader | undefined public translations: Map private macros: Map constructor() { this.language = undefined this.rtl = ["ar","dv","fa","ha","he","ks","ku","ps","ur","yi"] this.mt = new MicroEvent() this.translations = new Map() this.macros = new Map() this.macros.set('plural', (_key: string, param:string, opts: any)=>{ let str , n = parseFloat(param); if (isNaN(n)) return; // initialize _pluralRules if (this._pluralRules === undefined) { this._pluralRules = this.getPluralRules(this.language!); } let index = this._pluralRules!(n); // try to find a [zero|one|two] key if it's defined if (n === 0 && ('zero') in opts) { str = opts['zero']; } else if (n == 1 && ('one') in opts) { str = opts['one']; } else if (n == 2 && ('two') in opts) { str = opts['two']; } else if (index in opts) { str = opts[index]; } return str; }) document.addEventListener('DOMContentLoaded', ()=> { this.index() }, false) } bind(event: string, fct: Func) { this.mt.bind(event, fct) } /** * Get rules for plural forms (shared with JetPack), see: * http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html * https://github.com/mozilla/addon-sdk/blob/master/python-lib/plural-rules-generator.p * * @param {string} lang * locale (language) used. * * @return {PluralFunc} * returns a function that gives the plural form name for a given integer: * var fun = getPluralRules('en'); * fun(1) -> 'one' * fun(0) -> 'other' * fun(1000) -> 'other'. */ getPluralRules(lang: string): PluralFunc { const locales2rules = new Map([ ['af', 3], ['ak', 4], ['am', 4], ['ar', 1], ['asa', 3], ['az', 0], ['be', 11], ['bem', 3], ['bez', 3], ['bg', 3], ['bh', 4], ['bm', 0], ['bn', 3], ['bo', 0], ['br', 20], ['brx', 3], ['bs', 11], ['ca', 3], ['cgg', 3], ['chr', 3], ['cs', 12], ['cy', 17], ['da', 3], ['de', 3], ['dv', 3], ['dz', 0], ['ee', 3], ['el', 3], ['en', 3], ['eo', 3], ['es', 3], ['et', 3], ['eu', 3], ['fa', 0], ['ff', 5], ['fi', 3], ['fil', 4], ['fo', 3], ['fr', 5], ['fur', 3], ['fy', 3], ['ga', 8], ['gd', 24], ['gl', 3], ['gsw', 3], ['gu', 3], ['guw', 4], ['gv', 23], ['ha', 3], ['haw', 3], ['he', 2], ['hi', 4], ['hr', 11], ['hu', 0], ['id', 0], ['ig', 0], ['ii', 0], ['is', 3], ['it', 3], ['iu', 7], ['ja', 0], ['jmc', 3], ['jv', 0], ['ka', 0], ['kab', 5], ['kaj', 3], ['kcg', 3], ['kde', 0], ['kea', 0], ['kk', 3], ['kl', 3], ['km', 0], ['kn', 0], ['ko', 0], ['ksb', 3], ['ksh', 21], ['ku', 3], ['kw', 7], ['lag', 18], ['lb', 3], ['lg', 3], ['ln', 4], ['lo', 0], ['lt', 10], ['lv', 6], ['mas', 3], ['mg', 4], ['mk', 16], ['ml', 3], ['mn', 3], ['mo', 9], ['mr', 3], ['ms', 0], ['mt', 15], ['my', 0], ['nah', 3], ['naq', 7], ['nb', 3], ['nd', 3], ['ne', 3], ['nl', 3], ['nn', 3], ['no', 3], ['nr', 3], ['nso', 4], ['ny', 3], ['nyn', 3], ['om', 3], ['or', 3], ['pa', 3], ['pap', 3], ['pl', 13], ['ps', 3], ['pt', 3], ['rm', 3], ['ro', 9], ['rof', 3], ['ru', 11], ['rwk', 3], ['sah', 0], ['saq', 3], ['se', 7], ['seh', 3], ['ses', 0], ['sg', 0], ['sh', 11], ['shi', 19], ['sk', 12], ['sl', 14], ['sma', 7], ['smi', 7], ['smj', 7], ['smn', 7], ['sms', 7], ['sn', 3], ['so', 3], ['sq', 3], ['sr', 11], ['ss', 3], ['ssy', 3], ['st', 3], ['sv', 3], ['sw', 3], ['syr', 3], ['ta', 3], ['te', 3], ['teo', 3], ['th', 0], ['ti', 4], ['tig', 3], ['tk', 3], ['tl', 4], ['tn', 3], ['to', 0], ['tr', 0], ['ts', 3], ['tzm', 22], ['uk', 11], ['ur', 3], ['ve', 3], ['vi', 0], ['vun', 3], ['wa', 4], ['wae', 3], ['wo', 0], ['xh', 3], ['xog', 3], ['yo', 0], ['zh', 0], ['zu', 3] ]) function isIn(n: number, list: number[]) { return list.indexOf(n) !== -1; } function isBetween(n: number, start: number, end: number) { return start <= n && n <= end; } type PluralFunc = (n: number) => string const pluralRules: { [key: string]: PluralFunc } = { '0': function() { return 'other'; }, '1': function(n: number) { if ((isBetween((n % 100), 3, 10))) return 'few'; if (n === 0) return 'zero'; if ((isBetween((n % 100), 11, 99))) return 'many'; if (n == 2) return 'two'; if (n == 1) return 'one'; return 'other'; }, '2': function(n: number) { if (n !== 0 && (n % 10) === 0) return 'many'; if (n == 2) return 'two'; if (n == 1) return 'one'; return 'other'; }, '3': function(n: number) { if (n == 1) return 'one'; return 'other'; }, '4': function(n: number) { if ((isBetween(n, 0, 1))) return 'one'; return 'other'; }, '5': function(n: number) { if ((isBetween(n, 0, 2)) && n != 2) return 'one'; return 'other'; }, '6': function(n: number) { if (n === 0) return 'zero'; if ((n % 10) == 1 && (n % 100) != 11) return 'one'; return 'other'; }, '7': function(n: number) { if (n == 2) return 'two'; if (n == 1) return 'one'; return 'other'; }, '8': function(n: number) { if ((isBetween(n, 3, 6))) return 'few'; if ((isBetween(n, 7, 10))) return 'many'; if (n == 2) return 'two'; if (n == 1) return 'one'; return 'other'; }, '9': function(n: number) { if (n === 0 || n != 1 && (isBetween((n % 100), 1, 19))) return 'few'; if (n == 1) return 'one'; return 'other'; }, '10': function(n: number) { if ((isBetween((n % 10), 2, 9)) && !(isBetween((n % 100), 11, 19))) return 'few'; if ((n % 10) == 1 && !(isBetween((n % 100), 11, 19))) return 'one'; return 'other'; }, '11': function(n: number) { if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14))) return 'few'; if ((n % 10) === 0 || (isBetween((n % 10), 5, 9)) || (isBetween((n % 100), 11, 14))) return 'many'; if ((n % 10) == 1 && (n % 100) != 11) return 'one'; return 'other'; }, '12': function(n: number) { if ((isBetween(n, 2, 4))) return 'few'; if (n == 1) return 'one'; return 'other'; }, '13': function(n: number) { if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14))) return 'few'; if (n != 1 && (isBetween((n % 10), 0, 1)) || (isBetween((n % 10), 5, 9)) || (isBetween((n % 100), 12, 14))) return 'many'; if (n == 1) return 'one'; return 'other'; }, '14': function(n: number) { if ((isBetween((n % 100), 3, 4))) return 'few'; if ((n % 100) == 2) return 'two'; if ((n % 100) == 1) return 'one'; return 'other'; }, '15': function(n: number) { if (n === 0 || (isBetween((n % 100), 2, 10))) return 'few'; if ((isBetween((n % 100), 11, 19))) return 'many'; if (n == 1) return 'one'; return 'other'; }, '16': function(n: number) { if ((n % 10) == 1 && n != 11) return 'one'; return 'other'; }, '17': function(n: number) { if (n == 3) return 'few'; if (n === 0) return 'zero'; if (n == 6) return 'many'; if (n == 2) return 'two'; if (n == 1) return 'one'; return 'other'; }, '18': function(n: number) { if (n === 0) return 'zero'; if ((isBetween(n, 0, 2)) && n !== 0 && n != 2) return 'one'; return 'other'; }, '19': function(n: number) { if ((isBetween(n, 2, 10))) return 'few'; if ((isBetween(n, 0, 1))) return 'one'; return 'other'; }, '20': function(n: number) { if ((isBetween((n % 10), 3, 4) || ((n % 10) == 9)) && !( isBetween((n % 100), 10, 19) || isBetween((n % 100), 70, 79) || isBetween((n % 100), 90, 99) )) return 'few'; if ((n % 1000000) === 0 && n !== 0) return 'many'; if ((n % 10) == 2 && !isIn((n % 100), [12, 72, 92])) return 'two'; if ((n % 10) == 1 && !isIn((n % 100), [11, 71, 91])) return 'one'; return 'other'; }, '21': function(n: number) { if (n === 0) return 'zero'; if (n == 1) return 'one'; return 'other'; }, '22': function(n: number) { if ((isBetween(n, 0, 1)) || (isBetween(n, 11, 99))) return 'one'; return 'other'; }, '23': function(n: number) { if ((isBetween((n % 10), 1, 2)) || (n % 20) === 0) return 'one'; return 'other'; }, '24': function(n: number) { if ((isBetween(n, 3, 10) || isBetween(n, 13, 19))) return 'few'; if (isIn(n, [2, 12])) return 'two'; if (isIn(n, [1, 11])) return 'one'; return 'other'; } }; const index = locales2rules.get(lang.replace(/-.*$/, '')); // @ts-ignore if (!(index in pluralRules)) { console.warn('plural form unknown for [' + lang + ']'); return function() { return 'other'; }; } // @ts-ignore return pluralRules[index]; } getTranslatableChildren(element: HTMLElement) { return element.querySelectorAll('*[data-l10n-id]') } localize(langs: (string|undefined)[]|string) { console.log('Available langs ', langs) if ('string' === typeof langs) { langs = [langs]; } let i = 0 langs.forEach((lang) => { if(!lang) return; langs[i++] = lang; if(~lang.indexOf('-')) langs[i++] = lang.substring(0, lang.indexOf('-')); }) this.build(langs, (er: null, translations: Map) =>{ this.translations = translations this.translateElement(translations) this.mt.trigger('localized') }) } /** * Triggers the translation process * for an element * @param translations A hash of all translation strings * @param element A DOM element, if omitted, the document element will be used */ translateElement(translations: Map, element?: HTMLElement) { element = element || document.documentElement const children = element ? this.getTranslatableChildren(element): document.childNodes for (let child of children) { this.translateNode(translations, child as HTMLElement) } // translate element itself if necessary this.translateNode(translations, element) } asyncForEach(list: (string|undefined)[], iterator: any, cb: Function) { let i = 0 , n = list.length iterator(list[i], i, function each(err?: string) { if(err) console.error(err) i++ if (i < n) return iterator(list[i],i, each); cb() }) } /** * Builds a translation object from a list of langs (loads the necessary translations) * @param langs Array - a list of langs sorted by priority (default langs should go last) * @param cb Function - a callback that will be called once all langs have been loaded */ build(langs: (string|undefined)[], cb: Function) { const build = new Map() this.asyncForEach(langs, (lang: string, _i: number, next:LoaderFunc)=> { if(!lang) return next(); this.loader!.load(lang, next) }, () =>{ let lang; langs.reverse() // loop through the priority array... for (let i=0, n=langs.length; i < n; i++) { lang = langs[i] if(!lang) continue; if(!(lang in langs)) {// uh, we don't have this lang availbable.. // then check for related langs if(~lang.indexOf('-') != -1) { lang = lang.split('-')[0]; } let l: string|undefined = '' for(l of langs) { if(l && lang != l && l.indexOf(lang) === 0) { lang = l break; } } // @ts-ignore if(lang != l) continue; } // ... and apply all strings of the current lang in the list // to our build object //lang = "de" if (this.loader!.langs.has(lang)) { for (let string in this.loader!.langs.get(lang)) { build.set(string,this.loader!.langs.get(lang)[string]) } this.language = lang } else { const loaderLang = lang.split('-')[0] for (let string in this.loader!.langs.get(loaderLang)) { build.set(string,this.loader!.langs.get(loaderLang)[string]) } this.language = loaderLang } // the last applied lang will be exposed as the // lang the page was translated to } cb(null, build) }) } /** * Returns the language that was last applied to the translations hash * thus overriding most of the formerly applied langs */ getLanguage() { return this.language } /** * Returns the direction of the language returned be html10n#getLanguage */ getDirection() { if(!this.language) return const langCode = this.language.indexOf('-') == -1? this.language : this.language.substring(0, this.language.indexOf('-')) return this.rtl.indexOf(langCode) == -1? 'ltr' : 'rtl' } /** * Index all s */ index() { // Find all s const links = document.getElementsByTagName('link') , resources = [] for (let i=0, n=links.length; i < n; i++) { if (links[i].type != 'application/l10n+json') continue; resources.push(links[i].href) } this.loader = new Loader(resources) this.mt.trigger('indexed') } translateNode(translations: Map, node: HTMLElement) { const str: { id?: string, args?: any, str?: string } = {} // get id str.id = node.getAttribute('data-l10n-id') as string if (!str.id) return if(!translations.get(str.id)) return console.warn('Couldn\'t find translation key '+str.id) // get args if(window.JSON) { str.args = JSON.parse(node.getAttribute('data-l10n-args') as string) }else{ try{ //str.args = eval(node.getAttribute('data-l10n-args') as string) console.error("Old eval method invoked!!") }catch(e) { console.warn('Couldn\'t parse args for '+str.id) } } str.str = this.get(str.id, str.args) // get attribute name to apply str to let prop , index = str.id.lastIndexOf('.') , attrList = // allowed attributes { "title": 1 , "innerHTML": 1 , "alt": 1 , "textContent": 1 , "value": 1 , "placeholder": 1 } if (index > 0 && str.id.substring(index + 1) in attrList) { // an attribute has been specified (example: "my_translation_key.placeholder") prop = str.id.substring(index + 1) } else { // no attribute: assuming text content by default prop = document.body.textContent ? 'textContent' : 'innerText' } // Apply translation if (node.children.length === 0 || prop != 'textContent') { // @ts-ignore node[prop] = str.str! node.setAttribute("aria-label", str.str!); // Sets the aria-label // The idea of the above is that we always have an aria value // This might be a bit of an abrupt solution but let's see how it goes } else { let children = node.childNodes, found = false let i = 0, n = children.length; for (; i < n; i++) { if (children[i].nodeType === 3 && /\S/.test(children[i].textContent!)) { if (!found) { children[i].nodeValue = str.str! found = true } else { children[i].nodeValue = '' } } } if (!found) { console.warn('Unexpected error: could not translate element content for key '+str.id, node) } } } get(id: string, args?:any) { let translations = this.translations if(!translations) return console.warn('No translations available (yet)') if(!translations.get(id)) return console.warn('Could not find string '+id) // apply macros let str = translations.get(id) str = this.substMacros(id, str, args) // apply args str = this.substArguments(str, args) return str } substMacros(key: string, str:string, args:any) { let regex = /\{\[\s*([a-zA-Z]+)\(([a-zA-Z]+)\)((\s*([a-zA-Z]+)\: ?([ a-zA-Z{}]+),?)+)*\s*\]\}/ //.exec('{[ plural(n) other: are {{n}}, one: is ]}') , match while(match = regex.exec(str)) { // a macro has been found // Note: at the moment, only one parameter is supported let macroName = match[1] , paramName = match[2] , optv = match[3] , opts: {[key:string]:any} = {} if (!(this.macros.has(macroName))) continue if(optv) { optv.match(/(?=\s*)([a-zA-Z]+)\: ?([ a-zA-Z{}]+)(?=,?)/g)!.forEach(function(arg) { const parts = arg.split(':') , name = parts[0]; opts[name] = parts[1].trim() }) } let param if (args && paramName in args) { param = args[paramName] } else if (paramName in this.translations) { param = this.translations.get(paramName) } // there's no macro parser: it has to be defined in html10n.macros let macro = this.macros.get(macroName)! str = str.substring(0, match.index) + macro(key, param, opts) + str.substring(match.index+match[0].length) } return str } substArguments(str: string, args:any) { let reArgs = /\{\{\s*([a-zA-Z\.]+)\s*\}\}/ , match let translations = this.translations; while (match = reArgs.exec(str)) { if (!match || match.length < 2) return str // argument key not found let arg = match[1] , sub = '' if (args && arg in args) { sub = args[arg] } else if (translations && arg in translations) { sub = translations.get(arg) } else { console.warn('Could not find argument {{' + arg + '}}') return str } str = str.substring(0, match.index) + sub + str.substring(match.index + match[0].length) } return str } } class MicroEvent { private events: Map constructor() { this.events = new Map(); } bind(event: string, fct: Func) { if (this.events.get(event) === undefined) { this.events.set(event, []); } this.events.get(event)!.push(fct); } unbind(event: string, fct: Func) { if (this.events.get(event) === undefined) { return; } const index = this.events.get(event)!.indexOf(fct); if (index !== -1) { this.events.get(event)!.splice(index, 1); } } trigger(event: string, ...args: any[]) { if (this.events.get(event) === undefined) { return; } for (const fct of this.events.get(event)!) { fct(...args); } } mixin(destObject: any) { const props = ['bind', 'unbind', 'trigger']; if (destObject !== undefined) { for (const prop of props) { // @ts-ignore destObject[prop] = this[prop]; } } } } type LoaderFunc = () => void type ErrorFunc = (data?:any)=>void class Loader { private resources: any private cache: Map langs: Map constructor(resources: any) { this.resources = resources; this.cache = new Map(); this.langs = new Map(); } load(lang: string, callback: LoaderFunc) { if (this.langs.get(lang) !== undefined) { callback(); return; } if (this.resources.length > 0) { let reqs = 0 for (const resource of this.resources) { this.fetch(resource, lang, (e)=> { reqs++; if (e) console.warn(e) if (reqs < this.resources.length) return;// Call back once all reqs are completed callback && callback() }) } } } fetch(href: string, lang: string, callback: ErrorFunc) { if (this.cache.get(href)) { this.parse(lang, href, this.cache.get(href), callback) return; } const xhr = new XMLHttpRequest(); xhr.open('GET', href, /*async: */true) if (xhr.overrideMimeType) { xhr.overrideMimeType('application/json; charset=utf-8'); } xhr.onreadystatechange = ()=> { if (xhr.readyState == 4) { if (xhr.status == 200 || xhr.status === 0) { const data = JSON.parse(xhr.responseText); this.cache.set(href, data) // Pass on the contents for parsing this.parse(lang, href, data, callback) } else { callback(new Error('Failed to load '+href)) } } }; xhr.send(null); } parse(lang: string, href: string, data: { [key: string]: string }, callback: ErrorFunc) { if ('object' !== typeof data) { callback(new Error('A file couldn\'t be parsed as json.')) return } function getBcp47LangCode(browserLang: string) { const bcp47Lang = browserLang.toLowerCase(); // Browser => BCP 47 const langCodeMap = new Map([ ['zh-cn', 'zh-hans-cn'], ['zh-hk', 'zh-hant-hk'], ['zh-mo', 'zh-hant-mo'], ['zh-my', 'zh-hans-my'], ['zh-sg', 'zh-hans-sg'], ['zh-tw', 'zh-hant-tw'], ]) return langCodeMap.get(bcp47Lang) ?? bcp47Lang; } // Issue #6129: Fix exceptions // NOTE: translatewiki.net use all lowercase form by default ('en-gb' insted of 'en-GB') function getJsonLangCode(bcp47Lang: string) { const jsonLang = bcp47Lang.toLowerCase(); // BCP 47 => JSON const langCodeMap = new Map([ ['sr-ec', 'sr-cyrl'], ['sr-el', 'sr-latn'], ['zh-hk', 'zh-hant-hk'], ]) return langCodeMap.get(jsonLang) ?? jsonLang; } let bcp47LangCode = getBcp47LangCode(lang); let jsonLangCode = getJsonLangCode(bcp47LangCode); if (!data[jsonLangCode]) { // lang not found // This may be due to formatting (expected 'ru' but browser sent 'ru-RU') // Set err msg before mutating lang (we may need this later) const msg = 'Couldn\'t find translations for ' + lang + '(lowercase BCP 47 lang tag ' + bcp47LangCode + ', JSON lang code ' + jsonLangCode + ')'; // Check for '-' (BCP 47 'ROOT-SCRIPT-REGION-VARIANT') and fallback until found data or ROOT // - 'ROOT-SCRIPT-REGION': 'zh-Hans-CN' // - 'ROOT-SCRIPT': 'zh-Hans' // - 'ROOT-REGION': 'en-GB' // - 'ROOT-VARIANT': 'be-tarask' while (!data[jsonLangCode] && bcp47LangCode.lastIndexOf('-') > -1) { // ROOT-SCRIPT-REGION-VARIANT formatting detected bcp47LangCode = bcp47LangCode.substring(0, bcp47LangCode.lastIndexOf('-')); // set lang to ROOT lang jsonLangCode = getJsonLangCode(bcp47LangCode); } if (!data[jsonLangCode]) { // ROOT lang not found. (e.g 'zh') // Loop through langs data. Maybe we have a variant? e.g (zh-hans) let l; // langs item. Declare outside of loop for (l in data) { // Is not ROOT? // And is variant of ROOT? // (NOTE: index of ROOT equals 0 would cause unexpected ISO 639-1 vs. 639-3 issues, // so append dash into query string) // And is known lang? if (bcp47LangCode != l && l.indexOf(lang + '-') === 0 && data[l]) { bcp47LangCode = l; // set lang to ROOT-SCRIPT (e.g 'zh-hans') jsonLangCode = getJsonLangCode(bcp47LangCode); break; } } // Did we find a variant? If not, return err. if (bcp47LangCode != l) { return callback(new Error(msg)); } } } lang = jsonLangCode if('string' === typeof data[lang]) { // Import rule // absolute path let importUrl = data[lang]; // relative path if(data[lang].indexOf("http") != 0 && data[lang].indexOf("/") != 0) { importUrl = href+"/../"+data[lang] } this.fetch(importUrl, lang, callback) return } if ('object' != typeof data[lang]) { callback(new Error('Translations should be specified as JSON objects!')) return } this.langs.set(lang,data[lang]) // TODO: Also store accompanying langs callback() } } const html10n = new Html10n() export default html10n // @ts-ignore window.html10n = html10n // gettext-like shortcut if (window._ === undefined){ // @ts-ignore window._ = html10n.get; }