├── _config.yml ├── .gitignore ├── docs ├── _config.yml └── index.md ├── types ├── typings.json ├── index.d.ts ├── tests │ ├── tsconfig.json │ ├── augmentation-test.ts │ └── banana-test.ts ├── tsconfig.json └── banana-i18n.d.ts ├── test ├── i18n │ └── en.json ├── tsconfig.json └── banana.test.js ├── demo ├── i18n │ ├── demo-de.json │ ├── demo-es.json │ ├── demo-fi.json │ ├── demo-en.json │ ├── demo-ru.json │ ├── demo-bn.json │ ├── demo-he.json │ ├── demo-ta.json │ └── demo-ml.json ├── css │ └── style.css ├── js │ └── demo.js └── index.html ├── src ├── languages │ ├── bs.js │ ├── digit-transform.json │ ├── dsb.js │ ├── hu.js │ ├── hsb.js │ ├── sl.js │ ├── hy.js │ ├── ru.js │ ├── he.js │ ├── ga.js │ ├── index.js │ ├── uk.js │ ├── fi.js │ ├── os.js │ ├── la.js │ ├── language.js │ └── fallbacks.json ├── parser.js ├── messagestore.js ├── index.js ├── ast.js └── emitter.js ├── eslint.config.js ├── AUTHORS.md ├── rollup.config.js ├── scripts └── fallbacks-update.js ├── LICENSE ├── package.json ├── .github └── workflows │ └── node.js.yml └── README.md /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | tmp/ 3 | dist/ -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /types/typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "banana-i18n", 3 | "main": "index.d.ts" 4 | } 5 | 6 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Banana } from './banana-i18n'; 2 | 3 | export default Banana; 4 | 5 | export { 6 | BananaOptions, 7 | Messages, 8 | MessageSource, 9 | ParameterType, 10 | } from './banana-i18n'; 11 | -------------------------------------------------------------------------------- /test/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "msg-one": "One", 3 | "msg-two": "$1 results", 4 | "msg-three": "{{plural:$1|One result|$1 results}}", 5 | "msg-four": "There {{plural:$1|is one result|are $1 results}} in {{plural:$2|one file|$2 files}}" 6 | } -------------------------------------------------------------------------------- /demo/i18n/demo-de.json: -------------------------------------------------------------------------------- 1 | { 2 | "$1 has $2 {{plural:$2|kitten|kittens}}. {{gender:$3|He|She}} loves to play with {{plural:$2|it|them}}." : "$1 hat {{plural:$2|eine Katze|$2 Katzen}}. {{gender:$3|Er|Sie}} spielt sehr gerne mit {{plural:$2|ihr|ihnen}}." 3 | } -------------------------------------------------------------------------------- /demo/i18n/demo-es.json: -------------------------------------------------------------------------------- 1 | { 2 | "$1 has $2 {{plural:$2|kitten|kittens}}. {{gender:$3|He|She}} loves to play with {{plural:$2|it|them}}." : "$1 tiene {{plural:$2|un gatito|$2 gatitos}}. A {{gender:$3|{{plural:$2|$1|él}}|ella}} le encanta jugar con {{plural:$2|él|ellos}}." 3 | } -------------------------------------------------------------------------------- /demo/i18n/demo-fi.json: -------------------------------------------------------------------------------- 1 | { 2 | "$1 has $2 {{plural:$2|kitten|kittens}}. {{gender:$3|He|She}} loves to play with {{plural:$2|it|them}}." : "Käyttäjällä $1 on $2 {{PLURAL:$2|kissanpentu|kissanpentua}}. {{GENDER:$3|Hän}} rakastaa leikkimistä {{PLURAL:$2|sen|niiden}} kanssa." 3 | } -------------------------------------------------------------------------------- /demo/i18n/demo-en.json: -------------------------------------------------------------------------------- 1 | { 2 | "one": "ONE", 3 | "two": "TWO", 4 | "$1 has $2 {{plural:$2|kitten|kittens}}. {{gender:$3|He|She}} loves to play with {{plural:$2|it|them}}." : "$1 has $2 {{plural:$2|kitten|kittens}}. {{gender:$3|He|She}} loves to play with {{plural:$2|it|them}}." 5 | } -------------------------------------------------------------------------------- /demo/i18n/demo-ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "$1 has $2 {{plural:$2|kitten|kittens}}. {{gender:$3|He|She}} loves to play with {{plural:$2|it|them}}." : "У {{grammar:genitive|$1}} есть {{plural:$2|$2 котёнок|$2 котёнка|$2 котят}}. {{gender:$3|Он|Она}} любит играть с {{plural:$2|ним|ними}}" 3 | } 4 | -------------------------------------------------------------------------------- /demo/i18n/demo-bn.json: -------------------------------------------------------------------------------- 1 | { 2 | "Meera": "মীরা", 3 | "Harry": "হ্যারি", 4 | "$1 has $2 {{plural:$2|kitten|kittens}}. {{gender:$3|He|She}} loves to play with {{plural:$2|it|them}}." : "$1-র কাছে $2-টি {{plural:$2|বেড়ালছানা|বেড়ালছানা}} আছে। {{gender:$3|সে|সে}} {{plural:$2|সেটির|সেগুলির}} সাথে খেলতে ভালোবাসে।" 5 | } 6 | -------------------------------------------------------------------------------- /demo/i18n/demo-he.json: -------------------------------------------------------------------------------- 1 | { 2 | "one": "אחד", 3 | "two": "שני", 4 | "Meera": "מירה", 5 | "Harry": "הארי", 6 | "$1 has $2 {{plural:$2|kitten|kittens}}. {{gender:$3|He|She}} loves to play with {{plural:$2|it|them}}." : "ל{{GRAMMAR:תחילית|$1}} יש {{PLURAL:$2|חתול אחד|$2 חתולים}}. {{GENDER:$3|הוא אוהב|היא אוהבת}} לשחק {{PLURAL:$2|אתו|אתם}}." 7 | } 8 | -------------------------------------------------------------------------------- /demo/i18n/demo-ta.json: -------------------------------------------------------------------------------- 1 | { 2 | "Meera": "மீரா", 3 | "Harry": "ஹாரி", 4 | "$1 has $2 {{plural:$2|kitten|kittens}}. {{gender:$3|He|She}} loves to play with {{plural:$2|it|them}}." : "$1 இடம் {{plural:$2|ஓரு பூனை|$2 பூனைகள்}} {{plural:$2|உள்ளது|உள்ளன}}. {{gender:$3|அவன்|அவள்}} {{plural:$2|அதனுடன்|அவைகளுடன்}} விளையாடுவதை {{gender:$3|விரும்புபவன்|விரும்புபவள்}}." 5 | } -------------------------------------------------------------------------------- /demo/i18n/demo-ml.json: -------------------------------------------------------------------------------- 1 | { 2 | "one": "ഒന്നു്", 3 | "two": "രണ്ടു്", 4 | "Meera": "മീര", 5 | "Harry": "ഹാരി", 6 | "$1 has $2 {{plural:$2|kitten|kittens}}. {{gender:$3|He|She}} loves to play with {{plural:$2|it|them}}." : "$1യ്ക്ക് {{plural:$2|ഒരു പൂച്ചക്കുട്ടി|$2 പൂച്ചക്കുട്ടികൾ}} ഉണ്ടു്. {{gender:$3|അവൻ|അവൾ}} {{plural:$2|അതുമായി|അവറ്റകളുമായി}} കളിക്കാൻ ഇഷ്ടപ്പെടുന്നു." 7 | } 8 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "experimentalDecorators": true, 5 | "module": "commonjs", 6 | "strict": true, 7 | "noEmit": true, 8 | "baseUrl": ".", 9 | "paths": { 10 | "banana-i18n": ["../index.d.ts"] 11 | } 12 | }, 13 | "include": [ 14 | "../*.d.ts", 15 | "*.ts", 16 | ], 17 | "compileOnSave": false 18 | } 19 | -------------------------------------------------------------------------------- /types/tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "experimentalDecorators": true, 5 | "module": "commonjs", 6 | "strict": true, 7 | "noEmit": true, 8 | "baseUrl": ".", 9 | "paths": { 10 | "banana-i18n": ["../index.d.ts"] 11 | } 12 | }, 13 | "include": [ 14 | "../*.d.ts", 15 | "*.ts" 16 | ], 17 | "compileOnSave": false 18 | } 19 | -------------------------------------------------------------------------------- /src/languages/bs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bosnian (bosanski) language functions 3 | */ 4 | import BananaLanguage from './language.js' 5 | 6 | export default class BosnianLanguage extends BananaLanguage { 7 | convertGrammar (word, form) { 8 | switch (form) { 9 | case 'instrumental': // instrumental 10 | word = 's ' + word 11 | break 12 | case 'lokativ': // locative 13 | word = 'o ' + word 14 | break 15 | } 16 | 17 | return word 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/languages/digit-transform.json: -------------------------------------------------------------------------------- 1 | { 2 | "ar": "٠١٢٣٤٥٦٧٨٩", 3 | "fa": "۰۱۲۳۴۵۶۷۸۹", 4 | "ml": "൦൧൨൩൪൫൬൭൮൯", 5 | "kn": "೦೧೨೩೪೫೬೭೮೯", 6 | "lo": "໐໑໒໓໔໕໖໗໘໙", 7 | "or": "୦୧୨୩୪୫୬୭୮୯", 8 | "kh": "០១២៣៤៥៦៧៨៩", 9 | "nqo": "߀߁߂߃߄߅߆߇߈߉", 10 | "pa": "੦੧੨੩੪੫੬੭੮੯", 11 | "gu": "૦૧૨૩૪૫૬૭૮૯", 12 | "hi": "०१२३४५६७८९", 13 | "my": "၀၁၂၃၄၅၆၇၈၉", 14 | "ta": "௦௧௨௩௪௫௬௭௮௯", 15 | "te": "౦౧౨౩౪౫౬౭౮౯", 16 | "th": "๐๑๒๓๔๕๖๗๘๙", 17 | "bo": "༠༡༢༣༤༥༦༧༨༩" 18 | } 19 | -------------------------------------------------------------------------------- /src/languages/dsb.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Lower Sorbian (Dolnoserbski) language functions 3 | */ 4 | import BananaLanguage from './language.js' 5 | 6 | export default class DolnoserbskiLanguage extends BananaLanguage { 7 | convertGrammar (word, form) { 8 | switch (form) { 9 | case 'instrumental': // instrumental 10 | word = 'z ' + word 11 | break 12 | case 'lokatiw': // lokatiw 13 | word = 'wo ' + word 14 | break 15 | } 16 | return word 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/languages/hu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Hungarian language functions 3 | * 4 | */ 5 | import BananaLanguage from './language.js' 6 | 7 | export default class HungarianLanguage extends BananaLanguage { 8 | convertGrammar (word, form) { 9 | switch (form) { 10 | case 'rol': 11 | word += 'ról' 12 | break 13 | case 'ba': 14 | word += 'ba' 15 | break 16 | case 'k': 17 | word += 'k' 18 | break 19 | } 20 | 21 | return word 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | export default [ 6 | { 7 | files: ["**/*.{js,mjs,cjs,ts}"], 8 | ignores: ["dist/**/*"], 9 | }, 10 | { 11 | languageOptions: { 12 | globals: { 13 | ...globals.browser, 14 | ...globals.node, 15 | ...globals.mocha 16 | } 17 | } 18 | }, 19 | pluginJs.configs.recommended, 20 | ...tseslint.configs.recommended, 21 | ]; -------------------------------------------------------------------------------- /src/languages/hsb.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Upper Sorbian (Hornjoserbsce) language functions 3 | */ 4 | import BananaLanguage from './language.js' 5 | 6 | export default class HornjoserbsceLanguage extends BananaLanguage { 7 | convertGrammar (word, form) { 8 | switch (form) { 9 | case 'instrumental': // instrumental 10 | word = 'z ' + word 11 | break 12 | case 'lokatiw': // lokatiw 13 | word = 'wo ' + word 14 | break 15 | } 16 | 17 | return word 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/languages/sl.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Slovenian (Slovenščina) language functions 3 | */ 4 | import BananaLanguage from './language.js' 5 | 6 | export default class SlovenianLanguage extends BananaLanguage { 7 | convertGrammar (word, form) { 8 | switch (form) { 9 | // locative 10 | case 'mestnik': 11 | word = 'o ' + word 12 | break 13 | // instrumental 14 | case 'orodnik': 15 | word = 'z ' + word 16 | break 17 | } 18 | 19 | return word 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | 2 | Major contributors 3 | ================== 4 | 5 | Santhosh Thottingal 6 | Amir E. Aharoni 7 | Niklas Laxström 8 | James D. Forrester 9 | Siebrand Mazeland 10 | Kartik Mistry 11 | Ricordisamoa 12 | Timo Tijhof 13 | Neil Kandalgaonkar 14 | David Chan 15 | 16 | And thanks to all patch contributors. 17 | -------------------------------------------------------------------------------- /types/tests/augmentation-test.ts: -------------------------------------------------------------------------------- 1 | import Banana from '../index'; 2 | 3 | declare module '../banana-i18n' { 4 | type Augmentation = 'key1'|'key2'; 5 | interface Banana { 6 | i18n( Key: Augmentation, parameter1: number, parameter2: number ): string; 7 | } 8 | } 9 | 10 | 11 | type Augmentation = 'key1'|'key2'; 12 | 13 | const banana1 = new Banana( 'it' ); 14 | banana1.i18n( 'key2', 12, 23 ); // message key 'key2' augmented 15 | banana1.i18n( 'key1', 12, 23, 'some string value' ); // message key 'key1' not augmented, which allows string|object|number|undefined as parameter without limits 16 | -------------------------------------------------------------------------------- /src/languages/hy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Armenian (Հայերեն) language functions 3 | */ 4 | 5 | import BananaLanguage from './language.js' 6 | 7 | export default class ArmenianLanguage extends BananaLanguage { 8 | convertGrammar (word, form) { 9 | if (form === 'genitive') { // սեռական հոլով 10 | if (word.slice(-1) === 'ա') { 11 | word = word.slice(0, -1) + 'այի' 12 | } else if (word.slice(-1) === 'ո') { 13 | word = word.slice(0, -1) + 'ոյի' 14 | } else if (word.slice(-4) === 'գիրք') { 15 | word = word.slice(0, -4) + 'գրքի' 16 | } else { 17 | word = word + 'ի' 18 | } 19 | } 20 | 21 | return word 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "esModuleInterop": true, 5 | "target": "esnext", 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "experimentalDecorators": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "noImplicitReturns": true, 12 | "noUnusedParameters": true, 13 | "noUnusedLocals": true, 14 | "declaration": true, 15 | "baseUrl": ".", 16 | "paths": { 17 | "@/*": [ 18 | "src/*" 19 | ] 20 | }, 21 | "typeRoots": [ 22 | "./node_modules/@types" 23 | ] 24 | }, 25 | "include": [ 26 | "./*.ts" 27 | ], 28 | "exclude": [ 29 | "node_modules" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /demo/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-size: 1em; 3 | margin: auto; 4 | color: #333; 5 | } 6 | 7 | .header { 8 | background: #258dc8; 9 | padding: 2px; 10 | margin-bottom: 1em 11 | } 12 | 13 | .lead { 14 | padding: 0 10%; 15 | margin: auto; 16 | } 17 | 18 | .header h1 { 19 | color: #fff; 20 | padding: 0 10%; 21 | font-size: 1.5em; 22 | font-weight: bold; 23 | } 24 | 25 | footer { 26 | padding: 0 10%; 27 | } 28 | 29 | select { 30 | font-size: 1em; 31 | } 32 | 33 | .input { 34 | padding: 5px; 35 | width: 50px; 36 | } 37 | 38 | .result { 39 | padding: 1em; 40 | font-size: 1.2em; 41 | line-height: 1.5em; 42 | background-color: #f8ffe8; 43 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import pkg from './package.json' with { type: 'json' } 2 | import json from '@rollup/plugin-json' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | import esbuild from 'rollup-plugin-esbuild' 5 | 6 | export default [ 7 | { 8 | input: 'src/index.js', 9 | output: [ 10 | { 11 | name: 'Banana', 12 | file: pkg.main, 13 | format: 'umd' 14 | }, 15 | { 16 | name: pkg.name, 17 | file: pkg.module, 18 | format: 'esm', 19 | sourcemap: true 20 | }, 21 | ], 22 | plugins: [ 23 | json(), 24 | commonjs(), 25 | esbuild({ 26 | sourceMap: true, 27 | minify: true 28 | }) 29 | ] 30 | } 31 | ] 32 | -------------------------------------------------------------------------------- /scripts/fallbacks-update.js: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs' 2 | import { get } from 'axios' 3 | 4 | get('https://en.wikipedia.org/w/api.php?action=query&format=json&meta=languageinfo&formatversion=2&liprop=fallbacks&licode=*').then(response => { 5 | const fallbacks = Object.fromEntries( 6 | Object.entries(response.data.query.languageinfo).filter(([, { fallbacks }]) => { 7 | return fallbacks.length > 0 8 | }).map(([code, { fallbacks }]) => { 9 | return [code, fallbacks] 10 | }) 11 | ) 12 | // prettify the json a bit, don't put newlines within arrays 13 | const jsonString = JSON.stringify(fallbacks, null, 2) 14 | .replace(/\n {4}/g, ' ') 15 | .replace(/\n {2}\]/g, ' ]') 16 | writeFileSync('../src/languages/fallbacks.json', jsonString) 17 | }) 18 | -------------------------------------------------------------------------------- /src/languages/ru.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Russian (Русский) language functions 3 | */ 4 | 5 | import BananaLanguage from './language.js' 6 | 7 | export default class RussianLanguage extends BananaLanguage { 8 | convertGrammar (word, form) { 9 | if (form === 'genitive') { // родительный падеж 10 | if (word.slice(-1) === 'ь') { 11 | word = word.slice(0, -1) + 'я' 12 | } else if (word.slice(-2) === 'ия') { 13 | word = word.slice(0, -2) + 'ии' 14 | } else if (word.slice(-2) === 'ка') { 15 | word = word.slice(0, -2) + 'ки' 16 | } else if (word.slice(-2) === 'ти') { 17 | word = word.slice(0, -2) + 'тей' 18 | } else if (word.slice(-2) === 'ды') { 19 | word = word.slice(0, -2) + 'дов' 20 | } else if (word.slice(-3) === 'ник') { 21 | word = word.slice(0, -3) + 'ника' 22 | } 23 | } 24 | 25 | return word 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/languages/he.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Hebrew (עברית) language functions 3 | */ 4 | import BananaLanguage from './language.js' 5 | 6 | export default class HebrewLanguage extends BananaLanguage { 7 | convertGrammar (word, form) { 8 | switch (form) { 9 | case 'prefixed': 10 | case 'תחילית': // the same word in Hebrew 11 | // Duplicate prefixed "Waw", but only if it's not already double 12 | if (word.slice(0, 1) === 'ו' && word.slice(0, 2) !== 'וו') { 13 | word = 'ו' + word 14 | } 15 | 16 | // Remove the "He" if prefixed 17 | if (word.slice(0, 1) === 'ה') { 18 | word = word.slice(1) 19 | } 20 | 21 | // Add a hyphen (maqaf) before numbers and non-Hebrew letters 22 | if (word.slice(0, 1) < 'א' || word.slice(0, 1) > 'ת') { 23 | word = '־' + word 24 | } 25 | } 26 | 27 | return word 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/languages/ga.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Irish (Gaeilge) language functions 3 | */ 4 | import BananaLanguage from './language.js' 5 | 6 | export default class IrishLanguage extends BananaLanguage { 7 | convertGrammar (word, form) { 8 | if (form === 'ainmlae') { 9 | switch (word) { 10 | case 'an Domhnach': 11 | word = 'Dé Domhnaigh' 12 | break 13 | case 'an Luan': 14 | word = 'Dé Luain' 15 | break 16 | case 'an Mháirt': 17 | word = 'Dé Mháirt' 18 | break 19 | case 'an Chéadaoin': 20 | word = 'Dé Chéadaoin' 21 | break 22 | case 'an Déardaoin': 23 | word = 'Déardaoin' 24 | break 25 | case 'an Aoine': 26 | word = 'Dé hAoine' 27 | break 28 | case 'an Satharn': 29 | word = 'Dé Sathairn' 30 | break 31 | } 32 | } 33 | 34 | return word 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /types/banana-i18n.d.ts: -------------------------------------------------------------------------------- 1 | export interface Messages { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | [ messageKey: string ]: string|Record; 4 | } 5 | 6 | export interface BananaOptions { 7 | messages?: Messages | MessageSource; 8 | finalFallback?: string; 9 | wikilinks?: boolean; 10 | } 11 | 12 | export interface BananaConstructor { 13 | new ( locale: string, options?: BananaOptions ): Banana; 14 | } 15 | 16 | export type MessageSource = Record; 17 | 18 | export type ParameterType = string|object|number|undefined; 19 | 20 | export interface Banana { 21 | locale: string; 22 | load( messageSource: Messages | MessageSource, locale?: string ): void; 23 | i18n( key: string, ...params: ParameterType[] ): string; 24 | setLocale( locale: string ): void; 25 | getFallbackLocales(): string[]; 26 | getMessage( messageKey: string ): string; 27 | registerParserPlugin( name: string, plugin: ((nodes: ParameterType[]) => string) ): void; 28 | } 29 | 30 | export const Banana: BananaConstructor; 31 | -------------------------------------------------------------------------------- /src/languages/index.js: -------------------------------------------------------------------------------- 1 | import BananaLanguage from './language.js' 2 | import HebrewLanguage from './he.js' 3 | import BosnianLanguage from './bs.js' 4 | import DolnoserbskiLanguage from './dsb.js' 5 | import HornjoserbsceLanguage from './hsb.js' 6 | import FinnishLanguage from './fi.js' 7 | import RussianLanguage from './ru.js' 8 | import SlovenianLanguage from './sl.js' 9 | import LatinLanguage from './la.js' 10 | import ArmenianLanguage from './hy.js' 11 | import IrishLanguage from './ga.js' 12 | import HungarianLanguage from './hu.js' 13 | import OssetianLanguage from './os.js' 14 | import UkrainianLanguage from './uk.js' 15 | 16 | export default { 17 | bs: BosnianLanguage, 18 | default: BananaLanguage, 19 | dsb: DolnoserbskiLanguage, 20 | fi: FinnishLanguage, 21 | ga: IrishLanguage, 22 | he: HebrewLanguage, 23 | hsb: HornjoserbsceLanguage, 24 | hu: HungarianLanguage, 25 | hy: ArmenianLanguage, 26 | la: LatinLanguage, 27 | os: OssetianLanguage, 28 | ru: RussianLanguage, 29 | sl: SlovenianLanguage, 30 | uk: UkrainianLanguage 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Santhosh Thottingal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/languages/uk.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Ukrainian (Українська) language functions 3 | */ 4 | 5 | import BananaLanguage from './language.js' 6 | 7 | export default class UkrainianLanguage extends BananaLanguage { 8 | convertGrammar (word, form) { 9 | switch (form) { 10 | case 'genitive': // родовий відмінок 11 | if (word.slice(-1) === 'ь') { 12 | word = word.slice(0, -1) + 'я' 13 | } else if (word.slice(-2) === 'ія') { 14 | word = word.slice(0, -2) + 'ії' 15 | } else if (word.slice(-2) === 'ка') { 16 | word = word.slice(0, -2) + 'ки' 17 | } else if (word.slice(-2) === 'ти') { 18 | word = word.slice(0, -2) + 'тей' 19 | } else if (word.slice(-2) === 'ды') { 20 | word = word.slice(0, -2) + 'дов' 21 | } else if (word.slice(-3) === 'ник') { 22 | word = word.slice(0, -3) + 'ника' 23 | } 24 | 25 | break 26 | case 'accusative': // знахідний відмінок 27 | if (word.slice(-2) === 'ія') { 28 | word = word.slice(0, -2) + 'ію' 29 | } 30 | 31 | break 32 | } 33 | 34 | return word 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/parser.js: -------------------------------------------------------------------------------- 1 | import BananaEmitter, { normalizeLocale } from './emitter.js' 2 | import BananaMessage from './ast.js' 3 | 4 | export default class BananaParser { 5 | /** 6 | * 7 | * @param {string} locale 8 | * @param {Object} options options 9 | * @param {boolean} [options.wikilinks] whether the wiki style link syntax should be parsed or not 10 | */ 11 | constructor (locale, { wikilinks = false } = {}) { 12 | this.locale = normalizeLocale(locale) 13 | this.wikilinks = wikilinks 14 | this.emitter = new BananaEmitter(this.locale) 15 | } 16 | 17 | parse (message, params) { 18 | if (message.includes('{{') || message.includes('<') || (this.wikilinks && message.includes('['))) { 19 | const ast = BananaMessage(message, { wikilinks: this.wikilinks }) 20 | return this.emitter.emit(ast, params) 21 | } else { 22 | return this.simpleParse(message, params) 23 | } 24 | } 25 | 26 | simpleParse (message, parameters) { 27 | return message.replace(/\$(\d+)/g, (str, match) => { 28 | const index = parseInt(match, 10) - 1 29 | return parameters[index] !== undefined ? parameters[index] : '$' + match 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/languages/fi.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Finnish (Suomi) language functions 3 | * 4 | * @author Santhosh Thottingal 5 | */ 6 | 7 | import BananaLanguage from './language.js' 8 | 9 | export default class FinnishLanguage extends BananaLanguage { 10 | convertGrammar (word, form) { 11 | // vowel harmony flag 12 | let aou = word.match(/[aou][^äöy]*$/i) 13 | 14 | const origWord = word 15 | if (word.match(/wiki$/i)) { 16 | aou = false 17 | } 18 | 19 | // append i after final consonant 20 | if (word.match(/[bcdfghjklmnpqrstvwxz]$/i)) { 21 | word += 'i' 22 | } 23 | 24 | switch (form) { 25 | case 'genitive': 26 | word += 'n' 27 | break 28 | case 'elative': 29 | word += (aou ? 'sta' : 'stä') 30 | break 31 | case 'partitive': 32 | word += (aou ? 'a' : 'ä') 33 | break 34 | case 'illative': 35 | // Double the last letter and add 'n' 36 | word += word.slice(-1) + 'n' 37 | break 38 | case 'inessive': 39 | word += (aou ? 'ssa' : 'ssä') 40 | break 41 | default: 42 | word = origWord 43 | break 44 | } 45 | 46 | return word 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /demo/js/demo.js: -------------------------------------------------------------------------------- 1 | import Banana from '../../dist/esm/banana-i18n.js' 2 | 3 | const banana = new Banana() 4 | 5 | function updateText () { 6 | const message = '$1 has $2 {{plural:$2|kitten|kittens}}. ' + 7 | '{{gender:$3|He|She}} loves to play with {{plural:$2|it|them}}.' 8 | const langSelector = document.getElementById('language') 9 | const language = langSelector.options[langSelector.selectedIndex].value 10 | const personSelector = document.getElementById('person') 11 | const gender = personSelector.options[personSelector.selectedIndex].value 12 | const personName = personSelector.options[personSelector.selectedIndex].text 13 | const kittens = document.getElementById('kittens').value 14 | 15 | banana.setLocale(language) 16 | 17 | fetch('i18n/demo-' + banana.locale + '.json').then((response) => response.json()).then((messages) => { 18 | banana.load(messages, banana.locale) 19 | let localizedPersonName = banana.i18n(personName) 20 | let localizedMessage = banana.i18n(message, localizedPersonName, kittens, gender) 21 | document.getElementById('result').innerText = localizedMessage 22 | }) 23 | } 24 | 25 | window.addEventListener('load', () => { 26 | updateText() 27 | document.querySelectorAll('#kittens, #person, #language').forEach(element => { 28 | element.addEventListener('change', updateText) 29 | element.addEventListener('keyup', updateText) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "banana-i18n", 3 | "version": "2.4.0", 4 | "description": "Banana Internationalization library", 5 | "main": "dist/cjs/banana-i18n.cjs", 6 | "module": "dist/esm/banana-i18n.js", 7 | "type": "module", 8 | "scripts": { 9 | "test:types": "tsc -p ./types/tests/tsconfig.json", 10 | "test:unit": "mocha", 11 | "test": "npm run lint && npm run test:types && npm run test:unit", 12 | "lint": "eslint src test scripts", 13 | "build": "rollup -c", 14 | "dev": "rollup -c -w" 15 | }, 16 | "keywords": [ 17 | "internationalization", 18 | "localization", 19 | "i18n", 20 | "l10n" 21 | ], 22 | "author": { 23 | "name": "Santhosh Thottingal", 24 | "email": "santhosh.thottingal@gmail.com" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/wikimedia/banana-i18n.git" 29 | }, 30 | "typings": "./types/index.d.ts", 31 | "license": "MIT", 32 | "engines": { 33 | "node": ">=18" 34 | }, 35 | "devDependencies": { 36 | "@eslint/js": "^9.13.0", 37 | "@rollup/plugin-commonjs": "^28.0.1", 38 | "@rollup/plugin-json": "^6.1.0", 39 | "axios": "^1.7.7", 40 | "esbuild": "^0.24.0", 41 | "eslint": "^9.13.0", 42 | "globals": "^15.11.0", 43 | "mocha": "^10.7.3", 44 | "rollup": "^4.0.0", 45 | "rollup-plugin-esbuild": "^6.1.1", 46 | "typescript": "^5.6.3", 47 | "typescript-eslint": "^8.12.1" 48 | } 49 | } -------------------------------------------------------------------------------- /types/tests/banana-test.ts: -------------------------------------------------------------------------------- 1 | import Banana from '../index'; 2 | 3 | let banana1 = new Banana('zh', { 4 | messages: { 5 | 'key-1': 'Localized message' 6 | } 7 | } ); 8 | 9 | const banana2 = new Banana('zh', { finalFallback: 'ru', wikilinks: true } ); 10 | 11 | const messages = { 12 | 'es': { 13 | '@metadata': { data: 'me' }, 14 | 'message-key-1': 'Localized message 1 for es', 15 | 'message-key-2': 'Localized message 2 for es with $1', 16 | // Rest of the messages for es 17 | }, 18 | 'ru': { 19 | 'message-key-1': 'Localized message 1 for ru', 20 | 'message-key-2': 'Localized message 2 for ru with $1', 21 | // Rest of the messages for ru 22 | } 23 | }; 24 | 25 | const messages2 = { 26 | '@metadata': { data: 'me' }, 27 | 'message-key-1': 'Localized message 1 for zh', 28 | 'message-key-2': 'Localized message 2 for zh with $1', 29 | }; 30 | 31 | banana1 = new Banana( 'ru' ); 32 | banana1.load( messages ); 33 | banana1.setLocale( 'an' ); 34 | banana1.i18n( 'message-key-1' ); // should return: Localized message 1 for es 35 | banana1.i18n( 'message-key-2', 'parameter' ); // should return: Localized message 2 for es with parameter 36 | // @see src/languages/fallback.json 37 | banana1.getFallbackLocales(); // should return: [ 'es' ] 38 | 39 | banana2.load( messages2 ); 40 | banana2.getFallbackLocales(); // should return: [ 'ru', 'zh-hans' ] 41 | 42 | banana1.getMessage( 'message-key-2' ); // should return 'Localized message 2 for es with $1' 43 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x, 22.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm install 29 | - run: npm run build --if-present 30 | - run: npm test 31 | 32 | build-and-deploy: 33 | 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v4 39 | - run: npm install 40 | - run: npm run build 41 | # Remove the .gitignore to ensure the dist folder is included for the demo page. 42 | - name: Remove .gitignore 43 | run: rm -f .gitignore 44 | - name: Deploy Pages 45 | uses: JamesIves/github-pages-deploy-action@v4 46 | with: 47 | folder: . 48 | branch: gh-pages 49 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ## Welcome to GitHub Pages 2 | 3 | You can use the [editor on GitHub](https://github.com/wikimedia/banana-i18n/edit/master/docs/index.md) to maintain and preview the content for your website in Markdown files. 4 | 5 | Whenever you commit to this repository, GitHub Pages will run [Jekyll](https://jekyllrb.com/) to rebuild the pages in your site, from the content in your Markdown files. 6 | 7 | ### Markdown 8 | 9 | Markdown is a lightweight and easy-to-use syntax for styling your writing. It includes conventions for 10 | 11 | ```markdown 12 | Syntax highlighted code block 13 | 14 | # Header 1 15 | ## Header 2 16 | ### Header 3 17 | 18 | - Bulleted 19 | - List 20 | 21 | 1. Numbered 22 | 2. List 23 | 24 | **Bold** and _Italic_ and `Code` text 25 | 26 | [Link](url) and ![Image](src) 27 | ``` 28 | 29 | For more details see [Basic writing and formatting syntax](https://docs.github.com/en/github/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax). 30 | 31 | ### Jekyll Themes 32 | 33 | Your Pages site will use the layout and styles from the Jekyll theme you have selected in your [repository settings](https://github.com/wikimedia/banana-i18n/settings/pages). The name of this theme is saved in the Jekyll `_config.yml` configuration file. 34 | 35 | ### Support or Contact 36 | 37 | Having trouble with Pages? Check out our [documentation](https://docs.github.com/categories/github-pages-basics/) or [contact support](https://support.github.com/contact) and we’ll help you sort it out. 38 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Banana i18n Demo 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |

Banana i18n Demo

17 |
18 |
19 |

Display the below sentence in 20 | 31 |

32 |

33 | 37 | has kitten(s). He/She loves to play with 38 | it/them. 39 |

40 |
41 |
42 |
43 |
44 | 45 |
46 | 47 | 48 | -------------------------------------------------------------------------------- /src/languages/os.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Ossetian (Ирон) language functions 3 | * 4 | * @author Santhosh Thottingal 5 | */ 6 | import BananaLanguage from './language.js' 7 | 8 | export default class OssetianLanguage extends BananaLanguage { 9 | convertGrammar (word, form) { 10 | let endAllative, jot, hyphen, ending 11 | 12 | // Ending for allative case 13 | endAllative = 'мæ' 14 | // Variable for 'j' beetwen vowels 15 | jot = '' 16 | // Variable for "-" for not Ossetic words 17 | hyphen = '' 18 | // Variable for ending 19 | ending = '' 20 | 21 | if (word.match(/тæ$/i)) { 22 | // Checking if the $word is in plural form 23 | word = word.slice(0, -1) 24 | endAllative = 'æм' 25 | } else if (word.match(/[аæеёиоыэюя]$/i)) { 26 | // Works if word is in singular form. 27 | // Checking if word ends on one of the vowels: е, ё, и, о, ы, э, ю, 28 | // я. 29 | jot = 'й' 30 | } else if (word.match(/у$/i)) { 31 | // Checking if word ends on 'у'. 'У' can be either consonant 'W' or 32 | // vowel 'U' in cyrillic Ossetic. 33 | // Examples: {{grammar:genitive|аунеу}} = аунеуы, 34 | // {{grammar:genitive|лæппу}} = лæппуйы. 35 | if (!word.slice(-2, -1) 36 | .match(/[аæеёиоыэюя]$/i)) { 37 | jot = 'й' 38 | } 39 | } else if (!word.match(/[бвгджзйклмнопрстфхцчшщьъ]$/i)) { 40 | hyphen = '-' 41 | } 42 | 43 | switch (form) { 44 | case 'genitive': 45 | ending = hyphen + jot + 'ы' 46 | break 47 | case 'dative': 48 | ending = hyphen + jot + 'æн' 49 | break 50 | case 'allative': 51 | ending = hyphen + endAllative 52 | break 53 | case 'ablative': 54 | if (jot === 'й') { 55 | ending = hyphen + jot + 'æ' 56 | } else { 57 | ending = hyphen + jot + 'æй' 58 | } 59 | break 60 | case 'superessive': 61 | ending = hyphen + jot + 'ыл' 62 | break 63 | case 'equative': 64 | ending = hyphen + jot + 'ау' 65 | break 66 | case 'comitative': 67 | ending = hyphen + 'имæ' 68 | break 69 | } 70 | 71 | return word + ending 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/messagestore.js: -------------------------------------------------------------------------------- 1 | import { normalizeLocale } from './emitter.js' 2 | 3 | export default class BananaMessageStore { 4 | constructor() { 5 | this.sourceMap = new Map() 6 | } 7 | 8 | /** 9 | * 10 | * @param {Object} messageSource 11 | * @param {string} locale BCP 47 language tag. In its most common form 12 | * it can contain, in order: a language code, a script code, and a country 13 | * or region code, all separated by hyphens. A very minimal validation 14 | * is done. 15 | */ 16 | load (messageSource, locale) { 17 | if (typeof messageSource !== 'object') { 18 | throw new Error('Invalid message source. Must be an object') 19 | } 20 | 21 | locale = normalizeLocale(locale) 22 | 23 | if (locale) { 24 | // Validate locale. This is a very minimal test for BCP 47 language tag 25 | if (!/^[a-zA-Z0-9-]+$/.test(locale)) { 26 | throw new Error(`Invalid locale ${locale}`) 27 | } 28 | // Validate messages 29 | for (const key in messageSource) { 30 | if (key.indexOf('@') === 0) continue 31 | // Check if the message source is locale - message data 32 | if (typeof messageSource[key] === 'object') { 33 | // The passed locale argument is irrelevant here. 34 | return this.load(messageSource) 35 | } 36 | if (typeof messageSource[key] !== 'string') { 37 | throw new Error(`Invalid message for message ${key} in ${locale} locale.`) 38 | } 39 | break 40 | } 41 | if (this.sourceMap.has(locale)) { 42 | this.sourceMap.set(locale, Object.assign(this.sourceMap.get(locale), messageSource)) 43 | } else { 44 | this.sourceMap.set(locale, messageSource) 45 | } 46 | } else { 47 | for (locale in messageSource) { 48 | this.load(messageSource[locale], locale) 49 | } 50 | } 51 | } 52 | 53 | getMessage (key, locale) { 54 | const localeMessages = this.sourceMap.get(normalizeLocale(locale)) 55 | return localeMessages ? localeMessages[key] : null 56 | } 57 | 58 | /** 59 | * Check if the given locale is present in the message store or not 60 | * @param {string} locale 61 | * @returns {boolean} 62 | */ 63 | hasLocale (locale) { 64 | return this.sourceMap.has(locale) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/languages/la.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Latin (lingua Latina) language functions 3 | * 4 | */ 5 | import BananaLanguage from './language.js' 6 | 7 | export default class LatinLanguage extends BananaLanguage { 8 | convertGrammar (word, form) { 9 | switch (form) { 10 | case 'genitive': 11 | // only a few declensions, and even for those mostly the singular only 12 | word = word.replace(/u[ms]$/i, 'i') // 2nd declension singular 13 | word = word.replace(/ommunia$/i, 'ommunium') // 3rd declension neuter plural (partly) 14 | word = word.replace(/a$/i, 'ae') // 1st declension singular 15 | word = word.replace(/libri$/i, 'librorum') // 2nd declension plural (partly) 16 | word = word.replace(/nuntii$/i, 'nuntiorum') // 2nd declension plural (partly) 17 | word = word.replace(/tio$/i, 'tionis') // 3rd declension singular (partly) 18 | word = word.replace(/ns$/i, 'ntis') 19 | word = word.replace(/as$/i, 'atis') 20 | word = word.replace(/es$/i, 'ei') // 5th declension singular 21 | break 22 | case 'accusative': 23 | // only a few declensions, and even for those mostly the singular only 24 | word = word.replace(/u[ms]$/i, 'um') // 2nd declension singular 25 | word = word.replace(/ommunia$/i, 'am') // 3rd declension neuter plural (partly) 26 | word = word.replace(/a$/i, 'ommunia') // 1st declension singular 27 | word = word.replace(/libri$/i, 'libros') // 2nd declension plural (partly) 28 | word = word.replace(/nuntii$/i, 'nuntios')// 2nd declension plural (partly) 29 | word = word.replace(/tio$/i, 'tionem') // 3rd declension singular (partly) 30 | word = word.replace(/ns$/i, 'ntem') 31 | word = word.replace(/as$/i, 'atem') 32 | word = word.replace(/es$/i, 'em') // 5th declension singular 33 | break 34 | case 'ablative': 35 | // only a few declensions, and even for those mostly the singular only 36 | word = word.replace(/u[ms]$/i, 'o') // 2nd declension singular 37 | word = word.replace(/ommunia$/i, 'ommunibus') // 3rd declension neuter plural (partly) 38 | word = word.replace(/a$/i, 'a') // 1st declension singular 39 | word = word.replace(/libri$/i, 'libris') // 2nd declension plural (partly) 40 | word = word.replace(/nuntii$/i, 'nuntiis') // 2nd declension plural (partly) 41 | word = word.replace(/tio$/i, 'tione') // 3rd declension singular (partly) 42 | word = word.replace(/ns$/i, 'nte') 43 | word = word.replace(/as$/i, 'ate') 44 | word = word.replace(/es$/i, 'e') // 5th declension singular 45 | break 46 | } 47 | 48 | return word 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import BananaParser from './parser.js' 2 | import BananaMessageStore from './messagestore.js' 3 | import BananaEmitter, { normalizeLocale } from './emitter.js' 4 | import fallbacks from './languages/fallbacks.json' with { type: 'json' } 5 | 6 | export default class Banana { 7 | /** 8 | * @param {string} locale 9 | * @param {Object} options options 10 | * @param {string} [options.finalFallback] Final fallback locale 11 | * @param {Object|undefined} [options.messages] messages 12 | * @param {boolean} [options.wikilinks] whether the wiki style link syntax should be parsed or not 13 | */ 14 | constructor (locale, { finalFallback = 'en', messages = undefined, wikilinks = false } = {} 15 | ) { 16 | this.locale = normalizeLocale(locale) 17 | this.parser = new BananaParser(this.locale, { wikilinks }) 18 | this.messageStore = new BananaMessageStore() 19 | if (messages) { 20 | this.load(messages, this.locale) 21 | } 22 | this.finalFallback = finalFallback 23 | this.wikilinks = wikilinks 24 | } 25 | 26 | /** 27 | * Load localized messages for a locale 28 | * If locale not provided, the keys in messageSource will be used as locales. 29 | * @param {Object} messageSource 30 | * @param {string} [locale] 31 | */ 32 | load (messageSource, locale) { 33 | return this.messageStore.load(messageSource, locale || this.locale) 34 | } 35 | 36 | i18n (key, ...parameters) { 37 | return this.parser.parse(this.getMessage(key), parameters) 38 | } 39 | 40 | setLocale (locale) { 41 | this.locale = normalizeLocale(locale) 42 | // Update parser 43 | this.parser = new BananaParser(this.locale, { wikilinks: this.wikilinks }) 44 | } 45 | 46 | getFallbackLocales () { 47 | return [...(fallbacks[this.locale] || []), this.finalFallback] 48 | } 49 | 50 | getMessage (messageKey) { 51 | let locale = this.locale 52 | let fallbackIndex = 0 53 | const fallbackLocales = this.getFallbackLocales(this.locale) 54 | while (locale) { 55 | // Iterate through locales starting at most-specific until 56 | // localization is found. As in fi-Latn-FI, fi-Latn and fi. 57 | const localeParts = locale.split('-') 58 | let localePartIndex = localeParts.length 59 | 60 | do { 61 | const tryingLocale = localeParts.slice(0, localePartIndex).join('-') 62 | 63 | const message = this.messageStore.getMessage(messageKey, tryingLocale) 64 | 65 | if (typeof message === 'string') { 66 | return message 67 | } 68 | 69 | localePartIndex-- 70 | } while (localePartIndex) 71 | 72 | locale = fallbackLocales[fallbackIndex] 73 | fallbackIndex++ 74 | } 75 | return messageKey 76 | } 77 | 78 | /** 79 | * Register a plugin for the library's message parser 80 | * Example: 81 | *
 82 |    *   banana.registerParserPlugin('foobar', nodes => {
 83 |    *     return nodes[0] === 'foo' ? nodes[1] : nodes[2]
 84 |    *   }
 85 |    * 
86 | * Usage: 87 | *
 88 |    *   banana.i18n('{{foobar:foo|first message|second message}}') --> 'first message'
 89 |    *   banana.i18n('{{foobar:bar|first message|second message}}') --> 'second message'
 90 |    * 
91 | * See emitter.js for built-in parser operations. 92 | * @param {string} name - the name of the plugin 93 | * @param {Function} plugin - the plugin function. It receives nodes as argument - 94 | * a mixed array corresponding to the pipe-separated objects in the operation. 95 | */ 96 | registerParserPlugin (name, plugin) { 97 | BananaEmitter.prototype[name] = plugin 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/languages/language.js: -------------------------------------------------------------------------------- 1 | import DIGITTRANSFORMTABLE from './digit-transform.json' with { type: 'json' } 2 | import fallbacks from './fallbacks.json' with { type: 'json' } 3 | 4 | export default class BananaLanguage { 5 | constructor (locale) { 6 | this.locale = locale 7 | } 8 | 9 | /** 10 | * Plural form transformations, needed for some languages. 11 | * 12 | * @param {integer} count Non-localized quantifier 13 | * @param {Array} forms List of plural forms 14 | * @return {string} Correct form for quantifier in this language 15 | */ 16 | convertPlural (count, forms) { 17 | const explicitPluralPattern = /\d+=/i 18 | 19 | if (!forms || forms.length === 0) { 20 | return '' 21 | } 22 | 23 | // Handle for Explicit 0= & 1= values 24 | for (let index = 0; index < forms.length; index++) { 25 | const form = forms[index] 26 | if (explicitPluralPattern.test(form)) { 27 | const formCount = parseInt(form.slice(0, form.indexOf('=')), 10) 28 | if (formCount === count) { 29 | return (form.slice(form.indexOf('=') + 1)) 30 | } 31 | forms[index] = undefined 32 | } 33 | } 34 | 35 | forms = forms.filter((form) => !!form) 36 | 37 | let pluralFormIndex = this.getPluralForm(count, this.locale) 38 | pluralFormIndex = Math.min(pluralFormIndex, forms.length - 1) 39 | 40 | return forms[pluralFormIndex] 41 | } 42 | 43 | /** 44 | * For the number, get the plural for index 45 | * 46 | * @param {integer} number 47 | * @param {string} locale 48 | * @return {integer} plural form index 49 | */ 50 | getPluralForm (number, locale) { 51 | // Allowed forms as per CLDR spec 52 | const pluralForms = ['zero', 'one', 'two', 'few', 'many', 'other'] 53 | // Create an instance of Intl PluralRules. If the locale is invalid or 54 | // not supported, it fallbacks to `en`. 55 | const pluralRules = new Intl.PluralRules(locale) 56 | // For a locale, find the plural categories 57 | const pluralCategories = pluralRules.resolvedOptions().pluralCategories 58 | // Get the plural form. `select` method return values are like 'one', 'few' etc. 59 | const form = pluralRules.select(number) 60 | // The index of form we need to return is the index in pluralCategories. 61 | // And the index should be based on the order defined in pluralForms above. 62 | // So we need make sure pluralCategories follow same order as in pluralForms. 63 | // For that, get an intersection of pluralForms and pluralCategories. 64 | const pluralFormIndex = pluralForms.filter(f => pluralCategories.includes(f)).indexOf(form) 65 | return pluralFormIndex 66 | } 67 | 68 | /** 69 | * Converts a number using digitTransformTable. 70 | * 71 | * @param {number} num Value to be converted 72 | * @param {boolean} integer Convert the return value to an integer 73 | * @return {string} The number converted into a String. 74 | */ 75 | convertNumber (num, integer = false) { 76 | // Set the target Transform table: 77 | let transformTable = this.digitTransformTable(this.locale) 78 | let convertedNumber = '' 79 | 80 | // Check if the restore to Latin number flag is set: 81 | if (integer) { 82 | if (parseFloat(num, 10) === num) { 83 | return num 84 | } 85 | 86 | if (!transformTable) { 87 | return num 88 | } 89 | 90 | // Reverse the digit transformation tables if we are doing unformatting 91 | const tmp = [] 92 | for (const item in transformTable) { 93 | tmp[transformTable[item]] = item 94 | } 95 | transformTable = tmp 96 | 97 | const numberString = String(num) 98 | for (let i = 0; i < numberString.length; i++) { 99 | convertedNumber += transformTable[numberString[i]] || numberString[i] 100 | } 101 | return parseFloat(convertedNumber, 10) 102 | } 103 | 104 | if (Intl.NumberFormat) { 105 | let localeWithFallbacks 106 | const fallbackLocales = [...fallbacks[this.locale] || [], 'en'] 107 | // Check if locale is supported or not 108 | if (!Intl.NumberFormat.supportedLocalesOf(this.locale).length) { 109 | localeWithFallbacks = fallbackLocales 110 | } else { 111 | localeWithFallbacks = [this.locale] 112 | } 113 | 114 | convertedNumber = new Intl.NumberFormat(localeWithFallbacks).format(num) 115 | if (convertedNumber === 'NaN') { 116 | // Invalid number. Return it as such. 117 | convertedNumber = num 118 | } 119 | return convertedNumber 120 | } 121 | } 122 | 123 | /** 124 | * Grammatical transformations, needed for inflected languages. 125 | * Invoked by putting {{grammar:form|word}} in a message. 126 | * Override this method for languages that need special grammar rules 127 | * applied dynamically. 128 | * 129 | * @param {string} word 130 | * @param {string} form 131 | * @return {string} 132 | */ 133 | 134 | convertGrammar(word, /*form*/) { 135 | return word 136 | } 137 | 138 | /** 139 | * Provides an alternative text depending on specified gender. Usage 140 | * {{gender:[gender|user object]|masculine|feminine|neutral}}. If second 141 | * or third parameter are not specified, masculine is used. 142 | * 143 | * These details may be overriden per language. 144 | * 145 | * @param {string} gender male, female, or anything else for neutral. 146 | * @param {Array} forms List of gender forms 147 | * @return {string} 148 | */ 149 | gender (gender, forms) { 150 | if (!forms || forms.length === 0) { 151 | return '' 152 | } 153 | 154 | while (forms.length < 2) { 155 | forms.push(forms[forms.length - 1]) 156 | } 157 | 158 | if (gender === 'male') { 159 | return forms[0] 160 | } 161 | 162 | if (gender === 'female') { 163 | return forms[1] 164 | } 165 | 166 | return (forms.length === 3) ? forms[2] : forms[0] 167 | } 168 | 169 | /** 170 | * Get the digit transform table for the given language 171 | * See http://cldr.unicode.org/translation/numbering-systems 172 | * 173 | * @param {string} language 174 | * @return {Array|boolean} List of digits in the passed language or false 175 | * representation, or boolean false if there is no information. 176 | */ 177 | digitTransformTable (language) { 178 | if (!DIGITTRANSFORMTABLE[language]) { 179 | return false 180 | } 181 | 182 | return DIGITTRANSFORMTABLE[language].split('') 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/languages/fallbacks.json: -------------------------------------------------------------------------------- 1 | { 2 | "ab": [ "ru" ], 3 | "abs": [ "id" ], 4 | "ace": [ "id" ], 5 | "ady": [ "ady-cyrl" ], 6 | "aeb": [ "aeb-arab" ], 7 | "aeb-arab": [ "ar" ], 8 | "aln": [ "sq" ], 9 | "alt": [ "ru" ], 10 | "ami": [ "zh-hant" ], 11 | "an": [ "es" ], 12 | "anp": [ "hi" ], 13 | "arn": [ "es" ], 14 | "arq": [ "ar" ], 15 | "ary": [ "ar" ], 16 | "arz": [ "ar" ], 17 | "ast": [ "es" ], 18 | "atj": [ "fr" ], 19 | "av": [ "ru" ], 20 | "avk": [ "fr", "es", "ru" ], 21 | "awa": [ "hi" ], 22 | "ay": [ "es" ], 23 | "azb": [ "fa" ], 24 | "ba": [ "ru" ], 25 | "ban": [ "id" ], 26 | "ban-bali": [ "ban" ], 27 | "bar": [ "de" ], 28 | "bbc": [ "bbc-latn" ], 29 | "bbc-latn": [ "id" ], 30 | "bcc": [ "fa" ], 31 | "be-tarask": [ "be" ], 32 | "bgn": [ "fa" ], 33 | "bh": [ "bho" ], 34 | "bi": [ "en" ], 35 | "bjn": [ "id" ], 36 | "bm": [ "fr" ], 37 | "bpy": [ "bn" ], 38 | "bqi": [ "fa" ], 39 | "br": [ "fr" ], 40 | "btm": [ "id" ], 41 | "bug": [ "id" ], 42 | "bxr": [ "ru" ], 43 | "ca": [ "oc" ], 44 | "cbk-zam": [ "es" ], 45 | "cdo": [ "nan", "zh-hant" ], 46 | "ce": [ "ru" ], 47 | "co": [ "it" ], 48 | "crh": [ "crh-latn" ], 49 | "crh-cyrl": [ "ru" ], 50 | "cs": [ "sk" ], 51 | "csb": [ "pl" ], 52 | "cv": [ "ru" ], 53 | "de-at": [ "de" ], 54 | "de-ch": [ "de" ], 55 | "de-formal": [ "de" ], 56 | "dsb": [ "hsb", "de" ], 57 | "dtp": [ "ms" ], 58 | "dty": [ "ne" ], 59 | "egl": [ "it" ], 60 | "eml": [ "it" ], 61 | "en-ca": [ "en" ], 62 | "en-gb": [ "en" ], 63 | "es-419": [ "es" ], 64 | "es-formal": [ "es" ], 65 | "ext": [ "es" ], 66 | "ff": [ "fr" ], 67 | "fit": [ "fi" ], 68 | "frc": [ "fr" ], 69 | "frp": [ "fr" ], 70 | "frr": [ "de" ], 71 | "fur": [ "it" ], 72 | "gag": [ "tr" ], 73 | "gan": [ "gan-hant", "zh-hant", "zh-hans" ], 74 | "gan-hans": [ "zh-hans" ], 75 | "gan-hant": [ "zh-hant", "zh-hans" ], 76 | "gcr": [ "fr" ], 77 | "gl": [ "pt" ], 78 | "glk": [ "fa" ], 79 | "gn": [ "es" ], 80 | "gom": [ "gom-deva" ], 81 | "gom-deva": [ "hi" ], 82 | "gor": [ "id" ], 83 | "gsw": [ "de" ], 84 | "guc": [ "es" ], 85 | "hak": [ "zh-hant" ], 86 | "hif": [ "hif-latn" ], 87 | "hrx": [ "de" ], 88 | "hsb": [ "dsb", "de" ], 89 | "ht": [ "fr" ], 90 | "hu-formal": [ "hu" ], 91 | "hyw": [ "hy" ], 92 | "ii": [ "zh-cn", "zh-hans" ], 93 | "inh": [ "ru" ], 94 | "io": [ "eo" ], 95 | "iu": [ "ike-cans" ], 96 | "jam": [ "en" ], 97 | "jut": [ "da" ], 98 | "jv": [ "id" ], 99 | "kaa": [ "kk-latn", "kk-cyrl" ], 100 | "kab": [ "fr" ], 101 | "kbd": [ "kbd-cyrl" ], 102 | "kbp": [ "fr" ], 103 | "khw": [ "ur" ], 104 | "kiu": [ "tr" ], 105 | "kjp": [ "my" ], 106 | "kk": [ "kk-cyrl" ], 107 | "kk-arab": [ "kk-cyrl" ], 108 | "kk-cn": [ "kk-arab", "kk-cyrl" ], 109 | "kk-kz": [ "kk-cyrl" ], 110 | "kk-latn": [ "kk-cyrl" ], 111 | "kk-tr": [ "kk-latn", "kk-cyrl" ], 112 | "kl": [ "da" ], 113 | "ko-kp": [ "ko" ], 114 | "koi": [ "ru" ], 115 | "krc": [ "ru" ], 116 | "krl": [ "fi" ], 117 | "ks": [ "ks-arab" ], 118 | "ksh": [ "de" ], 119 | "ku": [ "ku-latn" ], 120 | "ku-arab": [ "ckb" ], 121 | "kum": [ "ru" ], 122 | "kv": [ "ru" ], 123 | "lad": [ "es" ], 124 | "lb": [ "de" ], 125 | "lbe": [ "ru" ], 126 | "lez": [ "ru", "az" ], 127 | "li": [ "nl" ], 128 | "lij": [ "it" ], 129 | "liv": [ "et" ], 130 | "lki": [ "fa" ], 131 | "lld": [ "it", "rm", "fur" ], 132 | "lmo": [ "pms", "eml", "lij", "vec", "it" ], 133 | "ln": [ "fr" ], 134 | "lrc": [ "fa" ], 135 | "ltg": [ "lv" ], 136 | "luz": [ "fa" ], 137 | "lzh": [ "zh-hant" ], 138 | "lzz": [ "tr" ], 139 | "mad": [ "id" ], 140 | "mai": [ "hi" ], 141 | "map-bms": [ "jv", "id" ], 142 | "mdf": [ "myv", "ru" ], 143 | "mg": [ "fr" ], 144 | "mhr": [ "mrj", "ru" ], 145 | "min": [ "id" ], 146 | "mnw": [ "my" ], 147 | "mo": [ "ro" ], 148 | "mrj": [ "mhr", "ru" ], 149 | "ms-arab": [ "ms" ], 150 | "mwl": [ "pt" ], 151 | "myv": [ "mdf", "ru" ], 152 | "mzn": [ "fa" ], 153 | "nah": [ "es" ], 154 | "nan": [ "cdo", "zh-hant" ], 155 | "nap": [ "it" ], 156 | "nb": [ "nn" ], 157 | "nds": [ "de" ], 158 | "nds-nl": [ "nl" ], 159 | "nia": [ "id" ], 160 | "nl-informal": [ "nl" ], 161 | "nn": [ "nb" ], 162 | "nrm": [ "fr" ], 163 | "oc": [ "ca", "fr" ], 164 | "olo": [ "fi" ], 165 | "os": [ "ru" ], 166 | "pcd": [ "fr" ], 167 | "pdc": [ "de" ], 168 | "pdt": [ "de" ], 169 | "pfl": [ "de" ], 170 | "pih": [ "en" ], 171 | "pms": [ "it" ], 172 | "pnt": [ "el" ], 173 | "pt": [ "pt-br" ], 174 | "pt-br": [ "pt" ], 175 | "qu": [ "qug", "es" ], 176 | "qug": [ "qu", "es" ], 177 | "rgn": [ "it" ], 178 | "rmy": [ "ro" ], 179 | "roa-tara": [ "it" ], 180 | "rue": [ "uk", "ru" ], 181 | "rup": [ "ro" ], 182 | "ruq": [ "ruq-latn", "ro" ], 183 | "ruq-cyrl": [ "mk" ], 184 | "ruq-latn": [ "ro" ], 185 | "sa": [ "hi" ], 186 | "sah": [ "ru" ], 187 | "scn": [ "it" ], 188 | "sco": [ "en" ], 189 | "sdc": [ "it" ], 190 | "sdh": [ "cbk", "fa" ], 191 | "ses": [ "fr" ], 192 | "sg": [ "fr" ], 193 | "sgs": [ "lt" ], 194 | "sh": [ "bs", "sr-el", "hr" ], 195 | "shi": [ "fr" ], 196 | "shy": [ "shy-latn" ], 197 | "shy-latn": [ "fr" ], 198 | "sk": [ "cs" ], 199 | "skr": [ "skr-arab" ], 200 | "skr-arab": [ "ur", "pnb" ], 201 | "sli": [ "de" ], 202 | "smn": [ "fi" ], 203 | "sr": [ "sr-ec" ], 204 | "srn": [ "nl" ], 205 | "stq": [ "de" ], 206 | "sty": [ "ru" ], 207 | "su": [ "id" ], 208 | "szl": [ "pl" ], 209 | "szy": [ "zh-tw", "zh-hant", "zh-hans" ], 210 | "tay": [ "zh-tw", "zh-hant", "zh-hans" ], 211 | "tcy": [ "kn" ], 212 | "tet": [ "pt" ], 213 | "tg": [ "tg-cyrl" ], 214 | "trv": [ "zh-tw", "zh-hant", "zh-hans" ], 215 | "tt": [ "tt-cyrl", "ru" ], 216 | "tt-cyrl": [ "ru" ], 217 | "ty": [ "fr" ], 218 | "tyv": [ "ru" ], 219 | "udm": [ "ru" ], 220 | "ug": [ "ug-arab" ], 221 | "vec": [ "it" ], 222 | "vep": [ "et" ], 223 | "vls": [ "nl" ], 224 | "vmf": [ "de" ], 225 | "vot": [ "fi" ], 226 | "vro": [ "et" ], 227 | "wa": [ "fr" ], 228 | "wo": [ "fr" ], 229 | "wuu": [ "zh-hans" ], 230 | "xal": [ "ru" ], 231 | "xmf": [ "ka" ], 232 | "yi": [ "he" ], 233 | "za": [ "zh-hans" ], 234 | "zea": [ "nl" ], 235 | "zgh": [ "kab" ], 236 | "zh": [ "zh-hans" ], 237 | "zh-cn": [ "zh-hans" ], 238 | "zh-hant": [ "zh-hans" ], 239 | "zh-hk": [ "zh-hant", "zh-hans" ], 240 | "zh-mo": [ "zh-hk", "zh-hant", "zh-hans" ], 241 | "zh-my": [ "zh-sg", "zh-hans" ], 242 | "zh-sg": [ "zh-hans" ], 243 | "zh-tw": [ "zh-hant", "zh-hans" ] 244 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # banana-i18n - Javascript Internationalization library 2 | 3 | [![Build](https://github.com/wikimedia/banana-i18n/actions/workflows/node.js.yml/badge.svg)](https://github.com/wikimedia/banana-i18n/actions/workflows/node.js.yml) 4 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/wikimedia/banana-18n/blob/master/LICENSE) 5 | [![npm version](https://img.shields.io/npm/v/banana-i18n.svg?style=flat)](https://www.npmjs.com/package/banana-i18n) 6 | 7 | banana-i18n is a javascript internationalization library that uses "banana" format - A JSON based localization file format. 8 | 9 | - [Features](#features) 10 | - [Installation](#installation) 11 | - [Usage](#usage) 12 | - [Node.js](#nodejs) 13 | - [JavaScript](#javascript) 14 | - [Demo](#demo) 15 | - [Developer Documentation](#developer-documentation) 16 | - [Banana File format](#banana-file-format) 17 | - [Loading the messages](#loading-the-messages) 18 | - [Using the constructor](#using-the-constructor) 19 | - [Load the messages for a locale](#load-the-messages-for-a-locale) 20 | - [Load the messages for many locales at once](#load-the-messages-for-many-locales-at-once) 21 | - [Setting the locale](#setting-the-locale) 22 | - [Fallback](#fallback) 23 | - [Placeholders](#placeholders) 24 | - [Plurals](#plurals) 25 | - [Gender](#gender) 26 | - [Grammar](#grammar) 27 | - [Directionality-safe isolation](#directionality-safe-isolation) 28 | - [Wiki style links](#wiki-style-links) 29 | - [Extending the parser](#extending-the-parser) 30 | - [Message documentation](#message-documentation) 31 | - [Translation](#translation) 32 | - [Frameworks](#frameworks) 33 | - [Thanks](#thanks) 34 | 35 | ## Features 36 | 37 | - Simple file format - JSON. Easily readable for humans and machines. 38 | - Bindings and wrappers available for React.js and Vue.js. See [frameworks](#frameworks) sections. 39 | - Author and metadata information is not lost anywhere. There are other file formats using comments to store this. 40 | - Uses MediaWiki convention for placeholders. Easily readable and proven convention. Example: ```There are $1 cars``` 41 | - Supports plural conversion without using extra messages for all plural forms. Plural rule handling is done using CLDR. Covers a wide range of languages 42 | - Supports gender. By passing the gender value, you get correct sentences according to gender. 43 | - Supports grammar forms. banana-i18n has a basic but extensible grammar conversion support 44 | - Fallback chains for all languages. 45 | - Nestable grammar, plural, gender support. These constructs can be nested to any arbitrary level for supporting sophisticated message localization 46 | - Message documentation through special language code ```qqq``` 47 | - Extensible message parser to add or customize magic words in the messages. Example: ```{sitename}``` or ```[[link]]``` 48 | - Automatic message file linter using [banana-checker](https://www.npmjs.com/package/grunt-banana-checker) 49 | - Tested in production - MediaWiki and and its extensions use this file format 50 | 51 | ## Installation 52 | 53 | ``` 54 | npm i banana-i18n 55 | ``` 56 | ## Usage 57 | 58 | ### Node.js 59 | 60 | ```js 61 | const Banana = require('banana-i18n'); 62 | 63 | // Initialize Banana-i18n with the default locale and messages 64 | const banana = new Banana('en', { 65 | messages: { 66 | 'greet-user': 'Hello, $1!', 67 | 'items-count': '$1 item(s) found.', 68 | } 69 | }); 70 | 71 | // Translating a simple message 72 | console.log(banana.i18n('greet-user', 'Alice')); // Output: Hello, Alice! 73 | 74 | // Translating with variables 75 | console.log(banana.i18n('items-count', 5)); // Output: 5 item(s) found. 76 | 77 | // Changing the language dynamically 78 | banana.setLocale('fr'); 79 | banana.load({ 80 | 'greet-user': 'Bonjour, $1!', 81 | 'items-count': '$1 article(s) trouvé(s).', 82 | }); 83 | 84 | console.log(banana.i18n('greet-user', 'Alice')); // Output: Bonjour, Alice! 85 | ``` 86 | 87 | ### JavaScript 88 | 89 | For usage in JavaScript, please check the files under the `demo` folder after building the distribution files by running `npm run build`. 90 | 91 | ## Demo 92 | 93 | See the library in action: https://wikimedia.github.io/banana-i18n/demo/ 94 | 95 | Related code can be found under the `demo` folder. 96 | 97 | ## Developer Documentation 98 | ### Banana File format 99 | 100 | The message files are json formatted. As a convention, you can have a folder named i18n inside your source code. For each language or locale, have a file named like languagecode.json. 101 | 102 | Example: 103 | 104 | ``` 105 | App 106 | |--src 107 | |--doc 108 | |--i18n 109 | |--ar.json 110 | |--de.json 111 | |--en.json 112 | |--he.json 113 | |--hi.json 114 | |--fr.json 115 | |--qqq.json 116 | ``` 117 | 118 | A simple en.json file example is given below 119 | 120 | ```json 121 | { 122 | "@metadata": { 123 | "authors": [ 124 | "Alice", 125 | "David", 126 | "Santhosh" 127 | ], 128 | "last-updated": "2012-09-21", 129 | "locale": "en", 130 | "message-documentation": "qqq", 131 | "AnotherMetadata": "AnotherMedatadataValue" 132 | }, 133 | "appname-title": "Example Application", 134 | "appname-sub-title": "An example application with jquery.i18n", 135 | "appname-header-introduction": "Introduction", 136 | "appname-about": "About this application", 137 | "appname-footer": "Footer text" 138 | } 139 | ``` 140 | 141 | The json file should be a valid json. The ```@metadata``` holds all kind of data that are not messages. You can store author information, copyright, updated date or anything there. 142 | 143 | Messages are key-value pairs. It is a good convention to prefix your appname to message keys to make the messages unique. It acts as the namespace for the message keys. It is also a good convention to have the message keys with ```-``` separated words, all in lower case. 144 | 145 | If you are curious to see some real jquery.i18n message file from other projects: 146 | 147 | - message files of MediaWiki https://github.com/wikimedia/mediawiki-core/tree/master/languages/i18n 148 | - message files from jquery.uls project https://github.com/wikimedia/jquery.uls/blob/master/i18n 149 | 150 | ### Loading the messages 151 | 152 | The localized message should be loaded before using .i18n() method. This can be done as follows: 153 | 154 | #### Using the constructor 155 | 156 | ```javascript 157 | const banana = new Banana('es',{ 158 | messages: { 159 | 'key-1': 'Localized message' 160 | } 161 | }) 162 | ``` 163 | 164 | #### Load the messages for a locale 165 | 166 | After the initialization, 167 | 168 | ```javascript 169 | const messages = { 170 | 'message-key-1': 'Localized message 1', 171 | // Rest of the messages 172 | }; 173 | banana.load(messages, 'es' ); 174 | ``` 175 | 176 | #### Load the messages for many locales at once 177 | 178 | > While it is possible to store all translations in a single file, we recommend using separate files for each language. 179 | > It simplifies tracking changes via VCS and enables multiple translators or teams to work on different language files simultaneously without conflicts. 180 | > Additionally, using separate files allows loading only the translations for the currently selected language, improving performance by avoiding the need 181 | > to load all translations. 182 | > 183 | > Having one translation file per language is a requirement to add the project for translation on [translatewiki.net](https://translatewiki.net) 184 | 185 | To load all messages for all locales at once, you can do this as follows. Here the messages are keyed by locale. 186 | 187 | ```javascript 188 | const messages = { 189 | 'es': { 190 | 'message-key-1': 'Localized message 1 for es', 191 | // Rest of the messages for es 192 | }, 193 | 'ru': { 194 | 'message-key-1': 'Localized message 1 for ru', 195 | // Rest of the messages for ru 196 | } 197 | }; 198 | banana.load(messages); // Note that the locale parameter is missing here 199 | ``` 200 | 201 | Depeding on your application, the messages can be fetched from a server or a file system. Here is an example that fetches the localized messages json file. 202 | 203 | ```javascript 204 | fetch('i18n/es.json').then((response) => response.json()).then((messages) => { 205 | banana.load(messages, 'es'); 206 | }); 207 | ``` 208 | 209 | You may load the messages in parts too. That means, you can use the `banana.load(message_set1, 'es')` and later `banana.load(message_set2, 'es')`. Both of the messages will be merged to the locale. If message_2 has the same key of message_set1, the last message loaded wins. 210 | 211 | ### Setting the locale 212 | 213 | The constructor for Banana class accepts the locale 214 | 215 | ```javascript 216 | const banana = new Banana('es') 217 | ``` 218 | 219 | Once the banana i18n is initialized you can change the locale using setLocale method 220 | 221 | ```javascript 222 | banana.setLocale('es'); // Change to new locale 223 | ``` 224 | 225 | All .i18n() calls will set the message for the new locale from there onwards. 226 | 227 | ### Fallback 228 | 229 | If a particular message is not localized for locale, but localized for a fallback locale(defined in src/languages/fallbacks.json), 230 | the .i18n() method will return that. By default English is the final fallback language. But this configurable using `finalFallback` option. Example: `new Banana('ru', {finalFallback:'es' })` 231 | 232 | ### Placeholders 233 | 234 | Messages take parameters. They are represented by $1, $2, $3, … in the message texts, and replaced at run time. Typical parameter values are numbers (Example: "Delete 3 versions?"), or user names (Example: "Page last edited by $1"), page names, links, and so on, or sometimes other messages. 235 | 236 | ```javascript 237 | const message = "Welcome, $1"; 238 | banana.i18n(message, 'Alice'); // This gives "Welcome, Alice" 239 | ``` 240 | 241 | ### Plurals 242 | 243 | To make the syntax of sentence correct, plural forms are required. jquery.i18n support plural forms in the message using the syntax `{{PLURAL:$1|pluralform1|pluralform2|...}}` 244 | 245 | For example: 246 | 247 | ```javascript 248 | const message = "Found $1 {{PLURAL:$1|result|results}}"; 249 | banana.i18n(message, 1); // This gives "Found 1 result" 250 | banana.i18n(message, 4); // This gives "Found 4 results" 251 | ``` 252 | 253 | Note that {{PLURAL:...}} is not case sensitive. It can be {{plural:...}} too. 254 | 255 | In case of English, there are only 2 plural forms, but many languages use more than 2 plural forms. All the plural forms can be given in the above syntax, separated by pipe(|). The number of plural forms for each language is defined in [CLDR](http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html). You need to provide all those plural forms for a language. 256 | 257 | For example, English has 2 plural forms and the message format will look like `{{PLURAL:$1|one|other}}`. for Arabic there are 6 plural forms and format will look like `{{PLURAL:$1|zero|one|two|few|many|other}}`. 258 | 259 | You cannot skip a plural form from the middle or beginning. However, you can skip from end. For example, in Arabic, if the message is like 260 | `{{PLURAL:$1|A|B}}`, for 0, A will be used, for numbers that fall under one, two, few, many, other categories B will be used. 261 | 262 | If there is an explicit plural form to be given for a specific number, it is possible with the following syntax 263 | 264 | ```javascript 265 | const message = 'Box has {{PLURAL:$1|one egg|$1 eggs|12=a dozen eggs}}.'; 266 | banana.i18n(message, 4 ); // Gives "Box has 4 eggs." 267 | banana.i18n(message, 12 ); // Gives "Box has a dozen eggs." 268 | ``` 269 | 270 | ### Gender 271 | 272 | Similar to plural, depending on gender of placeholders, mostly user names, the syntax changes dynamically. An example in English is "Alice changed her profile picture" and "Bob changed his profile picture". To support this {{GENDER...}} syntax can be used as shown in example 273 | 274 | ```javascript 275 | const message = "$1 changed {{GENDER:$2|his|her}} profile picture"; 276 | banana.i18n(message, 'Alice', 'female' ); // This gives "Alice changed her profile picture" 277 | banana.i18n(message, 'Bob', 'male' ); // This gives "Bob changed his profile picture" 278 | ``` 279 | 280 | Note that {{GENDER:...}} is not case sensitive. It can be {{gender:...}} too. 281 | 282 | ### Grammar 283 | 284 | ```javascript 285 | const banana = new Banana( 'fi' ); 286 | 287 | const message = "{{grammar:genitive|$1}}"; 288 | 289 | banana.i18n(message, 'talo' ); // This gives "talon" 290 | 291 | banana.locale = 'hy'; // Switch to locale Armenian 292 | banana.i18n(message, 'Մաունա'); // This gives "Մաունայի" 293 | ``` 294 | 295 | ### Directionality-safe isolation 296 | 297 | To avoid BIDI corruption that looks like "(Foo_(Bar", which happens when a string is inserted into a context with the reverse directionality, you can use `{{bidi:…}}`. Directionality-neutral characters at the edge of the string can get wrongly interpreted by the BIDI algorithm. This would let you embed your substituted string into a new BIDI context, //e.g.//: 298 | 299 | "`Shalom, {{bidi:$1}}, hi!`" 300 | 301 | The embedded context's directionality is determined by looking at the argument for `$1`, and then explicitly inserted into the Unicode text, ensuring correct rendering (because then the bidi algorithm "knows" the argument text is a separate context). 302 | 303 | ### Wiki style links 304 | 305 | The message can use [MediaWiki link syntax](https://www.mediawiki.org/wiki/Help:Links). By default this is disabled. To enable support for this, pass `wikilinks=true` option to `Banana` constructor. Example: 306 | 307 | ``` 308 | new Banana('es', { wikilinks: true } ) 309 | ``` 310 | 311 | The original wiki links markup is elaborate, but here we only support simple syntax. 312 | 313 | * Internal links: `[[pageTitle]]` or `[[pageTitle|displayText]]`. For example `[[Apple]]` gives `Apple`. 314 | * External links: `[https://example.com]` or `[https://example.com display text]` 315 | 316 | ### Extending the parser 317 | 318 | Following example illustrates extending the parser to support more parser plugins 319 | 320 | ```js 321 | const banana = new Banana('en'); 322 | banana.registerParserPlugin('sitename', () => { 323 | return 'Wikipedia'; 324 | }); 325 | banana.registerParserPlugin('link', (nodes) => { 326 | return '' + nodes[0] + ''; 327 | }); 328 | ``` 329 | 330 | This will parse the message 331 | ```js 332 | banana.i18n('{{link:{{SITENAME}}|https://en.wikipedia.org}}'); 333 | ``` 334 | to 335 | 336 | ``` 337 | Wikipedia 338 | ``` 339 | 340 | ### Message documentation 341 | 342 | The message keys and messages won't give a enough context about the message being translated to the translator. Whenever a developer adds a new message, it is a usual practice to document the message to a file named qqq.json 343 | with same message key. 344 | 345 | Example qqq.json: 346 | 347 | ```json 348 | { 349 | "@metadata": { 350 | "authors": [ 351 | "Developer Name" 352 | ] 353 | }, 354 | "appname-title": "Application name. Transliteration is recommended", 355 | "appname-sub-title": "Brief explanation of the application", 356 | "appname-header-introduction": "Text for the introduction header", 357 | "appname-about": "About this application text", 358 | "appname-footer": "Footer text" 359 | } 360 | 361 | ``` 362 | 363 | In MediaWiki and its hundreds of extensions, message documentation is a strictly followed practice. There is a grunt task to check whether all messages are documented or not. See https://www.npmjs.org/package/grunt-banana-checker 364 | 365 | ### Translation 366 | 367 | To translate the banana-i18n based application, depending on the expertise of the translator, there are multiple ways. 368 | 369 | - Editing the json files directly - Suitable for translators with technical background. Also suitable if your application is small and you want to work with only a small number of languages 370 | - Providing a translation interface along with your application: Suitable for proprietary or private applications with significant amount of translators 371 | - Using open source translation platforms like translatewiki.net. The MediaWiki and jquery.uls from previous examples use translatewiki.net for crowdsourced message translation. Translatewiki.net can update your code repo at regular intervals with updated translations. Highly recommended if your application is opensource and want it to be localized to as many as languages possible with maximum number of translators. 372 | 373 | ### Frameworks and other programming languages 374 | 375 | * React bindings for banana-i18n https://www.npmjs.com/package/@wikimedia/react.i18n 376 | * A Banana-i18n wrapper to support localization in Vue.js https://www.npmjs.com/package/vue-banana-i18n 377 | * Python version of this library: https://pypi.org/project/banana-i18n/ 378 | * Python Flask integration https://pypi.org/project/Flask-Banana/ 379 | 380 | ## Thanks 381 | 382 | This project is based on [jquery.i18n](github.com/wikimedia/jquery.i18n) library maintained by Wikimedia Foundation. Most of the internationalization related logic comes from that project. In Banana-i18n, jquery dependency was removed and the library was modernized to use in modern web applications. Contributors of jquery.i18n are greatly acknowledged and listed in AUTHORS.md 383 | -------------------------------------------------------------------------------- /src/ast.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Abstract Syntax Tree for a localization message in 'Banana' format 3 | * @param {string} message 4 | * @param {Object} options options 5 | * @param {boolean} [options.wikilinks] whether the wiki style link syntax should be parsed or not 6 | */ 7 | export default function BananaMessage (message, { wikilinks = false } = {}) { 8 | let pos = 0 9 | 10 | // Try parsers until one works, if none work return null 11 | function choice (parserSyntax) { 12 | return () => { 13 | for (let i = 0; i < parserSyntax.length; i++) { 14 | const result = parserSyntax[i]() 15 | 16 | if (result !== null) { 17 | return result 18 | } 19 | } 20 | 21 | return null 22 | } 23 | } 24 | 25 | // Try several parserSyntax-es in a row. 26 | // All must succeed; otherwise, return null. 27 | // This is the only eager one. 28 | function sequence (parserSyntax) { 29 | const originalPos = pos 30 | 31 | const result = [] 32 | 33 | for (let i = 0; i < parserSyntax.length; i++) { 34 | const res = parserSyntax[i]() 35 | 36 | if (res === null) { 37 | pos = originalPos 38 | 39 | return null 40 | } 41 | 42 | result.push(res) 43 | } 44 | 45 | return result 46 | } 47 | 48 | // Run the same parser over and over until it fails. 49 | // Must succeed a minimum of n times; otherwise, return null. 50 | function nOrMore (n, p) { 51 | return () => { 52 | const originalPos = pos 53 | 54 | const result = [] 55 | 56 | let parsed = p() 57 | 58 | while (parsed !== null) { 59 | result.push(parsed) 60 | parsed = p() 61 | } 62 | 63 | if (result.length < n) { 64 | pos = originalPos 65 | 66 | return null 67 | } 68 | 69 | return result 70 | } 71 | } 72 | 73 | // Helpers -- just make parserSyntax out of simpler JS builtin types 74 | 75 | function makeStringParser (s) { 76 | const len = s.length 77 | 78 | return () => { 79 | let result = null 80 | 81 | if (message.slice(pos, pos + len) === s) { 82 | result = s 83 | pos += len 84 | } 85 | 86 | return result 87 | } 88 | } 89 | 90 | function makeRegexParser (regex) { 91 | return () => { 92 | const matches = message.slice(pos).match(regex) 93 | 94 | if (matches === null) { 95 | return null 96 | } 97 | 98 | pos += matches[0].length 99 | 100 | return matches[0] 101 | } 102 | } 103 | 104 | const whitespace = makeRegexParser(/^\s+/) 105 | const pipe = makeStringParser('|') 106 | const colon = makeStringParser(':') 107 | const backslash = makeStringParser('\\') 108 | const anyCharacter = makeRegexParser(/^./) 109 | const dollar = makeStringParser('$') 110 | const digits = makeRegexParser(/^\d+/) 111 | const doubleQuote = makeStringParser('"') 112 | const singleQuote = makeStringParser('\'') 113 | // A literal is any character except the special characters in the message markup 114 | // Special characters are: [, ], {, }, $, \, <, > 115 | // If wikilinks parsing is disabled, treat [ and ] as regular text. 116 | const regularLiteral = wikilinks ? makeRegexParser(/^[^{}[\]$<\\]/) : makeRegexParser(/^[^{}$<\\]/) 117 | const regularLiteralWithoutBar = wikilinks ? makeRegexParser(/^[^{}[\]$\\|]/) : makeRegexParser(/^[^{}$\\|]/) 118 | const regularLiteralWithoutSpace = wikilinks ? makeRegexParser(/^[^{}[\]$\s]/) : makeRegexParser(/^[^{}$\s]/) 119 | 120 | // There is a general pattern: 121 | // parse a thing; 122 | // if it worked, apply transform, 123 | // otherwise return null. 124 | // But using this as a combinator seems to cause problems 125 | // when combined with nOrMore(). 126 | // May be some scoping issue. 127 | function transform (p, fn) { 128 | return () => { 129 | const result = p() 130 | return result === null ? null : fn(result) 131 | } 132 | } 133 | 134 | // Used to define "literals" within template parameters. The pipe 135 | // character is the parameter delimeter, so by default 136 | // it is not a literal in the parameter 137 | function literalWithoutBar () { 138 | const result = nOrMore(1, escapedOrLiteralWithoutBar)() 139 | 140 | return result === null ? null : result.join('') 141 | } 142 | 143 | // Used to define "literals" within template parameters. 144 | // The pipe character is the parameter delimeter, so by default 145 | // it is not a literal in the parameter 146 | function literal () { 147 | const result = nOrMore(1, escapedOrRegularLiteral)() 148 | return result === null ? null : result.join('') 149 | } 150 | 151 | const escapedOrLiteralWithoutSpace = choice([ 152 | escapedLiteral, 153 | regularLiteralWithoutSpace 154 | ]) 155 | 156 | // Used to define "literals" without spaces, in space-delimited situations 157 | function literalWithoutSpace () { 158 | const result = nOrMore(1, escapedOrLiteralWithoutSpace)() 159 | return result === null ? null : result.join('') 160 | } 161 | 162 | function escapedLiteral () { 163 | const result = sequence([backslash, anyCharacter]) 164 | 165 | return result === null ? null : result[1] 166 | } 167 | 168 | choice([escapedLiteral, regularLiteralWithoutSpace]) 169 | const escapedOrLiteralWithoutBar = choice([escapedLiteral, regularLiteralWithoutBar]) 170 | const escapedOrRegularLiteral = choice([escapedLiteral, regularLiteral]) 171 | 172 | function replacement () { 173 | const result = sequence([dollar, digits]) 174 | 175 | if (result === null) { 176 | return null 177 | } 178 | 179 | return ['REPLACE', parseInt(result[1], 10) - 1] 180 | } 181 | 182 | const templateName = transform( 183 | // see $wgLegalTitleChars 184 | // not allowing : due to the need to catch "PLURAL:$1" 185 | makeRegexParser(/^[ !"$&'()*,./0-9;=?@A-Z^_`a-z~\x80-\xFF+-]+/), 186 | 187 | function (result) { 188 | return result.toString() 189 | } 190 | ) 191 | 192 | function templateParam () { 193 | const result = sequence([pipe, nOrMore(0, paramExpression)]) 194 | 195 | if (result === null) { 196 | return null 197 | } 198 | 199 | const expr = result[1] 200 | 201 | // use a "CONCAT" operator if there are multiple nodes, 202 | // otherwise return the first node, raw. 203 | return expr.length > 1 ? ['CONCAT'].concat(expr) : expr[0] 204 | } 205 | 206 | function templateWithReplacement () { 207 | const result = sequence([templateName, colon, replacement]) 208 | 209 | return result === null ? null : [result[0], result[2]] 210 | } 211 | 212 | function templateWithOutReplacement () { 213 | const result = sequence([templateName, colon, paramExpression]) 214 | 215 | return result === null ? null : [result[0], result[2]] 216 | } 217 | 218 | function templateWithOutFirstParameter () { 219 | const result = sequence([templateName, colon]) 220 | return result === null ? null : [result[0], ''] 221 | } 222 | 223 | const templateContents = choice([ 224 | function () { 225 | const res = sequence([ 226 | // templates can have placeholders for dynamic 227 | // replacement eg: {{PLURAL:$1|one car|$1 cars}} 228 | // or no placeholders eg:{{GRAMMAR:genitive|{{SITENAME}}} 229 | // Templates can also have empty first param eg:{{GENDER:|A|B|C}} 230 | // to indicate current user in the context. We need to parse them without 231 | // error, but can only fallback to gender neutral form. 232 | choice([templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter]), 233 | nOrMore(0, templateParam) 234 | ]) 235 | 236 | return res === null ? null : res[0].concat(res[1]) 237 | }, 238 | function () { 239 | const res = sequence([templateName, nOrMore(0, templateParam)]) 240 | 241 | if (res === null) { 242 | return null 243 | } 244 | 245 | return [res[0]].concat(res[1]) 246 | } 247 | ]) 248 | 249 | const openTemplate = makeStringParser('{{') 250 | const closeTemplate = makeStringParser('}}') 251 | const openWikilink = makeStringParser('[[') 252 | const closeWikilink = makeStringParser(']]') 253 | const openExtlink = makeStringParser('[') 254 | const closeExtlink = makeStringParser(']') 255 | 256 | /** 257 | * An expression in the form of {{...}} 258 | */ 259 | function template () { 260 | const result = sequence([openTemplate, templateContents, closeTemplate]) 261 | 262 | return result === null ? null : result[1] 263 | } 264 | 265 | function pipedWikilink () { 266 | const result = sequence([ 267 | nOrMore(1, paramExpression), 268 | pipe, 269 | nOrMore(1, expression) 270 | ]) 271 | return result === null 272 | ? null 273 | : [ 274 | ['CONCAT'].concat(result[0]), 275 | ['CONCAT'].concat(result[2]) 276 | ] 277 | } 278 | 279 | function unpipedWikilink () { 280 | const result = sequence([ 281 | nOrMore(1, paramExpression) 282 | ]) 283 | return result === null 284 | ? null 285 | : [ 286 | ['CONCAT'].concat(result[0]) 287 | ] 288 | } 289 | 290 | const wikilinkContents = choice([ 291 | pipedWikilink, 292 | unpipedWikilink 293 | ]) 294 | 295 | function wikilink () { 296 | let result = null 297 | 298 | const parsedResult = sequence([ 299 | openWikilink, 300 | wikilinkContents, 301 | closeWikilink 302 | ]) 303 | 304 | if (parsedResult !== null) { 305 | const parsedLinkContents = parsedResult[1] 306 | result = ['WIKILINK'].concat(parsedLinkContents) 307 | } 308 | 309 | return result 310 | } 311 | 312 | // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo bar], etc. are allowed 313 | function extlink () { 314 | let result = null 315 | 316 | const parsedResult = sequence([ 317 | openExtlink, 318 | nOrMore(1, nonWhitespaceExpression), 319 | whitespace, 320 | nOrMore(1, expression), 321 | closeExtlink 322 | ]) 323 | 324 | if (parsedResult !== null) { 325 | // When the entire link target is a single parameter, we can't use CONCAT, as we allow 326 | // passing fancy parameters (like a whole jQuery object or a function) to use for the 327 | // link. Check only if it's a single match, since we can either do CONCAT or not for 328 | // singles with the same effect. 329 | const target = parsedResult[1].length === 1 330 | ? parsedResult[1][0] 331 | : ['CONCAT'].concat(parsedResult[1]) 332 | result = [ 333 | 'EXTLINK', 334 | target, 335 | ['CONCAT'].concat(parsedResult[3]) 336 | ] 337 | } 338 | 339 | return result 340 | } 341 | 342 | const asciiAlphabetLiteral = makeRegexParser(/^[A-Za-z]+/) 343 | 344 | /** 345 | * Checks if HTML is allowed 346 | * 347 | * @param {string} startTagName HTML start tag name 348 | * @param {string} endTagName HTML start tag name 349 | * @param {Object} attributes array of consecutive key value pairs, 350 | * with index 2 * n being a name and 2 * n + 1 the associated value 351 | * @return {boolean} true if this is HTML is allowed, false otherwise 352 | */ 353 | function isAllowedHtml (startTagName, endTagName, attributes, settings = { 354 | // Whitelist for allowed HTML elements in wikitext. 355 | // Self-closing tags are not currently supported. 356 | allowedHtmlElements: ['b', 'bdi', 'del', 'i', 'ins', 'u', 'font', 'big', 'small', 'sub', 357 | 'sup', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'cite', 'code', 'em', 's', 'strike', 'strong', 358 | 'tt', 'var', 'div', 'center', 'blockquote', 'ol', 'ul', 'dl', 'table', 'caption', 'pre', 359 | 'ruby', 'rb', 'rp', 'rt', 'rtc', 'p', 'span', 'abbr', 'dfn', 'kbd', 'samp', 'data', 'time', 360 | 'mark', 'li', 'dt', 'dd'], 361 | // Key tag name, value allowed attributes for that tag. 362 | // Sourced from Parsoid's Sanitizer::setupAttributeWhitelist 363 | allowedHtmlCommonAttributes: [ 364 | // HTML 365 | 'id', 366 | 'class', 367 | 'style', 368 | 'lang', 369 | 'dir', 370 | 'title', 371 | // WAI-ARIA 372 | 'aria-describedby', 373 | 'aria-flowto', 374 | 'aria-hidden', 375 | 'aria-label', 376 | 'aria-labelledby', 377 | 'aria-owns', 378 | 'role', 379 | // RDFa 380 | // These attributes are specified in section 9 of 381 | // https://www.w3.org/TR/2008/REC-rdfa-syntax-20081014 382 | 'about', 383 | 'property', 384 | 'resource', 385 | 'datatype', 386 | 'typeof', 387 | // Microdata. These are specified by 388 | // https://html.spec.whatwg.org/multipage/microdata.html#the-microdata-model 389 | 'itemid', 390 | 'itemprop', 391 | 'itemref', 392 | 'itemscope', 393 | 'itemtype' 394 | ], 395 | 396 | // Attributes allowed for specific elements. 397 | // Key is element name in lower case 398 | // Value is array of allowed attributes for that element 399 | allowedHtmlAttributesByElement: {} 400 | }) { 401 | startTagName = startTagName.toLowerCase() 402 | endTagName = endTagName.toLowerCase() 403 | if (startTagName !== endTagName || settings.allowedHtmlElements.indexOf(startTagName) === -1) { 404 | return false 405 | } 406 | 407 | const badStyle = /[\000-\010\013\016-\037\177]|expression|filter\s*:|accelerator\s*:|-o-link\s*:|-o-link-source\s*:|-o-replace\s*:|url\s*\(|image\s*\(|image-set\s*\(/i 408 | 409 | for (let i = 0, len = attributes.length; i < len; i += 2) { 410 | const attributeName = attributes[i] 411 | if (settings.allowedHtmlCommonAttributes.indexOf(attributeName) === -1 && 412 | (settings.allowedHtmlAttributesByElement[startTagName] || []).indexOf(attributeName) === -1) { 413 | return false 414 | } 415 | if (attributeName === 'style' && attributes[i + 1].search(badStyle) !== -1) { 416 | return false 417 | } 418 | } 419 | 420 | return true 421 | } 422 | 423 | function doubleQuotedHtmlAttributeValue () { 424 | const htmlDoubleQuoteAttributeValue = makeRegexParser(/^[^"]*/) 425 | 426 | const parsedResult = sequence([ 427 | doubleQuote, 428 | htmlDoubleQuoteAttributeValue, 429 | doubleQuote 430 | ]) 431 | return parsedResult === null ? null : parsedResult[1] 432 | } 433 | 434 | function singleQuotedHtmlAttributeValue () { 435 | const htmlSingleQuoteAttributeValue = makeRegexParser(/^[^']*/) 436 | const parsedResult = sequence([ 437 | singleQuote, 438 | htmlSingleQuoteAttributeValue, 439 | singleQuote 440 | ]) 441 | return parsedResult === null ? null : parsedResult[1] 442 | } 443 | 444 | function htmlAttribute () { 445 | const htmlAttributeEquals = makeRegexParser(/^\s*=\s*/) 446 | const parsedResult = sequence([ 447 | whitespace, 448 | asciiAlphabetLiteral, 449 | htmlAttributeEquals, 450 | choice([ 451 | doubleQuotedHtmlAttributeValue, 452 | singleQuotedHtmlAttributeValue 453 | ]) 454 | ]) 455 | return parsedResult === null ? null : [parsedResult[1], parsedResult[3]] 456 | } 457 | 458 | function htmlAttributes () { 459 | const parsedResult = nOrMore(0, htmlAttribute)() 460 | // Un-nest attributes array due to structure of emitter operations. 461 | return Array.prototype.concat.apply(['HTMLATTRIBUTES'], parsedResult) 462 | } 463 | 464 | // Parse, validate and escape HTML content in messages using a whitelisted tag names 465 | // and attributes. 466 | function html () { 467 | let result = null 468 | 469 | // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match. 470 | // 1. open through closeHtmlTag 471 | // 2. expression 472 | // 3. openHtmlEnd through close 473 | // This will allow recording the positions to reconstruct if HTML is to be treated as text. 474 | 475 | const startOpenTagPos = pos 476 | 477 | const openHtmlStartTag = makeStringParser('<') 478 | const optionalForwardSlash = makeRegexParser(/^\/?/) 479 | const closeHtmlTag = makeRegexParser(/^\s*>/) 480 | 481 | const parsedOpenTagResult = sequence([ 482 | openHtmlStartTag, 483 | asciiAlphabetLiteral, 484 | htmlAttributes, 485 | optionalForwardSlash, 486 | closeHtmlTag 487 | ]) 488 | 489 | if (parsedOpenTagResult === null) { 490 | return null 491 | } 492 | 493 | const endOpenTagPos = pos 494 | const startTagName = parsedOpenTagResult[1] 495 | 496 | const parsedHtmlContents = nOrMore(0, expression)() 497 | 498 | const startCloseTagPos = pos 499 | const openHtmlEndTag = makeStringParser('[[Foo|bar]] 527 | // 528 | // results in '<script>' and '</script>' 529 | // (not treated as an HTML tag), surrounding a fully 530 | // parsed HTML link. 531 | // 532 | // Concatenate everything from the tag, flattening the contents. 533 | const escapeHTML = (unsafeContent) => unsafeContent 534 | .replace(/&/g, '&') 535 | .replace(//g, '>') 537 | .replace(/"/g, '"') 538 | .replace(/'/g, ''') 539 | result = ['CONCAT', escapeHTML(message.slice(startOpenTagPos, endOpenTagPos))] 540 | .concat(parsedHtmlContents, escapeHTML(message.slice(startCloseTagPos, endCloseTagPos))) 541 | } 542 | 543 | return result 544 | } 545 | 546 | const nonWhitespaceExpression = choice([ 547 | template, 548 | replacement, 549 | wikilink, 550 | extlink, 551 | literalWithoutSpace 552 | ]) 553 | 554 | const expression = choice([ 555 | template, 556 | replacement, 557 | wikilink, 558 | extlink, 559 | html, 560 | literal 561 | ]) 562 | 563 | const paramExpression = choice([template, replacement, literalWithoutBar]) 564 | 565 | function start () { 566 | const result = nOrMore(0, expression)() 567 | 568 | if (result === null) { 569 | return null 570 | } 571 | 572 | return ['CONCAT'].concat(result) 573 | } 574 | 575 | const result = start() 576 | 577 | /* 578 | * For success, the pos must have gotten to the end of the input 579 | * and returned a non-null. 580 | * n.b. This is part of language infrastructure, so we do not throw an internationalizable message. 581 | */ 582 | if (result === null || pos !== message.length) { 583 | throw new Error('Parse error at position ' + pos.toString() + ' in input: ' + message) 584 | } 585 | 586 | return result 587 | } 588 | -------------------------------------------------------------------------------- /src/emitter.js: -------------------------------------------------------------------------------- 1 | import languages from './languages/index.js' 2 | 3 | /** 4 | * Matches the first strong directionality codepoint: 5 | * - in group 1 if it is LTR 6 | * - in group 2 if it is RTL 7 | * Does not match if there is no strong directionality codepoint. 8 | * 9 | * Generated by UnicodeJS (see tools/strongDir) from the UCD; see 10 | * https://phabricator.wikimedia.org/diffusion/GUJS/ . 11 | */ 12 | 13 | const strongDirRegExp = new RegExp( 14 | /* eslint-disable-next-line no-misleading-character-class */ 15 | '(?:' + 16 | '(' + 17 | '[\u0041-\u005a\u0061-\u007a\u00aa\u00b5\u00ba\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02b8\u02bb-\u02c1\u02d0\u02d1\u02e0-\u02e4\u02ee\u0370-\u0373\u0376\u0377\u037a-\u037d\u037f\u0386\u0388-\u038a\u038c\u038e-\u03a1\u03a3-\u03f5\u03f7-\u0482\u048a-\u052f\u0531-\u0556\u0559-\u055f\u0561-\u0587\u0589\u0903-\u0939\u093b\u093d-\u0940\u0949-\u094c\u094e-\u0950\u0958-\u0961\u0964-\u0980\u0982\u0983\u0985-\u098c\u098f\u0990\u0993-\u09a8\u09aa-\u09b0\u09b2\u09b6-\u09b9\u09bd-\u09c0\u09c7\u09c8\u09cb\u09cc\u09ce\u09d7\u09dc\u09dd\u09df-\u09e1\u09e6-\u09f1\u09f4-\u09fa\u0a03\u0a05-\u0a0a\u0a0f\u0a10\u0a13-\u0a28\u0a2a-\u0a30\u0a32\u0a33\u0a35\u0a36\u0a38\u0a39\u0a3e-\u0a40\u0a59-\u0a5c\u0a5e\u0a66-\u0a6f\u0a72-\u0a74\u0a83\u0a85-\u0a8d\u0a8f-\u0a91\u0a93-\u0aa8\u0aaa-\u0ab0\u0ab2\u0ab3\u0ab5-\u0ab9\u0abd-\u0ac0\u0ac9\u0acb\u0acc\u0ad0\u0ae0\u0ae1\u0ae6-\u0af0\u0af9\u0b02\u0b03\u0b05-\u0b0c\u0b0f\u0b10\u0b13-\u0b28\u0b2a-\u0b30\u0b32\u0b33\u0b35-\u0b39\u0b3d\u0b3e\u0b40\u0b47\u0b48\u0b4b\u0b4c\u0b57\u0b5c\u0b5d\u0b5f-\u0b61\u0b66-\u0b77\u0b83\u0b85-\u0b8a\u0b8e-\u0b90\u0b92-\u0b95\u0b99\u0b9a\u0b9c\u0b9e\u0b9f\u0ba3\u0ba4\u0ba8-\u0baa\u0bae-\u0bb9\u0bbe\u0bbf\u0bc1\u0bc2\u0bc6-\u0bc8\u0bca-\u0bcc\u0bd0\u0bd7\u0be6-\u0bf2\u0c01-\u0c03\u0c05-\u0c0c\u0c0e-\u0c10\u0c12-\u0c28\u0c2a-\u0c39\u0c3d\u0c41-\u0c44\u0c58-\u0c5a\u0c60\u0c61\u0c66-\u0c6f\u0c7f\u0c82\u0c83\u0c85-\u0c8c\u0c8e-\u0c90\u0c92-\u0ca8\u0caa-\u0cb3\u0cb5-\u0cb9\u0cbd-\u0cc4\u0cc6-\u0cc8\u0cca\u0ccb\u0cd5\u0cd6\u0cde\u0ce0\u0ce1\u0ce6-\u0cef\u0cf1\u0cf2\u0d02\u0d03\u0d05-\u0d0c\u0d0e-\u0d10\u0d12-\u0d3a\u0d3d-\u0d40\u0d46-\u0d48\u0d4a-\u0d4c\u0d4e\u0d57\u0d5f-\u0d61\u0d66-\u0d75\u0d79-\u0d7f\u0d82\u0d83\u0d85-\u0d96\u0d9a-\u0db1\u0db3-\u0dbb\u0dbd\u0dc0-\u0dc6\u0dcf-\u0dd1\u0dd8-\u0ddf\u0de6-\u0def\u0df2-\u0df4\u0e01-\u0e30\u0e32\u0e33\u0e40-\u0e46\u0e4f-\u0e5b\u0e81\u0e82\u0e84\u0e87\u0e88\u0e8a\u0e8d\u0e94-\u0e97\u0e99-\u0e9f\u0ea1-\u0ea3\u0ea5\u0ea7\u0eaa\u0eab\u0ead-\u0eb0\u0eb2\u0eb3\u0ebd\u0ec0-\u0ec4\u0ec6\u0ed0-\u0ed9\u0edc-\u0edf\u0f00-\u0f17\u0f1a-\u0f34\u0f36\u0f38\u0f3e-\u0f47\u0f49-\u0f6c\u0f7f\u0f85\u0f88-\u0f8c\u0fbe-\u0fc5\u0fc7-\u0fcc\u0fce-\u0fda\u1000-\u102c\u1031\u1038\u103b\u103c\u103f-\u1057\u105a-\u105d\u1061-\u1070\u1075-\u1081\u1083\u1084\u1087-\u108c\u108e-\u109c\u109e-\u10c5\u10c7\u10cd\u10d0-\u1248\u124a-\u124d\u1250-\u1256\u1258\u125a-\u125d\u1260-\u1288\u128a-\u128d\u1290-\u12b0\u12b2-\u12b5\u12b8-\u12be\u12c0\u12c2-\u12c5\u12c8-\u12d6\u12d8-\u1310\u1312-\u1315\u1318-\u135a\u1360-\u137c\u1380-\u138f\u13a0-\u13f5\u13f8-\u13fd\u1401-\u167f\u1681-\u169a\u16a0-\u16f8\u1700-\u170c\u170e-\u1711\u1720-\u1731\u1735\u1736\u1740-\u1751\u1760-\u176c\u176e-\u1770\u1780-\u17b3\u17b6\u17be-\u17c5\u17c7\u17c8\u17d4-\u17da\u17dc\u17e0-\u17e9\u1810-\u1819\u1820-\u1877\u1880-\u18a8\u18aa\u18b0-\u18f5\u1900-\u191e\u1923-\u1926\u1929-\u192b\u1930\u1931\u1933-\u1938\u1946-\u196d\u1970-\u1974\u1980-\u19ab\u19b0-\u19c9\u19d0-\u19da\u1a00-\u1a16\u1a19\u1a1a\u1a1e-\u1a55\u1a57\u1a61\u1a63\u1a64\u1a6d-\u1a72\u1a80-\u1a89\u1a90-\u1a99\u1aa0-\u1aad\u1b04-\u1b33\u1b35\u1b3b\u1b3d-\u1b41\u1b43-\u1b4b\u1b50-\u1b6a\u1b74-\u1b7c\u1b82-\u1ba1\u1ba6\u1ba7\u1baa\u1bae-\u1be5\u1be7\u1bea-\u1bec\u1bee\u1bf2\u1bf3\u1bfc-\u1c2b\u1c34\u1c35\u1c3b-\u1c49\u1c4d-\u1c7f\u1cc0-\u1cc7\u1cd3\u1ce1\u1ce9-\u1cec\u1cee-\u1cf3\u1cf5\u1cf6\u1d00-\u1dbf\u1e00-\u1f15\u1f18-\u1f1d\u1f20-\u1f45\u1f48-\u1f4d\u1f50-\u1f57\u1f59\u1f5b\u1f5d\u1f5f-\u1f7d\u1f80-\u1fb4\u1fb6-\u1fbc\u1fbe\u1fc2-\u1fc4\u1fc6-\u1fcc\u1fd0-\u1fd3\u1fd6-\u1fdb\u1fe0-\u1fec\u1ff2-\u1ff4\u1ff6-\u1ffc\u200e\u2071\u207f\u2090-\u209c\u2102\u2107\u210a-\u2113\u2115\u2119-\u211d\u2124\u2126\u2128\u212a-\u212d\u212f-\u2139\u213c-\u213f\u2145-\u2149\u214e\u214f\u2160-\u2188\u2336-\u237a\u2395\u249c-\u24e9\u26ac\u2800-\u28ff\u2c00-\u2c2e\u2c30-\u2c5e\u2c60-\u2ce4\u2ceb-\u2cee\u2cf2\u2cf3\u2d00-\u2d25\u2d27\u2d2d\u2d30-\u2d67\u2d6f\u2d70\u2d80-\u2d96\u2da0-\u2da6\u2da8-\u2dae\u2db0-\u2db6\u2db8-\u2dbe\u2dc0-\u2dc6\u2dc8-\u2dce\u2dd0-\u2dd6\u2dd8-\u2dde\u3005-\u3007\u3021-\u3029\u302e\u302f\u3031-\u3035\u3038-\u303c\u3041-\u3096\u309d-\u309f\u30a1-\u30fa\u30fc-\u30ff\u3105-\u312d\u3131-\u318e\u3190-\u31ba\u31f0-\u321c\u3220-\u324f\u3260-\u327b\u327f-\u32b0\u32c0-\u32cb\u32d0-\u32fe\u3300-\u3376\u337b-\u33dd\u33e0-\u33fe\u3400-\u4db5\u4e00-\u9fd5\ua000-\ua48c\ua4d0-\ua60c\ua610-\ua62b\ua640-\ua66e\ua680-\ua69d\ua6a0-\ua6ef\ua6f2-\ua6f7\ua722-\ua787\ua789-\ua7ad\ua7b0-\ua7b7\ua7f7-\ua801\ua803-\ua805\ua807-\ua80a\ua80c-\ua824\ua827\ua830-\ua837\ua840-\ua873\ua880-\ua8c3\ua8ce-\ua8d9\ua8f2-\ua8fd\ua900-\ua925\ua92e-\ua946\ua952\ua953\ua95f-\ua97c\ua983-\ua9b2\ua9b4\ua9b5\ua9ba\ua9bb\ua9bd-\ua9cd\ua9cf-\ua9d9\ua9de-\ua9e4\ua9e6-\ua9fe\uaa00-\uaa28\uaa2f\uaa30\uaa33\uaa34\uaa40-\uaa42\uaa44-\uaa4b\uaa4d\uaa50-\uaa59\uaa5c-\uaa7b\uaa7d-\uaaaf\uaab1\uaab5\uaab6\uaab9-\uaabd\uaac0\uaac2\uaadb-\uaaeb\uaaee-\uaaf5\uab01-\uab06\uab09-\uab0e\uab11-\uab16\uab20-\uab26\uab28-\uab2e\uab30-\uab65\uab70-\uabe4\uabe6\uabe7\uabe9-\uabec\uabf0-\uabf9\uac00-\ud7a3\ud7b0-\ud7c6\ud7cb-\ud7fb\ue000-\ufa6d\ufa70-\ufad9\ufb00-\ufb06\ufb13-\ufb17\uff21-\uff3a\uff41-\uff5a\uff66-\uffbe\uffc2-\uffc7\uffca-\uffcf\uffd2-\uffd7\uffda-\uffdc]|\ud800[\udc00-\udc0b]|\ud800[\udc0d-\udc26]|\ud800[\udc28-\udc3a]|\ud800\udc3c|\ud800\udc3d|\ud800[\udc3f-\udc4d]|\ud800[\udc50-\udc5d]|\ud800[\udc80-\udcfa]|\ud800\udd00|\ud800\udd02|\ud800[\udd07-\udd33]|\ud800[\udd37-\udd3f]|\ud800[\uddd0-\uddfc]|\ud800[\ude80-\ude9c]|\ud800[\udea0-\uded0]|\ud800[\udf00-\udf23]|\ud800[\udf30-\udf4a]|\ud800[\udf50-\udf75]|\ud800[\udf80-\udf9d]|\ud800[\udf9f-\udfc3]|\ud800[\udfc8-\udfd5]|\ud801[\udc00-\udc9d]|\ud801[\udca0-\udca9]|\ud801[\udd00-\udd27]|\ud801[\udd30-\udd63]|\ud801\udd6f|\ud801[\ude00-\udf36]|\ud801[\udf40-\udf55]|\ud801[\udf60-\udf67]|\ud804\udc00|\ud804[\udc02-\udc37]|\ud804[\udc47-\udc4d]|\ud804[\udc66-\udc6f]|\ud804[\udc82-\udcb2]|\ud804\udcb7|\ud804\udcb8|\ud804[\udcbb-\udcc1]|\ud804[\udcd0-\udce8]|\ud804[\udcf0-\udcf9]|\ud804[\udd03-\udd26]|\ud804\udd2c|\ud804[\udd36-\udd43]|\ud804[\udd50-\udd72]|\ud804[\udd74-\udd76]|\ud804[\udd82-\uddb5]|\ud804[\uddbf-\uddc9]|\ud804\uddcd|\ud804[\uddd0-\udddf]|\ud804[\udde1-\uddf4]|\ud804[\ude00-\ude11]|\ud804[\ude13-\ude2e]|\ud804\ude32|\ud804\ude33|\ud804\ude35|\ud804[\ude38-\ude3d]|\ud804[\ude80-\ude86]|\ud804\ude88|\ud804[\ude8a-\ude8d]|\ud804[\ude8f-\ude9d]|\ud804[\ude9f-\udea9]|\ud804[\udeb0-\udede]|\ud804[\udee0-\udee2]|\ud804[\udef0-\udef9]|\ud804\udf02|\ud804\udf03|\ud804[\udf05-\udf0c]|\ud804\udf0f|\ud804\udf10|\ud804[\udf13-\udf28]|\ud804[\udf2a-\udf30]|\ud804\udf32|\ud804\udf33|\ud804[\udf35-\udf39]|\ud804[\udf3d-\udf3f]|\ud804[\udf41-\udf44]|\ud804\udf47|\ud804\udf48|\ud804[\udf4b-\udf4d]|\ud804\udf50|\ud804\udf57|\ud804[\udf5d-\udf63]|\ud805[\udc80-\udcb2]|\ud805\udcb9|\ud805[\udcbb-\udcbe]|\ud805\udcc1|\ud805[\udcc4-\udcc7]|\ud805[\udcd0-\udcd9]|\ud805[\udd80-\uddb1]|\ud805[\uddb8-\uddbb]|\ud805\uddbe|\ud805[\uddc1-\udddb]|\ud805[\ude00-\ude32]|\ud805\ude3b|\ud805\ude3c|\ud805\ude3e|\ud805[\ude41-\ude44]|\ud805[\ude50-\ude59]|\ud805[\ude80-\udeaa]|\ud805\udeac|\ud805\udeae|\ud805\udeaf|\ud805\udeb6|\ud805[\udec0-\udec9]|\ud805[\udf00-\udf19]|\ud805\udf20|\ud805\udf21|\ud805\udf26|\ud805[\udf30-\udf3f]|\ud806[\udca0-\udcf2]|\ud806\udcff|\ud806[\udec0-\udef8]|\ud808[\udc00-\udf99]|\ud809[\udc00-\udc6e]|\ud809[\udc70-\udc74]|\ud809[\udc80-\udd43]|\ud80c[\udc00-\udfff]|\ud80d[\udc00-\udc2e]|\ud811[\udc00-\ude46]|\ud81a[\udc00-\ude38]|\ud81a[\ude40-\ude5e]|\ud81a[\ude60-\ude69]|\ud81a\ude6e|\ud81a\ude6f|\ud81a[\uded0-\udeed]|\ud81a\udef5|\ud81a[\udf00-\udf2f]|\ud81a[\udf37-\udf45]|\ud81a[\udf50-\udf59]|\ud81a[\udf5b-\udf61]|\ud81a[\udf63-\udf77]|\ud81a[\udf7d-\udf8f]|\ud81b[\udf00-\udf44]|\ud81b[\udf50-\udf7e]|\ud81b[\udf93-\udf9f]|\ud82c\udc00|\ud82c\udc01|\ud82f[\udc00-\udc6a]|\ud82f[\udc70-\udc7c]|\ud82f[\udc80-\udc88]|\ud82f[\udc90-\udc99]|\ud82f\udc9c|\ud82f\udc9f|\ud834[\udc00-\udcf5]|\ud834[\udd00-\udd26]|\ud834[\udd29-\udd66]|\ud834[\udd6a-\udd72]|\ud834\udd83|\ud834\udd84|\ud834[\udd8c-\udda9]|\ud834[\uddae-\udde8]|\ud834[\udf60-\udf71]|\ud835[\udc00-\udc54]|\ud835[\udc56-\udc9c]|\ud835\udc9e|\ud835\udc9f|\ud835\udca2|\ud835\udca5|\ud835\udca6|\ud835[\udca9-\udcac]|\ud835[\udcae-\udcb9]|\ud835\udcbb|\ud835[\udcbd-\udcc3]|\ud835[\udcc5-\udd05]|\ud835[\udd07-\udd0a]|\ud835[\udd0d-\udd14]|\ud835[\udd16-\udd1c]|\ud835[\udd1e-\udd39]|\ud835[\udd3b-\udd3e]|\ud835[\udd40-\udd44]|\ud835\udd46|\ud835[\udd4a-\udd50]|\ud835[\udd52-\udea5]|\ud835[\udea8-\udeda]|\ud835[\udedc-\udf14]|\ud835[\udf16-\udf4e]|\ud835[\udf50-\udf88]|\ud835[\udf8a-\udfc2]|\ud835[\udfc4-\udfcb]|\ud836[\udc00-\uddff]|\ud836[\ude37-\ude3a]|\ud836[\ude6d-\ude74]|\ud836[\ude76-\ude83]|\ud836[\ude85-\ude8b]|\ud83c[\udd10-\udd2e]|\ud83c[\udd30-\udd69]|\ud83c[\udd70-\udd9a]|\ud83c[\udde6-\ude02]|\ud83c[\ude10-\ude3a]|\ud83c[\ude40-\ude48]|\ud83c\ude50|\ud83c\ude51|[\ud840-\ud868][\udc00-\udfff]|\ud869[\udc00-\uded6]|\ud869[\udf00-\udfff]|[\ud86a-\ud86c][\udc00-\udfff]|\ud86d[\udc00-\udf34]|\ud86d[\udf40-\udfff]|\ud86e[\udc00-\udc1d]|\ud86e[\udc20-\udfff]|[\ud86f-\ud872][\udc00-\udfff]|\ud873[\udc00-\udea1]|\ud87e[\udc00-\ude1d]|[\udb80-\udbbe][\udc00-\udfff]|\udbbf[\udc00-\udffd]|[\udbc0-\udbfe][\udc00-\udfff]|\udbff[\udc00-\udffd]' + 18 | ')|(' + 19 | '[\u0590\u05be\u05c0\u05c3\u05c6\u05c8-\u05ff\u07c0-\u07ea\u07f4\u07f5\u07fa-\u0815\u081a\u0824\u0828\u082e-\u0858\u085c-\u089f\u200f\ufb1d\ufb1f-\ufb28\ufb2a-\ufb4f\u0608\u060b\u060d\u061b-\u064a\u066d-\u066f\u0671-\u06d5\u06e5\u06e6\u06ee\u06ef\u06fa-\u0710\u0712-\u072f\u074b-\u07a5\u07b1-\u07bf\u08a0-\u08e2\ufb50-\ufd3d\ufd40-\ufdcf\ufdf0-\ufdfc\ufdfe\ufdff\ufe70-\ufefe]|\ud802[\udc00-\udd1e]|\ud802[\udd20-\ude00]|\ud802\ude04|\ud802[\ude07-\ude0b]|\ud802[\ude10-\ude37]|\ud802[\ude3b-\ude3e]|\ud802[\ude40-\udee4]|\ud802[\udee7-\udf38]|\ud802[\udf40-\udfff]|\ud803[\udc00-\ude5f]|\ud803[\ude7f-\udfff]|\ud83a[\udc00-\udccf]|\ud83a[\udcd7-\udfff]|\ud83b[\udc00-\uddff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\ude00-\udeef]|\ud83b[\udef2-\udeff]' + 20 | ')' + 21 | ')' 22 | ) 23 | 24 | class BananaEmitter { 25 | constructor (locale) { 26 | this.locale = normalizeLocale(locale) 27 | this.language = new (languages[this.locale] || languages.default)(this.locale) 28 | } 29 | 30 | /** 31 | * (We put this method definition here, and not in prototype, to make 32 | * sure it's not overwritten by any magic.) Walk entire node structure, 33 | * applying replacements and template functions when appropriate 34 | * 35 | * @param {Mixed} node abstract syntax tree (top node or subnode) 36 | * @param {Array} replacements for $1, $2, ... $n 37 | * @return {Mixed} single-string node or array of nodes suitable for 38 | * jQuery appending. 39 | */ 40 | emit (node, replacements) { 41 | let ret 42 | let subnodes 43 | let operation 44 | 45 | switch (typeof node) { 46 | case 'string': 47 | case 'number': 48 | ret = node 49 | break 50 | case 'object': 51 | // node is an array of nodes 52 | subnodes = node.slice(1).map((n) => this.emit(n, replacements)) 53 | 54 | operation = node[0].toLowerCase() 55 | 56 | if (typeof this[operation] === 'function') { 57 | ret = this[operation](subnodes, replacements) 58 | } else { 59 | throw new Error('unknown operation "' + operation + '"') 60 | } 61 | 62 | break 63 | case 'undefined': 64 | // Parsing the empty string (as an entire expression, or as a 65 | // paramExpression in a template) results in undefined 66 | // Perhaps a more clever parser can detect this, and return the 67 | // empty string? Or is that useful information? 68 | // The logical thing is probably to return the empty string here 69 | // when we encounter undefined. 70 | ret = '' 71 | break 72 | default: 73 | throw new Error('unexpected type in AST: ' + typeof node) 74 | } 75 | 76 | return ret 77 | } 78 | 79 | /** 80 | * Parsing has been applied depth-first we can assume that all nodes 81 | * here are single nodes Must return a single node to parents -- a 82 | * jQuery with synthetic span However, unwrap any other synthetic spans 83 | * in our children and pass them upwards 84 | * 85 | * @param {Array} nodes Mixed, some single nodes, some arrays of nodes. 86 | * @return {string} 87 | */ 88 | concat (nodes) { 89 | let result = '' 90 | 91 | nodes.forEach((node) => { 92 | // strings, integers, anything else 93 | result += node 94 | }) 95 | 96 | return result 97 | } 98 | 99 | /** 100 | * Return escaped replacement of correct index, or string if 101 | * unavailable. Note that we expect the parsed parameter to be 102 | * zero-based. i.e. $1 should have become [ 0 ]. if the specified 103 | * parameter is not found return the same string (e.g. "$99" -> 104 | parameter 98 -> not found -> return "$99" ) TODO throw error if 105 | * nodes.length > 1 ? 106 | * 107 | * @param {Array} nodes One element, integer, n >= 0 108 | * @param {Array} replacements for $1, $2, ... $n 109 | * @return {string} replacement 110 | */ 111 | replace (nodes, replacements) { 112 | const index = parseInt(nodes[0], 10) 113 | 114 | if (index < replacements.length) { 115 | // replacement is not a string, don't touch! 116 | return replacements[index] 117 | } else { 118 | // index not found, fallback to displaying letiable 119 | return '$' + (index + 1) 120 | } 121 | } 122 | 123 | /** 124 | * Transform parsed structure into pluralization n.b. The first node may 125 | * be a non-integer (for instance, a string representing an Arabic 126 | * number). So convert it back with the current language's 127 | * convertNumber. 128 | * 129 | * @param {Array} nodes List [ {String|Number}, {String}, {String} ... ] 130 | * @return {string} selected pluralized form according to current 131 | * language. 132 | */ 133 | plural (nodes) { 134 | const count = parseFloat(this.language.convertNumber(nodes[0], 10)) 135 | const forms = nodes.slice(1) 136 | return forms.length ? this.language.convertPlural(count, forms) : '' 137 | } 138 | 139 | /** 140 | * Transform parsed structure into gender Usage 141 | * {{gender:gender|masculine|feminine|neutral}}. 142 | * The first node(gender) must be one of 'male', 'female' or 'unknown' 143 | * Mediawiki allows this string as empty to indicate current logged in user. 144 | * But this library cannot access such user contexts unless explclitly passed. 145 | * So we need to fallback to gender neutral if it is empty. 146 | * 147 | * @param {Array} nodes List [ {String}, {String}, {String} , {String} ] 148 | * @return {string} selected gender form according to current language 149 | */ 150 | gender (nodes) { 151 | const gender = nodes[0] 152 | const forms = nodes.slice(1) 153 | return this.language.gender(gender, forms) 154 | } 155 | 156 | /** 157 | * Transform parsed structure into grammar conversion. Invoked by 158 | * putting {{grammar:form|word}} in a message 159 | * 160 | * @param {Array} nodes List [{Grammar case eg: genitive}, {String word}] 161 | * @return {string} selected grammatical form according to current 162 | * language. 163 | */ 164 | grammar (nodes) { 165 | const form = nodes[0] 166 | const word = nodes[1] 167 | return word && form && this.language.convertGrammar(word, form) 168 | } 169 | 170 | /** 171 | * Transform wiki-link 172 | * 173 | * @param {String[]} nodes 174 | * @return {String} 175 | */ 176 | wikilink (nodes) { 177 | let anchor 178 | let page = nodes[0] 179 | // Strip leading ':', which is used to suppress special behavior in wikitext links, 180 | // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]] 181 | if (page.charAt(0) === ':') { 182 | page = page.slice(1) 183 | } 184 | const url = `./${page}` 185 | 186 | if (nodes.length === 1) { 187 | // [[Some Page]] or [[Namespace:Some Page]] 188 | anchor = page 189 | } else { 190 | // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]] 191 | anchor = nodes[1] 192 | } 193 | 194 | return `${anchor}` 195 | } 196 | 197 | /** 198 | * Transform parsed structure into external link. 199 | * 200 | * @param {String[]} nodes 201 | * @return {String} 202 | */ 203 | extlink (nodes) { 204 | if (nodes.length !== 2) { 205 | throw new Error('Expected two items in the node') 206 | } 207 | return `${nodes[1]}` 208 | } 209 | 210 | /** 211 | * Wraps argument with unicode control characters for directionality safety 212 | * 213 | * This solves the problem where directionality-neutral characters at the edge of 214 | * the argument string get interpreted with the wrong directionality from the 215 | * enclosing context, giving renderings that look corrupted like "(Ben_(WMF". 216 | * 217 | * The wrapping is LRE...PDF or RLE...PDF, depending on the detected 218 | * directionality of the argument string, using the BIDI algorithm's own "First 219 | * strong directional codepoint" rule. Essentially, this works round the fact that 220 | * there is no embedding equivalent of U+2068 FSI (isolation with heuristic 221 | * direction inference). The latter is cleaner but still not widely supported. 222 | * 223 | * @param {string[]} nodes The text nodes from which to take the first item. 224 | * @return {string} Wrapped String of content as needed. 225 | */ 226 | bidi (nodes) { 227 | const dir = strongDirFromContent(nodes[0]) 228 | if (dir === 'ltr') { 229 | // Wrap in LEFT-TO-RIGHT EMBEDDING ... POP DIRECTIONAL FORMATTING 230 | return '\u202A' + nodes[0] + '\u202C' 231 | } 232 | if (dir === 'rtl') { 233 | // Wrap in RIGHT-TO-LEFT EMBEDDING ... POP DIRECTIONAL FORMATTING 234 | return '\u202B' + nodes[0] + '\u202C' 235 | } 236 | // No strong directionality: do not wrap 237 | return nodes[0] 238 | } 239 | 240 | /** 241 | * Takes an unformatted number (arab, no group separators and . as decimal separator) 242 | * and outputs it in the localized digit script and formatted with decimal 243 | * separator, according to the current language. 244 | * 245 | * @param {Array} nodes List of nodes 246 | * @return {number|string} Formatted number 247 | */ 248 | formatnum (nodes) { 249 | const isInteger = !!nodes[1] && nodes[1] === 'R' 250 | const number = nodes[0] 251 | if (typeof number === 'string' || typeof number === 'number') { 252 | return this.language.convertNumber(number, isInteger) 253 | } 254 | return number 255 | } 256 | 257 | /** 258 | * Converts array of HTML element key value pairs to object 259 | * 260 | * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a 261 | * name and 2 * n + 1 the associated value 262 | * @return {Object} Object mapping attribute name to attribute value 263 | */ 264 | htmlattributes (nodes) { 265 | const mapping = {} 266 | for (let i = 0, len = nodes.length; i < len; i += 2) { 267 | mapping[nodes[i]] = nodes[i + 1] 268 | } 269 | return mapping 270 | } 271 | 272 | /** 273 | * Handles an (already-validated) HTML element. 274 | * 275 | * @param {Array} nodes Nodes to process when creating element 276 | * @return {string} 277 | */ 278 | htmlelement (nodes) { 279 | const tagName = nodes.shift() 280 | /** @type {Object} */ 281 | const attributes = nodes.shift() 282 | let contents = nodes 283 | let attrStr = '' 284 | for (const attrName in attributes) { 285 | attrStr += ` ${attrName}="${attributes[attrName]}"` 286 | }; 287 | 288 | if (!Array.isArray(contents)) { 289 | contents = [contents] 290 | } 291 | 292 | const contentsStr = contents.join('') 293 | 294 | return `<${tagName}${attrStr}>${contentsStr}` 295 | } 296 | } 297 | 298 | /** 299 | * Normalize locale to lower case, as BCP 47 tags are case insensitive. 300 | * Phabricator ticket: T359822 301 | * 302 | * @param {unknown} locale 303 | * @return {string} normalized locale 304 | */ 305 | function normalizeLocale (locale) { 306 | return typeof locale === 'string' ? locale.toLowerCase() : locale 307 | } 308 | 309 | /** 310 | * Gets directionality of the first strongly directional codepoint 311 | * 312 | * This is the rule the BIDI algorithm uses to determine the directionality of 313 | * paragraphs ( http://unicode.org/reports/tr9/#The_Paragraph_Level ) and 314 | * FSI isolates ( http://unicode.org/reports/tr9/#Explicit_Directional_Isolates ). 315 | * 316 | * TODO: Does not handle BIDI control characters inside the text. 317 | * TODO: Does not handle unallocated characters. 318 | * 319 | * @param {string} text The text from which to extract initial directionality. 320 | * @return {string} Directionality (either 'ltr' or 'rtl') 321 | */ 322 | function strongDirFromContent (text) { 323 | const m = text.match(strongDirRegExp) 324 | if (!m) { 325 | return null 326 | } 327 | if (m[2] === undefined) { 328 | return 'ltr' 329 | } 330 | return 'rtl' 331 | } 332 | 333 | export { BananaEmitter as default, normalizeLocale } 334 | -------------------------------------------------------------------------------- /test/banana.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import Banana from '../src/index.js' 4 | import assert from 'assert' 5 | import fs from 'fs' 6 | import path from 'path' 7 | 8 | const isNodeVersionAbove12 = () => parseInt(process.versions.node) > 12 9 | const __dirname = path.dirname(new URL(import.meta.url).pathname) 10 | const grammarTests = { 11 | bs: [{ 12 | word: 'word', 13 | grammarForm: 'instrumental', 14 | expected: 's word', 15 | description: 'Grammar test for instrumental case' 16 | }, { 17 | word: 'word', 18 | grammarForm: 'lokativ', 19 | expected: 'o word', 20 | description: 'Grammar test for lokativ case' 21 | }], 22 | 23 | dsb: [{ 24 | word: 'word', 25 | grammarForm: 'instrumental', 26 | expected: 'z word', 27 | description: 'Grammar test for instrumental case' 28 | }, { 29 | word: 'word', 30 | grammarForm: 'lokatiw', 31 | expected: 'wo word', 32 | description: 'Grammar test for lokatiw case' 33 | }], 34 | 35 | fi: [{ 36 | word: 'talo', 37 | grammarForm: 'genitive', 38 | expected: 'talon', 39 | description: 'Grammar test for genitive case' 40 | }, { 41 | word: 'linux', 42 | grammarForm: 'genitive', 43 | expected: 'linuxin', 44 | description: 'Grammar test for genitive case' 45 | }, { 46 | word: 'talo', 47 | grammarForm: 'elative', 48 | expected: 'talosta', 49 | description: 'Grammar test for elative case' 50 | }, { 51 | word: 'pastöroitu', 52 | grammarForm: 'partitive', 53 | expected: 'pastöroitua', 54 | description: 'Grammar test for partitive case' 55 | }, { 56 | word: 'talo', 57 | grammarForm: 'partitive', 58 | expected: 'taloa', 59 | description: 'Grammar test for partitive case' 60 | }, { 61 | word: 'talo', 62 | grammarForm: 'illative', 63 | expected: 'taloon', 64 | description: 'Grammar test for illative case' 65 | }, { 66 | word: 'linux', 67 | grammarForm: 'inessive', 68 | expected: 'linuxissa', 69 | description: 'Grammar test for inessive case' 70 | }], 71 | 72 | ga: [{ 73 | word: 'an Domhnach', 74 | grammarForm: 'ainmlae', 75 | expected: 'Dé Domhnaigh', 76 | description: 'Grammar test for ainmlae case' 77 | }, { 78 | word: 'an Luan', 79 | grammarForm: 'ainmlae', 80 | expected: 'Dé Luain', 81 | description: 'Grammar test for ainmlae case' 82 | }, { 83 | word: 'an Satharn', 84 | grammarForm: 'ainmlae', 85 | expected: 'Dé Sathairn', 86 | description: 'Grammar test for ainmlae case' 87 | }], 88 | 89 | he: [{ 90 | word: 'ויקיפדיה', 91 | grammarForm: 'prefixed', 92 | expected: 'וויקיפדיה', 93 | description: 'Duplicate the "Waw" if prefixed' 94 | }, { 95 | word: 'וולפגנג', 96 | grammarForm: 'prefixed', 97 | expected: 'וולפגנג', 98 | description: 'Duplicate the "Waw" if prefixed, but not if it is already duplicated.' 99 | }, { 100 | word: 'הקובץ', 101 | grammarForm: 'prefixed', 102 | expected: 'קובץ', 103 | description: 'Remove the "He" if prefixed' 104 | }, { 105 | word: 'Wikipedia', 106 | grammarForm: 'תחילית', 107 | expected: '־Wikipedia', 108 | description: 'Add a hyphen (maqaf) before non-Hebrew letters' 109 | }, { 110 | word: '1995', 111 | grammarForm: 'תחילית', 112 | expected: '־1995', 113 | description: 'Add a hyphen (maqaf) before numbers' 114 | }], 115 | 116 | hsb: [{ 117 | word: 'word', 118 | grammarForm: 'instrumental', 119 | expected: 'z word', 120 | description: 'Grammar test for instrumental case' 121 | }, { 122 | word: 'word', 123 | grammarForm: 'lokatiw', 124 | expected: 'wo word', 125 | description: 'Grammar test for lokatiw case' 126 | }], 127 | 128 | hu: [{ 129 | word: 'Wikipédiá', 130 | grammarForm: 'rol', 131 | expected: 'Wikipédiáról', 132 | description: 'Grammar test for rol case' 133 | }, { 134 | word: 'Wikipédiá', 135 | grammarForm: 'ba', 136 | expected: 'Wikipédiába', 137 | description: 'Grammar test for ba case' 138 | }, { 139 | word: 'Wikipédiá', 140 | grammarForm: 'k', 141 | expected: 'Wikipédiák', 142 | description: 'Grammar test for k case' 143 | }], 144 | 145 | hy: [{ 146 | word: 'Մաունա', 147 | grammarForm: 'genitive', 148 | expected: 'Մաունայի', 149 | description: 'Grammar test for genitive case' 150 | }, { 151 | word: 'հետո', 152 | grammarForm: 'genitive', 153 | expected: 'հետոյի', 154 | description: 'Grammar test for genitive case' 155 | }, { 156 | word: 'գիրք', 157 | grammarForm: 'genitive', 158 | expected: 'գրքի', 159 | description: 'Grammar test for genitive case' 160 | }, { 161 | word: 'ժամանակի', 162 | grammarForm: 'genitive', 163 | expected: 'ժամանակիի', 164 | description: 'Grammar test for genitive case' 165 | }], 166 | 167 | la: [{ 168 | word: 'Translatio', 169 | grammarForm: 'genitive', 170 | expected: 'Translationis', 171 | description: 'Grammar test for genitive case' 172 | }, { 173 | word: 'Translatio', 174 | grammarForm: 'accusative', 175 | expected: 'Translationem', 176 | description: 'Grammar test for accusative case' 177 | }, { 178 | word: 'Translatio', 179 | grammarForm: 'ablative', 180 | expected: 'Translatione', 181 | description: 'Grammar test for ablative case' 182 | }], 183 | 184 | os: [{ 185 | word: 'бæстæ', 186 | grammarForm: 'genitive', 187 | expected: 'бæсты', 188 | description: 'Grammar test for genitive case' 189 | }, { 190 | word: 'бæстæ', 191 | grammarForm: 'allative', 192 | expected: 'бæстæм', 193 | description: 'Grammar test for allative case' 194 | }, { 195 | word: 'Тигр', 196 | grammarForm: 'dative', 197 | expected: 'Тигрæн', 198 | description: 'Grammar test for dative case' 199 | }, { 200 | word: 'цъити', 201 | grammarForm: 'dative', 202 | expected: 'цъитийæн', 203 | description: 'Grammar test for dative case' 204 | }, { 205 | word: 'лæппу', 206 | grammarForm: 'genitive', 207 | expected: 'лæппуйы', 208 | description: 'Grammar test for genitive case' 209 | }, { 210 | word: '2011', 211 | grammarForm: 'equative', 212 | expected: '2011-ау', 213 | description: 'Grammar test for equative case' 214 | }], 215 | 216 | ru: [{ 217 | word: 'транслэйтвики', 218 | grammarForm: 'genitive', 219 | expected: 'транслэйтвики', 220 | description: 'Grammar test for genitive case' 221 | }, { 222 | word: 'тесть', 223 | grammarForm: 'genitive', 224 | expected: 'тестя', 225 | description: 'Grammar test for genitive case' 226 | }, { 227 | word: 'привилегия', 228 | grammarForm: 'genitive', 229 | expected: 'привилегии', 230 | description: 'Grammar test for genitive case' 231 | }, { 232 | word: 'установка', 233 | grammarForm: 'genitive', 234 | expected: 'установки', 235 | description: 'Grammar test for genitive case' 236 | }, { 237 | word: 'похоти', 238 | grammarForm: 'genitive', 239 | expected: 'похотей', 240 | description: 'Grammar test for genitive case' 241 | }, { 242 | word: 'доводы', 243 | grammarForm: 'genitive', 244 | expected: 'доводов', 245 | description: 'Grammar test for genitive case' 246 | }, { 247 | word: 'песчаник', 248 | grammarForm: 'genitive', 249 | expected: 'песчаника', 250 | description: 'Grammar test for genitive case' 251 | }], 252 | 253 | sl: [{ 254 | word: 'word', 255 | grammarForm: 'orodnik', 256 | expected: 'z word', 257 | description: 'Grammar test for orodnik case' 258 | }, { 259 | word: 'word', 260 | grammarForm: 'mestnik', 261 | expected: 'o word', 262 | description: 'Grammar test for mestnik case' 263 | }], 264 | 265 | uk: [{ 266 | word: 'транслейтвікі', 267 | grammarForm: 'genitive', 268 | expected: 'транслейтвікі', 269 | description: 'Grammar test for genitive case' 270 | }, { 271 | word: 'тесть', 272 | grammarForm: 'genitive', 273 | expected: 'тестя', 274 | description: 'Grammar test for genitive case' 275 | }, { 276 | word: 'Вікіпедія', 277 | grammarForm: 'genitive', 278 | expected: 'Вікіпедії', 279 | description: 'Grammar test for genitive case' 280 | }, { 281 | word: 'установка', 282 | grammarForm: 'genitive', 283 | expected: 'установки', 284 | description: 'Grammar test for genitive case' 285 | }, { 286 | word: 'похоти', 287 | grammarForm: 'genitive', 288 | expected: 'похотей', 289 | description: 'Grammar test for genitive case' 290 | }, { 291 | word: 'доводы', 292 | grammarForm: 'genitive', 293 | expected: 'доводов', 294 | description: 'Grammar test for genitive case' 295 | }, { 296 | word: 'песчаник', 297 | grammarForm: 'genitive', 298 | expected: 'песчаника', 299 | description: 'Grammar test for genitive case' 300 | }, { 301 | word: 'Вікіпедія', 302 | grammarForm: 'accusative', 303 | expected: 'Вікіпедію', 304 | description: 'Grammar test for accusative case' 305 | }] 306 | } 307 | 308 | describe('Banana', function () { 309 | it('should parse and localize to English', () => { 310 | const locale = 'en' 311 | const banana = new Banana(locale, {}) 312 | 313 | const messages = fs.readFileSync(path.join(__dirname, 'i18n', `${locale}.json`)) 314 | banana.load(JSON.parse(messages), locale) 315 | assert.strictEqual(banana.i18n('msg-one'), 'One') 316 | assert.strictEqual(banana.i18n('msg-two', 10), '10 results') 317 | assert.strictEqual(banana.i18n('msg-three', 10), '10 results') 318 | assert.strictEqual(banana.i18n('msg-three', 1), 'One result') 319 | assert.strictEqual(banana.i18n('msg-four', 10, 4), 'There are 10 results in 4 files') 320 | }) 321 | 322 | it('should load the messages for multiple locales', () => { 323 | const banana = new Banana() 324 | const messages = { 325 | en: { 326 | message_1: 'Message one', 327 | message_2: 'Message two' 328 | }, 329 | ml: { 330 | message_1: 'ഒന്നാമത്തെ മെസ്സേജ്' 331 | }, 332 | es: { 333 | message_1: 'Message one' 334 | } 335 | } 336 | banana.load(messages) 337 | assert.ok(banana.messageStore.hasLocale('en')) 338 | assert.ok(banana.messageStore.hasLocale('ml')) 339 | banana.setLocale('en') 340 | assert.strictEqual(banana.i18n('message_1'), 'Message one') 341 | banana.setLocale('ml') 342 | assert.strictEqual(banana.i18n('message_1'), 'ഒന്നാമത്തെ മെസ്സേജ്') 343 | banana.setLocale('es') 344 | assert.strictEqual(banana.i18n('message_2'), 'Message two', 'Fallbacks to en message') 345 | banana.setLocale('uk') 346 | assert.strictEqual(banana.i18n('message_2'), 'Message two', 'Fallbacks to en message by first checking ru.') 347 | }) 348 | 349 | it('should respect finalFallback option', () => { 350 | const banana = new Banana('es', { 351 | messages: { 352 | ml: { 353 | message_1: 'ഒന്നാമത്തെ മെസ്സേജ്', 354 | message_2: 'രണ്ടാമത്തെ മെസ്സേജ്' 355 | }, 356 | en: { 357 | message_1: 'Message one', 358 | message_2: 'Message two' 359 | } 360 | }, 361 | finalFallback: 'ml' 362 | }) 363 | assert.ok(!banana.messageStore.hasLocale('es')) 364 | assert.ok(banana.messageStore.hasLocale('en')) 365 | assert.ok(banana.messageStore.hasLocale('ml')) 366 | assert.strictEqual(banana.i18n('message_2'), 'രണ്ടാമത്തെ മെസ്സേജ്') 367 | }) 368 | 369 | it('should respect locales with country codes', () => { 370 | const banana = new Banana('en-GB', { 371 | messages: { 372 | en: { 373 | message_1: 'Message one', 374 | message_2: 'Message two' 375 | } 376 | } 377 | }) 378 | assert.strictEqual(banana.i18n('message_2'), 'Message two') 379 | }) 380 | 381 | it('should throw errors on invalid locales', () => { 382 | assert.throws(() => { 383 | 384 | new Banana('es/en', { 385 | messages: { 386 | message_1: 'Message one', 387 | message_2: 'Message two' 388 | } 389 | }) 390 | }, Error, 'Invalid locale es/en') 391 | }) 392 | 393 | it('should throw errors on invalid message source', () => { 394 | assert.throws(() => { 395 | 396 | new Banana('es/en', { 397 | messages: [] 398 | }) 399 | }, Error, 'Invalid message source.') 400 | }) 401 | 402 | it('should throw errors on invalid message key', () => { 403 | assert.throws(() => { 404 | 405 | new Banana('es/en', { 406 | messages: { 407 | message_1: ['Message one'], 408 | message_2: 'Message two' 409 | } 410 | }) 411 | }, Error, 'Invalid message key.') 412 | }) 413 | 414 | it('should handle messages that are an empty string', () => { 415 | const banana = new Banana('zh-hans', { 416 | messages: { 417 | 'zh-hans': { 418 | 'word-separator': '' 419 | } 420 | } 421 | }) 422 | assert.strictEqual(banana.i18n('word-separator'), '', 'Empty string message') 423 | }) 424 | 425 | it('should merge messages when added to an existing locale', () => { 426 | const banana = new Banana('ca', { 427 | messages: { 428 | ca: { 429 | message_1: 'Message one', 430 | message_2: 'Message two' 431 | } 432 | } 433 | }) 434 | // Add some more messages 435 | banana.load({ 436 | message_2: 'Message two - new', 437 | message_3: 'Message three' 438 | }, 'ca') 439 | assert.strictEqual(banana.i18n('message_1'), 'Message one') 440 | assert.strictEqual(banana.i18n('message_2'), 'Message two - new') 441 | assert.strictEqual(banana.i18n('message_3'), 'Message three') 442 | }) 443 | 444 | it('should parse the plural and gender', () => { 445 | const locale = 'en' 446 | const banana = new Banana(locale, {}) 447 | const messages = fs.readFileSync(path.join(__dirname, 'i18n', `${locale}.json`)) 448 | banana.load(JSON.parse(messages), locale) 449 | assert.strictEqual( 450 | banana.i18n('This message key does not exist'), 451 | 'This message key does not exist', 452 | 'This message key does not exist' 453 | ) 454 | assert.strictEqual(banana.i18n('Hello $1', 'Bob'), 'Hello Bob', 'Parameter replacement') 455 | const pluralAndGenderMessage = '$1 has $2 {{plural:$2|kitten|kittens}}. ' + 456 | '{{gender:$3|He|She}} loves to play with {{plural:$2|it|them}}.' 457 | const pluralAndGenderMessageWithLessParaMS = '$1 has $2 {{plural:$2|kitten}}. ' + 458 | '{{gender:$3|He|She}} loves to play with {{plural:$2|it}}.' 459 | const pluralAndGenderMessageWithCase = '$1 has $2 {{plURAl:$2|kitten}}. ' + 460 | '{{genDER:$3|He|She}} loves to play with {{pLural:$2|it}}.' 461 | const pluralAndGenderMessageWithSyntaxError = '$1 has $2 {{plural:$2|kitten}. ' + 462 | '{{gender:$3|He|She}} loves to play with {plural:$2|it}}.' 463 | const pluralAndGenderMessageWithSyntaxError2 = '$1 has $2 {{plural:$2|kitten}}. ' + 464 | '{gender:$3|He|She}} loves to play with {plural:$2|it}}.' 465 | assert.strictEqual( 466 | banana.i18n(pluralAndGenderMessage, 'Meera', 1, 'female'), 467 | 'Meera has 1 kitten. She loves to play with it.', 468 | 'Plural and gender test - female, singular' 469 | ) 470 | assert.throws( 471 | function () { 472 | banana.i18n(pluralAndGenderMessageWithSyntaxError, 'Meera', 1, 'female') 473 | }, 474 | /Parse error at position 10/, 475 | 'Message has syntax error' 476 | ) 477 | assert.throws( 478 | function () { 479 | banana.i18n(pluralAndGenderMessageWithSyntaxError2, 'Meera', 1, 'female') 480 | }, 481 | /Parse error at position 32/, 482 | 'Message has syntax error' 483 | ) 484 | assert.strictEqual( 485 | banana.i18n(pluralAndGenderMessageWithLessParaMS, 'Meera', 1, 'female'), 486 | 'Meera has 1 kitten. She loves to play with it.', 487 | 'Plural and gender test - female, singular, but will less parameters in message' 488 | ) 489 | assert.strictEqual( 490 | banana.i18n(pluralAndGenderMessageWithCase, 'Meera', 1, 'female'), 491 | 'Meera has 1 kitten. She loves to play with it.', 492 | 'Plural and gender test - female, singular. Plural, gender keywords with upper and lower case' 493 | ) 494 | assert.strictEqual( 495 | banana.i18n(pluralAndGenderMessage, 'Meera', 1, 'randomtext'), 496 | 'Meera has 1 kitten. He loves to play with it.', 497 | 'Plural and gender test - wrong gender- fallback to fist gender' 498 | ) 499 | assert.strictEqual( 500 | banana.i18n(pluralAndGenderMessage), 501 | '$1 has $2 kittens. He loves to play with them.', 502 | 'Plural and gender test - no params passed. Should not fail' 503 | ) 504 | assert.strictEqual( 505 | banana.i18n(pluralAndGenderMessage, 'Meera', 1, 'randomtext', 'extraparam'), 506 | 'Meera has 1 kitten. He loves to play with it.', 507 | 'Plural and gender test - more params passed. Should not fail' 508 | ) 509 | assert.strictEqual( 510 | banana.i18n(pluralAndGenderMessage, 'Harry', 2, 'male'), 511 | 'Harry has 2 kittens. He loves to play with them.', 512 | 'Plural and gender test - male, plural' 513 | ) 514 | assert.strictEqual( 515 | banana.i18n('This costs $1.'), 516 | 'This costs $1.', 517 | 'No parameter supplied, $1 appears as is' 518 | ) 519 | 520 | const genderMessageWithoutExplicitGender = '{{gender:|He|She|They}}' 521 | assert.strictEqual( 522 | banana.i18n(genderMessageWithoutExplicitGender), 523 | 'They', 524 | 'Gender test - no gender passed' 525 | ) 526 | }) 527 | 528 | it('should parse formatnum', () => { 529 | const locale = 'ar' 530 | const banana = new Banana(locale) 531 | if (!isNodeVersionAbove12()) { return } 532 | 533 | assert.strictEqual( 534 | banana.i18n('{{formatnum:34242}}'), 535 | '٣٤٬٢٤٢', 536 | 'Arabic numerals' 537 | ) 538 | }) 539 | 540 | const formatnumTests = [ 541 | { 542 | lang: 'en', 543 | number: 987654321.654321, 544 | result: '987,654,321.654', 545 | description: 'formatnum test for English, decimal separator' 546 | }, 547 | { 548 | lang: 'ar', 549 | number: 987654321.654321, 550 | result: '٩٨٧٬٦٥٤٬٣٢١٫٦٥٤', 551 | description: 'formatnum test for Arabic, with decimal separator' 552 | }, 553 | { 554 | lang: 'ar', 555 | number: '٩٨٧٦٥٤٣٢١٫٦٥٤٣٢١', 556 | result: '987654321', 557 | integer: true, 558 | description: 'formatnum test for Arabic, with decimal separator, reverse' 559 | }, 560 | { 561 | lang: 'ar', 562 | number: -12.89, 563 | result: '؜-١٢٫٨٩', 564 | description: 'formatnum test for Arabic, negative number' 565 | }, 566 | { 567 | lang: 'ar', 568 | number: '-١٢٫٨٩', 569 | result: '-12', 570 | integer: true, 571 | description: 'formatnum test for Arabic, negative number, reverse' 572 | }, 573 | { 574 | lang: 'nl', 575 | number: 987654321.654321, 576 | result: '987.654.321,654', 577 | description: 'formatnum test for Nederlands, decimal separator' 578 | }, 579 | { 580 | lang: 'nl', 581 | number: -12.89, 582 | result: '-12,89', 583 | description: 'formatnum test for Nederlands, negative number' 584 | }, 585 | { 586 | lang: 'nl', 587 | number: '.89', 588 | result: '0,89', 589 | description: 'formatnum test for Nederlands' 590 | }, 591 | { 592 | lang: 'nl', 593 | number: 'invalidnumber', 594 | result: 'invalidnumber', 595 | description: 'formatnum test for Nederlands, invalid number' 596 | }, 597 | { 598 | lang: 'ml', 599 | number: '1000000000', 600 | result: '1,00,00,00,000', 601 | description: 'formatnum test for Malayalam' 602 | }, 603 | { 604 | lang: 'ml', 605 | number: '-1000000000', 606 | result: '-1,00,00,00,000', 607 | description: 'formatnum test for Malayalam, negative number' 608 | }, 609 | { 610 | lang: 'mr', 611 | number: '123456789.123456789', 612 | result: '१२,३४,५६,७८९.१२३', 613 | description: 'formatnum test for Marathi' 614 | }, 615 | { 616 | lang: 'hi', 617 | number: '123456789.123456789', 618 | result: '12,34,56,789.123', 619 | description: 'formatnum test for Hindi' 620 | }, 621 | { 622 | lang: 'fr', 623 | number: '1234.56', 624 | result: '1 234,56', 625 | description: 'formatnum test for French' 626 | }, 627 | { 628 | lang: 'ban', 629 | number: '123456.789', 630 | result: '123.456,789', 631 | description: 'formatnum test for Balinese using fallback language' 632 | }, 633 | { 634 | lang: 'hi', 635 | number: '१२,३४,५६,७८९', 636 | result: '१२,३४,५६,७८९', 637 | description: 'formatnum test for Hindi, Devanagari digits passed' 638 | } 639 | ] 640 | 641 | it('formatnum tests', () => { 642 | if (!isNodeVersionAbove12()) { return } 643 | 644 | const formatNumMsg = '{{formatnum:$1}}' 645 | const formatNumMsgInt = '{{formatnum:$1|R}}' 646 | formatnumTests.forEach((test) => { 647 | const banana = new Banana(test.lang) 648 | assert.strictEqual( 649 | banana.i18n(test.integer ? formatNumMsgInt : formatNumMsg, 650 | test.number), 651 | test.result, 652 | test.description 653 | ) 654 | }) 655 | }) 656 | 657 | it('should allow custom parser plugins', () => { 658 | const locale = 'en' 659 | const banana = new Banana(locale) 660 | banana.registerParserPlugin('foobar', (nodes) => { 661 | return nodes[0] === 'foo' ? nodes[1] : nodes[2] 662 | }) 663 | assert.strictEqual( 664 | banana.i18n('{{foobar:foo|first|second}}'), 665 | 'first', 666 | 'Emits first argument on passing foo to foobar plugin hook' 667 | ) 668 | assert.strictEqual( 669 | banana.i18n('{{foobar:bar|first|second}}'), 670 | 'second', 671 | 'Emits second argument on passing bar to foobar plugin hook' 672 | ) 673 | 674 | banana.registerParserPlugin('sitename', () => { 675 | return 'Wikipedia' 676 | }) 677 | banana.registerParserPlugin('link', (nodes) => { 678 | return '' + nodes[0] + '' 679 | }) 680 | assert.strictEqual( 681 | banana.i18n('{{link:{{SITENAME}}|https://en.wikipedia.org}}'), 682 | 'Wikipedia', 683 | 'complex use of custom parser plugins' 684 | ) 685 | }) 686 | 687 | it('should parse the Arabic message', () => { 688 | const locale = 'ar' 689 | const banana = new Banana(locale) 690 | assert.strictEqual(banana.locale, 'ar', 'Locale is Arabic') 691 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 1), 'one', 692 | 'Arabic plural test for one') 693 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', '٠'), 'zero', 694 | 'Arabic plural test for arabic digit zero') 695 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 2), 'two', 696 | 'Arabic plural test for two') 697 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 3), 'few', 698 | 'Arabic plural test for few') 699 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', '٨'), 'few', 700 | 'Arabic plural test for few') 701 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 9), 'few', 702 | 'Arabic plural test for few') 703 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 110), 'few', 704 | 'Arabic plural test for few') 705 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 11), 'many', 706 | 'Arabic plural test for many') 707 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 15), 'many', 708 | 'Arabic plural test for many') 709 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 99), 'many', 710 | 'Arabic plural test for many') 711 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 9999), 'many', 712 | 'Arabic plural test for many') 713 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 100), 'other', 714 | 'Arabic plural test for other') 715 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 102), 'other', 716 | 'Arabic plural test for other') 717 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 1000), 'other', 718 | 'Arabic plural test for other') 719 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', 1.7), 'other', 720 | 'Arabic decimal plural test for one') 721 | assert.strictEqual(banana.i18n('{{plural:$1|zero|one|two|few|many|other}}', '٠١٢٣٤٥٦٧٨٩'), 'many', 722 | 'Arabic plural test for ۰۱۲۳۴۵۶۷۸۹') 723 | }) 724 | 725 | it('should parse explicit plural forms correctly', () => { 726 | 727 | const banana = new Banana('en') 728 | const language = banana.parser.emitter.language 729 | assert.strictEqual(language.convertPlural(0, ['0=Explicit Zero', 'Singular', 'Plural']), 730 | 'Explicit Zero', 'Explicit Zero') 731 | 732 | assert.strictEqual(language.convertPlural(1, ['0=Explicit Zero', 'Singular', 'Plural', '1=Explicit One']), 733 | 'Explicit One', 'Explicit One') 734 | 735 | assert.strictEqual(language.convertPlural(3, ['0=Explicit Zero', '1=Explicit One', 'Singular', 'Plural']), 736 | 'Plural', 'Plural') 737 | 738 | assert.strictEqual(language.convertPlural(1, ['0=Explicit Zero', 'Singular', 'Plural']), 739 | 'Singular', 'Singular') 740 | 741 | // See https://bugzilla.wikimedia.org/69993 742 | assert.strictEqual(banana.i18n('Found {{PLURAL:$1|$1 results|1=$1 result}}', 1), 'Found 1 result', 'Plural message with explicit plural forms, plural form contains placeholder.') 743 | }) 744 | 745 | it('should use digit transform table and localize digits', function () { 746 | const langCode = 'fa' 747 | const banana = new Banana(langCode) 748 | assert.strictEqual(banana.parser.emitter.locale, langCode, 'Locale is ' + langCode) 749 | 750 | assert.strictEqual(banana.parser.emitter.language.convertNumber('8', true), 8, 751 | 'Persian transform of 8') 752 | assert.strictEqual(banana.parser.emitter.language.convertNumber('۰۱۲۳۴۵۶۷۸۹', true), 123456789, 753 | 'Persian transform of ۰۱۲۳۴۵۶۷۸۹') 754 | 755 | if (!isNodeVersionAbove12()) { return } 756 | // Rest of the tests need Intl.NumberFormat. 757 | assert.strictEqual(banana.parser.emitter.language.convertNumber('8'), '۸', 758 | 'Persian transform of 8') 759 | assert.strictEqual(banana.parser.emitter.language.convertNumber('0123456789'), '۱۲۳٬۴۵۶٬۷۸۹', 760 | 'Persian transform of 0123456789') 761 | }) 762 | 763 | it('should localize the messages with bidi arguments', () => { 764 | const banana = new Banana('he') 765 | banana.load({ 766 | 'greet-msg': 'שלום {{bidi:$1}} הי!' 767 | }, 'he') 768 | assert.strictEqual( 769 | banana.i18n('greet-msg', '123'), 770 | 'שלום ' + '123' + ' הי!', 771 | 'Bidi with neutral argument' 772 | ) 773 | assert.strictEqual( 774 | banana.i18n('greet-msg', 'Ben_(WMF)'), 775 | 'שלום ' + '\u202A' + 'Ben_(WMF)' + '\u202C' + ' הי!', 776 | 'Bidi with LTR argument' 777 | ) 778 | assert.strictEqual( 779 | banana.i18n('greet-msg', 'יהודי (מנוחין)'), 780 | 'שלום ' + '\u202B' + 'יהודי (מנוחין)' + '\u202C' + ' הי!', 781 | 'Bidi with RTL argument' 782 | ) 783 | }) 784 | 785 | it('should localize the messages with wiki liinks', () => { 786 | const banana = new Banana('en', { wikilinks: true }) 787 | banana.load({ 788 | 'msg-with-extlink': 'This is a link to [https://wikipedia.org wikipedia]', 789 | 'msg-with-wikilink': 'This is a link to [[Apple|Apple Page]]', 790 | 'msg-with-wikilink-no-anchor': 'This is a link to [[Apple]]' 791 | }, 'en') 792 | banana.load({ 793 | 'msg-with-extlink': 'ഇത് [https://wikipedia.org വിക്കിപീഡീയ] ലിങ്ക്' 794 | }, 'ml') 795 | assert.strictEqual( 796 | banana.i18n('msg-with-extlink'), 797 | 'This is a link to wikipedia', 798 | 'External link' 799 | ) 800 | assert.strictEqual( 801 | banana.i18n('msg-with-wikilink'), 802 | 'This is a link to Apple Page', 803 | 'Internal Wiki style link with link and title being different' 804 | ) 805 | assert.strictEqual( 806 | banana.i18n('msg-with-wikilink-no-anchor'), 807 | 'This is a link to Apple', 808 | 'Internal Wiki style link with link and title being same' 809 | ) 810 | banana.setLocale('ml') 811 | assert.strictEqual( 812 | banana.i18n('msg-with-extlink'), 813 | 'ഇത് വിക്കിപീഡീയ ലിങ്ക്', 814 | 'External link, after changing locale' 815 | ) 816 | }) 817 | 818 | it('should skip wiki links if disabled', () => { 819 | const banana = new Banana('en', { wikilinks: false }) 820 | banana.load({ 821 | 'msg-with-extlink': 'This is reference [10]', 822 | 'msg-with-wikilink': '$1 more {{plural:$1|item|items}} [[...]]' 823 | }, 'en') 824 | assert.strictEqual( 825 | banana.i18n('msg-with-extlink'), 826 | 'This is reference [10]' 827 | ) 828 | assert.strictEqual( 829 | banana.i18n('msg-with-wikilink', 10), 830 | '10 more items [[...]]' 831 | ) 832 | }) 833 | 834 | it('should parse and localize html content safely', () => { 835 | const banana = new Banana('en', { wikilinks: true }) 836 | banana.load({ 837 | 'msg-for-html-sanitize-script': 'This is link and it is ', 838 | 'msg-for-html-sanitize-onclick': 'This is link and it is a problem', 839 | 'msg-for-html-sanitize-mismatched': 'test', 840 | 'msg-for-html-sanitize-script-and-external-link': ' [http://example.com Foo bar]', 841 | 'msg-for-html-sanitize-script-as-link': '[http://example.com ]', 842 | 'msg-for-html-sanitize-attribute-quotes': 'Double Single Styled', 843 | 'msg-for-html-sanitize-special-content': '/>', 844 | 'msg-for-html-sanitize-placeholder-with-extra': '$1% of apples are good' 845 | }, 'en') 846 | assert.strictEqual( 847 | banana.i18n('msg-for-html-sanitize-script'), 848 | 'This is link and it is <script>bar</script>', 849 | 'Script tag text is escaped because that element is not allowed, but link inside is still HTML' 850 | ) 851 | assert.strictEqual( 852 | banana.i18n('msg-for-html-sanitize-onclick'), 853 | 'This is link and it is <a onclick="alert()" href="#">a problem</a>', 854 | 'Common attributes are preseved and link is escaped' 855 | ) 856 | assert.strictEqual( 857 | banana.i18n('msg-for-html-sanitize-mismatched'), 858 | '<i class="important">test</b>', 859 | 'Mismatched HTML start and end tag treated as text' 860 | ) 861 | assert.strictEqual( 862 | banana.i18n('msg-for-html-sanitize-script-and-external-link'), 863 | '<script>alert( "script-and-external-link test" );</script> Foo bar', 864 | 'HTML tags in external links not interfering with escaping of other tags' 865 | ) 866 | assert.strictEqual( 867 | banana.i18n('msg-for-html-sanitize-script-as-link'), 868 | '<script>alert( "link-script test" );</script>', 869 | 'HTML tag