├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── lib └── l20n.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Tools 5 | npm-debug.log 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 James Reggio 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-l20n 2 | 3 | Experimental adaptation of Mozilla's [L20n](http://l20n.org/) localization 4 | framework for use within [React Native](https://facebook.github.io/react-native/). 5 | 6 | ```bash 7 | npm install --save react-native-l20n 8 | ``` 9 | 10 | ```javascript 11 | import React, {Component} from 'react'; 12 | import {View, Text} from 'react-native'; 13 | import L20n, {ftl} from 'react-native-l20n'; 14 | 15 | const l20n = L20n.create({ 16 | en: ftl` 17 | product = react-native-l20n 18 | welcome = Welcome, {$name}, to {product}! 19 | description = 20 | | {product} makes it possible to harness the forward-thinking 21 | | design of Mozilla's L20n in an idiomatic fashion. 22 | stars = This repository has {$count -> 23 | [0] no stars 24 | [1] one star 25 | *[other] {$count} stars 26 | } on GitHub. 27 | `, 28 | 29 | es: ftl` 30 | welcome = Bienvenidos, {$name}, a {product}! 31 | `, 32 | }); 33 | 34 | class Example extends Component { 35 | render() { 36 | return ( 37 | 38 | 39 | {l20n.welcome({name: 'James'})} 40 | 41 | 42 | {l20n.description()} 43 | 44 | 45 | {l20n.stars({count: 1000})} 46 | 47 | 48 | ); 49 | } 50 | } 51 | ``` 52 | 53 | ### Why L20n? 54 | 55 | Mozilla has decades of experience shipping localized products. The design of 56 | L20n reflects this accumulation of experience, and manages to deliver a format 57 | as powerful as [ICU `MessageFormat`](http://userguide.icu-project.org/formatparse/messages), 58 | but as simple as [`gettext`](https://en.wikipedia.org/wiki/Gettext). 59 | 60 | If these comparisons mean nothing to you, perhaps it will suffice to say that 61 | L20n makes it easy to isolate the strings in your application, perform basic 62 | variable substitutions, and handle language nuances like pluralization, gender, 63 | and declension. 64 | 65 | You don't take my word for it, though. Here are three excellent resources for 66 | getting started with L20n: 67 | 68 | * Learn the syntax with this quick step-by-step [guide](http://l20n.org/learn/). 69 | 70 | * Tinker with framework with this [browser-based IDE](http://l20n.github.io/tinker/). 71 | 72 | * Read about the decisions that underpin the powerful, asymmetric design of the 73 | framework in this [blog post](http://informationisart.com/21/). 74 | 75 | ### What's different for React Native? 76 | 77 | The main drawback of L20n, from my perspective, is that it takes a heavy 78 | dependency upon the DOM as its formal interface. Just as `StyleSheet` brought 79 | the best of CSS for use in React Native, this module decouples L20n from the 80 | DOM and makes it available to your React Native app through a familiar, 81 | idiomatic interface. 82 | 83 | The first similarity to `StyleSheet` is that L20n translations are meant to be 84 | declared within the component they're used, alongside styles. For example: 85 | 86 | ```javascript 87 | const styles = StyleSheet.create({...}); 88 | const l20n = L20n.create({...}); 89 | ``` 90 | 91 | As a consequence, nothing in the React Native implementation of L20n is 92 | asynchronous, which means that the interface for accessing translations is 93 | a simple, synchronous function that returns a string, like such: 94 | 95 | ```javascript 96 | render() { 97 | return ( 98 | 99 | {l20n.helloWorld()} 100 | 101 | ); 102 | } 103 | ``` 104 | 105 | As seen in this example, the React Native implementation of L20n does not 106 | utilize `data` attributes (or any annotations in the virtual DOM or JSX) to 107 | look up translations; it's just simple function calls, which means it can be 108 | used with any component, builtin or third party. 109 | 110 | My advice is to generally ignore the API documentation on Mozilla's L20n 111 | website with the exception of their [guide to FTL](http://l20n.org/learn/), the 112 | L20n translation format. 113 | 114 | Finally, it's worth noting that L20n depends upon the ECMAScript 115 | Internationalization API (found in browsers under `window.Intl`), which is 116 | provided via polyfill. This module also removes bidirectional isolation 117 | characters which are inserted by L20n, but not supported by either React Native 118 | platform. 119 | 120 | ## API 121 | 122 | ### `L20n.create(translations)` 123 | 124 | `translations` is an object that maps locales to translations. 125 | Locales are specified as two-letter [ISO 639-1 codes](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). 126 | Translations are specified in L20n's [FTL format](http://l20n.org/learn/). 127 | 128 | `L20n.create()` returns an object that maps each translation key to a function. 129 | The function can be invoked with a single object argument to provide variables 130 | for substitution into the translated string. 131 | 132 | When the function is invoked, a translation for the current locale is used; if 133 | none is available, the default locales are attempted in order. Failing that, 134 | the translation key is returned. 135 | 136 | **Example:** 137 | 138 | ```javascript 139 | import L20n from 'react-native-l20n'; 140 | 141 | const l20n = L20n.create({ 142 | en: `key = The value is: {$variable}` 143 | es: `key = El valor es: {$variable}` 144 | }); 145 | 146 | console.log(l20n.key({variable: 'foo')); 147 | // => "The value is: foo" if device is in English 148 | // => "El valor es: foo" if device is in Spanish 149 | ``` 150 | 151 | ### `L20.currentLocale` 152 | 153 | Get or set the current locale. 154 | The locale is specified as a two-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). 155 | 156 | The value is initialized to the locale of the device. If you wish to 157 | programmatically change it, do so before rendering your first component. 158 | 159 | ### `L20.defaultLocales` 160 | 161 | Get or set the default locales. 162 | Locales are specified as two-letter [ISO 639-1 codes](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). 163 | These locales are attempted when a translation isn't available for the current 164 | locale. 165 | 166 | Defaults to `['en']`. If you wish to programmatically change it, do so before 167 | rendering your first component. 168 | 169 | ### `ftl` 170 | 171 | ES6 templated string tag for [FTL](http://l20n.org/learn/), the L20n 172 | translation format. 173 | 174 | The `ftl` tag is not required, but enables you to indent your translations, 175 | which is not normally legal. It also removes newlines from piped, multi-line 176 | translations, which emulates the whitespace-collapsing nature of HTML. 177 | 178 | **Example:** 179 | 180 | ```javascript 181 | import {ftl} from 'react-native-l20n'; 182 | 183 | const translations = { 184 | en: ftl` 185 | firstKey = First 186 | secondKey = 187 | | This string spans 188 | | multiple lines. 189 | `, 190 | }; 191 | 192 | console.log(translations.en); 193 | // (The output is legal FTL.) => 194 | // firstKey = First 195 | // secondKey = 196 | // | This string spans multiple lines. 197 | ``` 198 | 199 | ## Future work 200 | 201 | One of the creators of L20n, [@stasm](https://github.com/stasm), has been 202 | exploring proposals for deeper integration of L20n into browser-based React. 203 | A very thorough series of proposals is under discussion on [this thread](https://groups.google.com/d/msg/mozilla.tools.l10n/XtxHgBEokCA/onHthNvtBgAJ). 204 | 205 | The approach of this module is to hew as closely as possible to plain-old 206 | portable JavaScript, with some conveniences added to conform with React Native 207 | idioms. Perhaps L20n will formalize an API that requires neither DOM access or 208 | Node.js builtin modules, which would eliminate the need to vendor a modified 209 | version of the L20n framework. (This would likely involve isolating the FTL 210 | parser and runtime from the rest of L20n.) 211 | 212 | Beyond that, there are a number of enhancements that could be added to this 213 | module to mature it into a scalable localization solution: 214 | 215 | * Handle the [`TODOs`](/index.js) listed at the top of the source. 216 | 217 | * Generalize this module for use in browser-based React or apart from any 218 | framework. This would essentially substitute for the L20n [Node.js interface](https://github.com/l20n/l20n.js/blob/97d9e50d5ec7ae84fed0db8a910c21f78880a5f1/docs/node.md), 219 | which is a bit lacking. 220 | 221 | * Build tooling to collect strings from components, and support 222 | loading/bundling of translations into separate files, apart from the 223 | component definitions. 224 | 225 | * Build a runtime inspector to identify translation keys. 226 | 227 | More than anything, I'd appreciate your feedback on what it will take for this 228 | module to become a production-grade solution for your project. Please open an 229 | [issue](https://github.com/jamesreggio/react-native-l20n/issues) to discuss. 230 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // L20n depends upon the ECMAScript Internationalization API, 2 | // which we polyfill with this module built for Node.js. 3 | import Intl from 'intl'; 4 | global.Intl = global.Intl || Intl; 5 | 6 | // L20n also uses WeakSet, which isn't available in the Android runtime. 7 | import WeakSet from 'weakset'; 8 | global.WeakSet = global.WeakSet || WeakSet; 9 | 10 | // We have to vendor a copy of L20n for now, because its Node.js build 11 | // takes an unnecessary dependency upon `fs`, which we manually remove. 12 | import './lib/l20n'; 13 | 14 | // There are many opportunities for improvement! 15 | // TODO: Get native locale. 16 | // TODO: Detect changes in native locale. 17 | // TODO: Support LCIDs (e.g., en-US) in addition to ISO 639-1 codes. 18 | // TODO: Support hot reloading. 19 | // TODO: Support composition of L20n instances. 20 | // TODO: Consider using identifiers (a la StyleSheet) to reduce bridge traffic. 21 | 22 | // Unicode bidi isolation characters. 23 | const BIDI = ['\u2068', '\u2069']; 24 | 25 | // Remove Unicode bidi isolation characters from the specified string. 26 | // L20n inserts these characters at the site of every substituted value, but 27 | // they are not supported by React Native and generally cause problems, like 28 | // making an empty string appear to have a length of 2. 29 | function clean(string) { 30 | return BIDI.reduce((string, character) => { 31 | return string.replace(new RegExp(character, 'g'), ''); 32 | }, string); 33 | } 34 | 35 | const L20n = { 36 | currentLocale: null, 37 | defaultLocales: ['en'], 38 | 39 | // Create an object of L20n translations. 40 | // Translations are specified by an object with {locale: ftl} pairs. 41 | create(translations) { 42 | const contexts = {}; 43 | const instance = {}; 44 | 45 | // Find the active context and return a formatted string for the key. 46 | function format(key, props) { 47 | const locales = [L20n.currentLocale].concat(L20n.defaultLocales); 48 | const locale = locales.find((locale) => { 49 | return contexts[locale] && contexts[locale].messages.has(key); 50 | }); 51 | 52 | const context = contexts[locale]; 53 | const message = context.messages.get(key); 54 | const [value, errors] = context.format(message, props); 55 | errors.forEach((error) => console.warn(error)); 56 | return clean(value); 57 | } 58 | 59 | // Build a context for each locale, and build the instance object with 60 | // format functions bound to each unique key. 61 | Object.keys(translations).forEach((locale) => { 62 | const context = new global.Intl.MessageContext(locale); 63 | const errors = context.addMessages(translations[locale]); 64 | errors.forEach((error) => console.warn(error)); 65 | for ([key] of context.messages) { 66 | instance[key] = instance[key] || format.bind(null, key); 67 | } 68 | contexts[locale] = context; 69 | }); 70 | 71 | return instance; 72 | }, 73 | }; 74 | 75 | export default L20n; 76 | 77 | // ES6 templated string tag for FTL, the L20n translation format. 78 | // Removes leading whitespace, allowing FTL to be written at any indentation. 79 | // Also removes explicit newlines from piped, multi-line translations. 80 | // Lines with two or more trailing spaces will receive an explicit newline. 81 | export function ftl(strings, ...values) { 82 | return strings 83 | .map((string, i) => string + (i < values.length ? values[i] : '')) 84 | .join('') 85 | .replace(/^[ ]*/mg, '') 86 | .replace(/([^ \n\]=]{2})\n\|[ ]*(?!$)/mg, '$1 '); 87 | }; 88 | -------------------------------------------------------------------------------- /lib/l20n.js: -------------------------------------------------------------------------------- 1 | /*! l20n.js v4.0.0-alpha.3 | Apache-2.0 License | github.com/l20n/l20n.js 2 | This copy was modified to remove dependency upon Node.js APIs. */ 3 | 4 | 'use strict'; 5 | 6 | Object.defineProperty(exports, '__esModule', { value: true }); 7 | 8 | /*eslint no-magic-numbers: [0]*/ 9 | 10 | const locales2rules = { 11 | 'af': 3, 12 | 'ak': 4, 13 | 'am': 4, 14 | 'ar': 1, 15 | 'asa': 3, 16 | 'az': 0, 17 | 'be': 11, 18 | 'bem': 3, 19 | 'bez': 3, 20 | 'bg': 3, 21 | 'bh': 4, 22 | 'bm': 0, 23 | 'bn': 3, 24 | 'bo': 0, 25 | 'br': 20, 26 | 'brx': 3, 27 | 'bs': 11, 28 | 'ca': 3, 29 | 'cgg': 3, 30 | 'chr': 3, 31 | 'cs': 12, 32 | 'cy': 17, 33 | 'da': 3, 34 | 'de': 3, 35 | 'dv': 3, 36 | 'dz': 0, 37 | 'ee': 3, 38 | 'el': 3, 39 | 'en': 3, 40 | 'eo': 3, 41 | 'es': 3, 42 | 'et': 3, 43 | 'eu': 3, 44 | 'fa': 0, 45 | 'ff': 5, 46 | 'fi': 3, 47 | 'fil': 4, 48 | 'fo': 3, 49 | 'fr': 5, 50 | 'fur': 3, 51 | 'fy': 3, 52 | 'ga': 8, 53 | 'gd': 24, 54 | 'gl': 3, 55 | 'gsw': 3, 56 | 'gu': 3, 57 | 'guw': 4, 58 | 'gv': 23, 59 | 'ha': 3, 60 | 'haw': 3, 61 | 'he': 2, 62 | 'hi': 4, 63 | 'hr': 11, 64 | 'hu': 0, 65 | 'id': 0, 66 | 'ig': 0, 67 | 'ii': 0, 68 | 'is': 3, 69 | 'it': 3, 70 | 'iu': 7, 71 | 'ja': 0, 72 | 'jmc': 3, 73 | 'jv': 0, 74 | 'ka': 0, 75 | 'kab': 5, 76 | 'kaj': 3, 77 | 'kcg': 3, 78 | 'kde': 0, 79 | 'kea': 0, 80 | 'kk': 3, 81 | 'kl': 3, 82 | 'km': 0, 83 | 'kn': 0, 84 | 'ko': 0, 85 | 'ksb': 3, 86 | 'ksh': 21, 87 | 'ku': 3, 88 | 'kw': 7, 89 | 'lag': 18, 90 | 'lb': 3, 91 | 'lg': 3, 92 | 'ln': 4, 93 | 'lo': 0, 94 | 'lt': 10, 95 | 'lv': 6, 96 | 'mas': 3, 97 | 'mg': 4, 98 | 'mk': 16, 99 | 'ml': 3, 100 | 'mn': 3, 101 | 'mo': 9, 102 | 'mr': 3, 103 | 'ms': 0, 104 | 'mt': 15, 105 | 'my': 0, 106 | 'nah': 3, 107 | 'naq': 7, 108 | 'nb': 3, 109 | 'nd': 3, 110 | 'ne': 3, 111 | 'nl': 3, 112 | 'nn': 3, 113 | 'no': 3, 114 | 'nr': 3, 115 | 'nso': 4, 116 | 'ny': 3, 117 | 'nyn': 3, 118 | 'om': 3, 119 | 'or': 3, 120 | 'pa': 3, 121 | 'pap': 3, 122 | 'pl': 13, 123 | 'ps': 3, 124 | 'pt': 3, 125 | 'rm': 3, 126 | 'ro': 9, 127 | 'rof': 3, 128 | 'ru': 11, 129 | 'rwk': 3, 130 | 'sah': 0, 131 | 'saq': 3, 132 | 'se': 7, 133 | 'seh': 3, 134 | 'ses': 0, 135 | 'sg': 0, 136 | 'sh': 11, 137 | 'shi': 19, 138 | 'sk': 12, 139 | 'sl': 14, 140 | 'sma': 7, 141 | 'smi': 7, 142 | 'smj': 7, 143 | 'smn': 7, 144 | 'sms': 7, 145 | 'sn': 3, 146 | 'so': 3, 147 | 'sq': 3, 148 | 'sr': 11, 149 | 'ss': 3, 150 | 'ssy': 3, 151 | 'st': 3, 152 | 'sv': 3, 153 | 'sw': 3, 154 | 'syr': 3, 155 | 'ta': 3, 156 | 'te': 3, 157 | 'teo': 3, 158 | 'th': 0, 159 | 'ti': 4, 160 | 'tig': 3, 161 | 'tk': 3, 162 | 'tl': 4, 163 | 'tn': 3, 164 | 'to': 0, 165 | 'tr': 0, 166 | 'ts': 3, 167 | 'tzm': 22, 168 | 'uk': 11, 169 | 'ur': 3, 170 | 've': 3, 171 | 'vi': 0, 172 | 'vun': 3, 173 | 'wa': 4, 174 | 'wae': 3, 175 | 'wo': 0, 176 | 'xh': 3, 177 | 'xog': 3, 178 | 'yo': 0, 179 | 'zh': 0, 180 | 'zu': 3 181 | }; 182 | 183 | // utility functions for plural rules methods 184 | function isIn(n, list) { 185 | return list.indexOf(n) !== -1; 186 | } 187 | function isBetween(n, start, end) { 188 | return typeof n === typeof start && start <= n && n <= end; 189 | } 190 | 191 | // list of all plural rules methods: 192 | // map an integer to the plural form name to use 193 | const pluralRules = { 194 | '0': function () { 195 | return 'other'; 196 | }, 197 | '1': function (n) { 198 | if (isBetween(n % 100, 3, 10)) { 199 | return 'few'; 200 | } 201 | if (n === 0) { 202 | return 'zero'; 203 | } 204 | if (isBetween(n % 100, 11, 99)) { 205 | return 'many'; 206 | } 207 | if (n === 2) { 208 | return 'two'; 209 | } 210 | if (n === 1) { 211 | return 'one'; 212 | } 213 | return 'other'; 214 | }, 215 | '2': function (n) { 216 | if (n !== 0 && n % 10 === 0) { 217 | return 'many'; 218 | } 219 | if (n === 2) { 220 | return 'two'; 221 | } 222 | if (n === 1) { 223 | return 'one'; 224 | } 225 | return 'other'; 226 | }, 227 | '3': function (n) { 228 | if (n === 1) { 229 | return 'one'; 230 | } 231 | return 'other'; 232 | }, 233 | '4': function (n) { 234 | if (isBetween(n, 0, 1)) { 235 | return 'one'; 236 | } 237 | return 'other'; 238 | }, 239 | '5': function (n) { 240 | if (isBetween(n, 0, 2) && n !== 2) { 241 | return 'one'; 242 | } 243 | return 'other'; 244 | }, 245 | '6': function (n) { 246 | if (n === 0) { 247 | return 'zero'; 248 | } 249 | if (n % 10 === 1 && n % 100 !== 11) { 250 | return 'one'; 251 | } 252 | return 'other'; 253 | }, 254 | '7': function (n) { 255 | if (n === 2) { 256 | return 'two'; 257 | } 258 | if (n === 1) { 259 | return 'one'; 260 | } 261 | return 'other'; 262 | }, 263 | '8': function (n) { 264 | if (isBetween(n, 3, 6)) { 265 | return 'few'; 266 | } 267 | if (isBetween(n, 7, 10)) { 268 | return 'many'; 269 | } 270 | if (n === 2) { 271 | return 'two'; 272 | } 273 | if (n === 1) { 274 | return 'one'; 275 | } 276 | return 'other'; 277 | }, 278 | '9': function (n) { 279 | if (n === 0 || n !== 1 && isBetween(n % 100, 1, 19)) { 280 | return 'few'; 281 | } 282 | if (n === 1) { 283 | return 'one'; 284 | } 285 | return 'other'; 286 | }, 287 | '10': function (n) { 288 | if (isBetween(n % 10, 2, 9) && !isBetween(n % 100, 11, 19)) { 289 | return 'few'; 290 | } 291 | if (n % 10 === 1 && !isBetween(n % 100, 11, 19)) { 292 | return 'one'; 293 | } 294 | return 'other'; 295 | }, 296 | '11': function (n) { 297 | if (isBetween(n % 10, 2, 4) && !isBetween(n % 100, 12, 14)) { 298 | return 'few'; 299 | } 300 | if (n % 10 === 0 || isBetween(n % 10, 5, 9) || isBetween(n % 100, 11, 14)) { 301 | return 'many'; 302 | } 303 | if (n % 10 === 1 && n % 100 !== 11) { 304 | return 'one'; 305 | } 306 | return 'other'; 307 | }, 308 | '12': function (n) { 309 | if (isBetween(n, 2, 4)) { 310 | return 'few'; 311 | } 312 | if (n === 1) { 313 | return 'one'; 314 | } 315 | return 'other'; 316 | }, 317 | '13': function (n) { 318 | if (n % 1 !== 0) { 319 | return 'other'; 320 | } 321 | if (isBetween(n % 10, 2, 4) && !isBetween(n % 100, 12, 14)) { 322 | return 'few'; 323 | } 324 | if (n !== 1 && isBetween(n % 10, 0, 1) || isBetween(n % 10, 5, 9) || isBetween(n % 100, 12, 14)) { 325 | return 'many'; 326 | } 327 | if (n === 1) { 328 | return 'one'; 329 | } 330 | return 'other'; 331 | }, 332 | '14': function (n) { 333 | if (isBetween(n % 100, 3, 4)) { 334 | return 'few'; 335 | } 336 | if (n % 100 === 2) { 337 | return 'two'; 338 | } 339 | if (n % 100 === 1) { 340 | return 'one'; 341 | } 342 | return 'other'; 343 | }, 344 | '15': function (n) { 345 | if (n === 0 || isBetween(n % 100, 2, 10)) { 346 | return 'few'; 347 | } 348 | if (isBetween(n % 100, 11, 19)) { 349 | return 'many'; 350 | } 351 | if (n === 1) { 352 | return 'one'; 353 | } 354 | return 'other'; 355 | }, 356 | '16': function (n) { 357 | if (n % 10 === 1 && n !== 11) { 358 | return 'one'; 359 | } 360 | return 'other'; 361 | }, 362 | '17': function (n) { 363 | if (n === 3) { 364 | return 'few'; 365 | } 366 | if (n === 0) { 367 | return 'zero'; 368 | } 369 | if (n === 6) { 370 | return 'many'; 371 | } 372 | if (n === 2) { 373 | return 'two'; 374 | } 375 | if (n === 1) { 376 | return 'one'; 377 | } 378 | return 'other'; 379 | }, 380 | '18': function (n) { 381 | if (n === 0) { 382 | return 'zero'; 383 | } 384 | if (isBetween(n, 0, 2) && n !== 0 && n !== 2) { 385 | return 'one'; 386 | } 387 | return 'other'; 388 | }, 389 | '19': function (n) { 390 | if (isBetween(n, 2, 10)) { 391 | return 'few'; 392 | } 393 | if (isBetween(n, 0, 1)) { 394 | return 'one'; 395 | } 396 | return 'other'; 397 | }, 398 | '20': function (n) { 399 | if ((isBetween(n % 10, 3, 4) || n % 10 === 9) && !(isBetween(n % 100, 10, 19) || isBetween(n % 100, 70, 79) || isBetween(n % 100, 90, 99))) { 400 | return 'few'; 401 | } 402 | if (n % 1000000 === 0 && n !== 0) { 403 | return 'many'; 404 | } 405 | if (n % 10 === 2 && !isIn(n % 100, [12, 72, 92])) { 406 | return 'two'; 407 | } 408 | if (n % 10 === 1 && !isIn(n % 100, [11, 71, 91])) { 409 | return 'one'; 410 | } 411 | return 'other'; 412 | }, 413 | '21': function (n) { 414 | if (n === 0) { 415 | return 'zero'; 416 | } 417 | if (n === 1) { 418 | return 'one'; 419 | } 420 | return 'other'; 421 | }, 422 | '22': function (n) { 423 | if (isBetween(n, 0, 1) || isBetween(n, 11, 99)) { 424 | return 'one'; 425 | } 426 | return 'other'; 427 | }, 428 | '23': function (n) { 429 | if (isBetween(n % 10, 1, 2) || n % 20 === 0) { 430 | return 'one'; 431 | } 432 | return 'other'; 433 | }, 434 | '24': function (n) { 435 | if (isBetween(n, 3, 10) || isBetween(n, 13, 19)) { 436 | return 'few'; 437 | } 438 | if (isIn(n, [2, 12])) { 439 | return 'two'; 440 | } 441 | if (isIn(n, [1, 11])) { 442 | return 'one'; 443 | } 444 | return 'other'; 445 | } 446 | }; 447 | 448 | function getPluralRule(code) { 449 | // return a function that gives the plural form name for a given integer 450 | const index = locales2rules[code.replace(/-.*$/, '')]; 451 | if (!(index in pluralRules)) { 452 | return () => 'other'; 453 | } 454 | return pluralRules[index]; 455 | } 456 | 457 | class L10nError extends Error { 458 | constructor(message, id, lang) { 459 | super(); 460 | this.name = 'L10nError'; 461 | this.message = message; 462 | this.id = id; 463 | this.lang = lang; 464 | } 465 | } 466 | 467 | class ParseContext { 468 | constructor(string) { 469 | this._source = string; 470 | this._index = 0; 471 | this._length = string.length; 472 | 473 | this._lastGoodEntryEnd = 0; 474 | } 475 | 476 | getResource() { 477 | const entries = {}; 478 | const errors = []; 479 | 480 | this.getWS(); 481 | while (this._index < this._length) { 482 | try { 483 | const entry = this.getEntry(); 484 | if (!entry) { 485 | this.getWS(); 486 | continue; 487 | } 488 | 489 | const id = entry.id; 490 | entries[id] = {}; 491 | 492 | if (entry.traits !== null && entry.traits.length !== 0) { 493 | entries[id].traits = entry.traits; 494 | if (entry.value) { 495 | entries[id].val = entry.value; 496 | } 497 | } else { 498 | entries[id] = entry.value; 499 | } 500 | this._lastGoodEntryEnd = this._index; 501 | } catch (e) { 502 | if (e instanceof L10nError) { 503 | errors.push(e); 504 | this.getJunkEntry(); 505 | } else { 506 | throw e; 507 | } 508 | } 509 | this.getWS(); 510 | } 511 | 512 | return [entries, errors]; 513 | } 514 | 515 | getEntry() { 516 | if (this._index !== 0 && this._source[this._index - 1] !== '\n') { 517 | throw this.error('Expected new line and a new entry'); 518 | } 519 | 520 | if (this._source[this._index] === '#') { 521 | this.getComment(); 522 | return; 523 | } 524 | 525 | if (this._source[this._index] === '[') { 526 | this.getSection(); 527 | return; 528 | } 529 | 530 | if (this._index < this._length && this._source[this._index] !== '\n') { 531 | return this.getEntity(); 532 | } 533 | } 534 | 535 | getSection() { 536 | this._index += 1; 537 | if (this._source[this._index] !== '[') { 538 | throw this.error('Expected "[[" to open a section'); 539 | } 540 | 541 | this._index += 1; 542 | 543 | this.getLineWS(); 544 | this.getKeyword(); 545 | this.getLineWS(); 546 | 547 | if (this._source[this._index] !== ']' || this._source[this._index + 1] !== ']') { 548 | throw this.error('Expected "]]" to close a section'); 549 | } 550 | 551 | this._index += 2; 552 | 553 | // sections are ignored in the runtime ast 554 | return undefined; 555 | } 556 | 557 | getEntity() { 558 | const id = this.getIdentifier(); 559 | 560 | let traits = null; 561 | let value = null; 562 | 563 | this.getLineWS(); 564 | 565 | let ch = this._source[this._index]; 566 | 567 | if (ch !== '=') { 568 | throw this.error('Expected "=" after Entity ID'); 569 | } 570 | ch = this._source[++this._index]; 571 | 572 | this.getLineWS(); 573 | 574 | value = this.getPattern(); 575 | 576 | ch = this._source[this._index]; 577 | 578 | if (ch === '\n') { 579 | this._index++; 580 | this.getLineWS(); 581 | ch = this._source[this._index]; 582 | } 583 | 584 | if (ch === '[' && this._source[this._index + 1] !== '[' || ch === '*') { 585 | traits = this.getMembers(); 586 | } else if (value === null) { 587 | throw this.error('Expected a value (like: " = value") or a trait (like: "[key] value")'); 588 | } 589 | 590 | return { 591 | id, 592 | value, 593 | traits 594 | }; 595 | } 596 | 597 | getWS() { 598 | let cc = this._source.charCodeAt(this._index); 599 | // space, \n, \t, \r 600 | while (cc === 32 || cc === 10 || cc === 9 || cc === 13) { 601 | cc = this._source.charCodeAt(++this._index); 602 | } 603 | } 604 | 605 | getLineWS() { 606 | let cc = this._source.charCodeAt(this._index); 607 | // space, \t 608 | while (cc === 32 || cc === 9) { 609 | cc = this._source.charCodeAt(++this._index); 610 | } 611 | } 612 | 613 | getIdentifier() { 614 | let name = ''; 615 | 616 | const start = this._index; 617 | let cc = this._source.charCodeAt(this._index); 618 | 619 | if (cc >= 97 && cc <= 122 || // a-z 620 | cc >= 65 && cc <= 90 || // A-Z 621 | cc === 95) { 622 | // _ 623 | cc = this._source.charCodeAt(++this._index); 624 | } else if (name.length === 0) { 625 | throw this.error('Expected an identifier (starting with [a-zA-Z_])'); 626 | } 627 | 628 | while (cc >= 97 && cc <= 122 || // a-z 629 | cc >= 65 && cc <= 90 || // A-Z 630 | cc >= 48 && cc <= 57 || // 0-9 631 | cc === 95 || cc === 45) { 632 | // _- 633 | cc = this._source.charCodeAt(++this._index); 634 | } 635 | 636 | name += this._source.slice(start, this._index); 637 | 638 | return name; 639 | } 640 | 641 | getKeyword() { 642 | let name = ''; 643 | let namespace = this.getIdentifier(); 644 | 645 | if (this._source[this._index] === '/') { 646 | this._index++; 647 | } else if (namespace) { 648 | name = namespace; 649 | namespace = null; 650 | } 651 | 652 | const start = this._index; 653 | let cc = this._source.charCodeAt(this._index); 654 | 655 | if (cc >= 97 && cc <= 122 || // a-z 656 | cc >= 65 && cc <= 90 || // A-Z 657 | cc === 95 || cc === 32) { 658 | // _ 659 | cc = this._source.charCodeAt(++this._index); 660 | } else if (name.length === 0) { 661 | throw this.error('Expected an identifier (starting with [a-zA-Z_])'); 662 | } 663 | 664 | while (cc >= 97 && cc <= 122 || // a-z 665 | cc >= 65 && cc <= 90 || // A-Z 666 | cc >= 48 && cc <= 57 || // 0-9 667 | cc === 95 || cc === 45 || cc === 32) { 668 | // _- 669 | cc = this._source.charCodeAt(++this._index); 670 | } 671 | 672 | name += this._source.slice(start, this._index).trimRight(); 673 | 674 | return namespace ? { type: 'kw', ns: namespace, name } : { type: 'kw', name }; 675 | } 676 | 677 | getPattern() { 678 | const start = this._index; 679 | if (this._source[start] === '"') { 680 | return this.getComplexPattern(); 681 | } 682 | let eol = this._source.indexOf('\n', this._index); 683 | 684 | if (eol === -1) { 685 | eol = this._length; 686 | } 687 | 688 | const line = this._source.slice(start, eol); 689 | 690 | if (line.indexOf('{') !== -1) { 691 | return this.getComplexPattern(); 692 | } 693 | 694 | this._index = eol + 1; 695 | 696 | this.getWS(); 697 | 698 | if (this._source[this._index] === '|') { 699 | this._index = start; 700 | return this.getComplexPattern(); 701 | } 702 | 703 | return this._source.slice(start, eol); 704 | } 705 | 706 | getComplexPattern() { 707 | let buffer = ''; 708 | const content = []; 709 | let quoteDelimited = null; 710 | let firstLine = true; 711 | 712 | let ch = this._source[this._index]; 713 | 714 | if (ch === '\\' && (this._source[this._index + 1] === '"' || this._source[this._index + 1] === '{' || this._source[this._index + 1] === '\\')) { 715 | buffer += this._source[this._index + 1]; 716 | this._index += 2; 717 | ch = this._source[this._index]; 718 | } else if (ch === '"') { 719 | quoteDelimited = true; 720 | this._index++; 721 | ch = this._source[this._index]; 722 | } 723 | 724 | while (this._index < this._length) { 725 | if (ch === '\n') { 726 | if (quoteDelimited) { 727 | throw this.error('Unclosed string'); 728 | } 729 | this._index++; 730 | this.getLineWS(); 731 | if (this._source[this._index] !== '|') { 732 | break; 733 | } 734 | if (firstLine && buffer.length) { 735 | throw this.error('Multiline string should have the ID line empty'); 736 | } 737 | firstLine = false; 738 | this._index++; 739 | if (this._source[this._index] === ' ') { 740 | this._index++; 741 | } 742 | if (buffer.length) { 743 | buffer += '\n'; 744 | } 745 | ch = this._source[this._index]; 746 | continue; 747 | } else if (ch === '\\') { 748 | const ch2 = this._source[this._index + 1]; 749 | if (quoteDelimited && ch2 === '"' || ch2 === '{') { 750 | ch = ch2; 751 | this._index++; 752 | } 753 | } else if (quoteDelimited && ch === '"') { 754 | this._index++; 755 | quoteDelimited = false; 756 | break; 757 | } else if (ch === '{') { 758 | if (buffer.length) { 759 | content.push(buffer); 760 | } 761 | buffer = ''; 762 | content.push(this.getPlaceable()); 763 | ch = this._source[this._index]; 764 | continue; 765 | } 766 | 767 | if (ch) { 768 | buffer += ch; 769 | } 770 | this._index++; 771 | ch = this._source[this._index]; 772 | } 773 | 774 | if (quoteDelimited) { 775 | throw this.error('Unclosed string'); 776 | } 777 | 778 | if (buffer.length) { 779 | content.push(buffer); 780 | } 781 | 782 | if (content.length === 0) { 783 | if (quoteDelimited !== null) { 784 | return ''; 785 | } else { 786 | return null; 787 | } 788 | } 789 | 790 | if (content.length === 1 && typeof content[0] === 'string') { 791 | return content[0]; 792 | } 793 | 794 | return content; 795 | } 796 | 797 | getPlaceable() { 798 | this._index++; 799 | 800 | const expressions = []; 801 | 802 | this.getLineWS(); 803 | 804 | while (this._index < this._length) { 805 | const start = this._index; 806 | try { 807 | expressions.push(this.getPlaceableExpression()); 808 | } catch (e) { 809 | throw this.error(e.description, start); 810 | } 811 | this.getWS(); 812 | if (this._source[this._index] === '}') { 813 | this._index++; 814 | break; 815 | } else if (this._source[this._index] === ',') { 816 | this._index++; 817 | this.getWS(); 818 | } else { 819 | throw this.error('Expected "}" or ","'); 820 | } 821 | } 822 | 823 | return expressions; 824 | } 825 | 826 | getPlaceableExpression() { 827 | const selector = this.getCallExpression(); 828 | let members = null; 829 | 830 | this.getWS(); 831 | 832 | if (this._source[this._index] !== '}' && this._source[this._index] !== ',') { 833 | if (this._source[this._index] !== '-' || this._source[this._index + 1] !== '>') { 834 | throw this.error('Expected "}", "," or "->"'); 835 | } 836 | this._index += 2; // -> 837 | 838 | this.getLineWS(); 839 | 840 | if (this._source[this._index] !== '\n') { 841 | throw this.error('Members should be listed in a new line'); 842 | } 843 | 844 | this.getWS(); 845 | 846 | members = this.getMembers(); 847 | 848 | if (members.length === 0) { 849 | throw this.error('Expected members for the select expression'); 850 | } 851 | } 852 | 853 | if (members === null) { 854 | return selector; 855 | } 856 | return { 857 | type: 'sel', 858 | exp: selector, 859 | vars: members 860 | }; 861 | } 862 | 863 | getCallExpression() { 864 | const exp = this.getMemberExpression(); 865 | 866 | if (this._source[this._index] !== '(') { 867 | return exp; 868 | } 869 | 870 | this._index++; 871 | 872 | const args = this.getCallArgs(); 873 | 874 | this._index++; 875 | 876 | if (exp.type = 'ref') { 877 | exp.type = 'fun'; 878 | } 879 | 880 | return { 881 | type: 'call', 882 | name: exp, 883 | args 884 | }; 885 | } 886 | 887 | getCallArgs() { 888 | const args = []; 889 | 890 | if (this._source[this._index] === ')') { 891 | return args; 892 | } 893 | 894 | while (this._index < this._length) { 895 | this.getLineWS(); 896 | 897 | const exp = this.getCallExpression(); 898 | 899 | if (exp.type !== 'ref' || exp.namespace !== undefined) { 900 | args.push(exp); 901 | } else { 902 | this.getLineWS(); 903 | 904 | if (this._source[this._index] === ':') { 905 | this._index++; 906 | this.getLineWS(); 907 | 908 | const val = this.getCallExpression(); 909 | 910 | if (val.type === 'ref' || val.type === 'member') { 911 | this._index = this._source.lastIndexOf('=', this._index) + 1; 912 | throw this.error('Expected string in quotes'); 913 | } 914 | 915 | args.push({ 916 | type: 'kv', 917 | name: exp.name, 918 | val 919 | }); 920 | } else { 921 | args.push(exp); 922 | } 923 | } 924 | 925 | this.getLineWS(); 926 | 927 | if (this._source[this._index] === ')') { 928 | break; 929 | } else if (this._source[this._index] === ',') { 930 | this._index++; 931 | } else { 932 | throw this.error('Expected "," or ")"'); 933 | } 934 | } 935 | 936 | return args; 937 | } 938 | 939 | getNumber() { 940 | let num = ''; 941 | let cc = this._source.charCodeAt(this._index); 942 | 943 | if (cc === 45) { 944 | num += '-'; 945 | cc = this._source.charCodeAt(++this._index); 946 | } 947 | 948 | if (cc < 48 || cc > 57) { 949 | throw this.error(`Unknown literal "${ num }"`); 950 | } 951 | 952 | while (cc >= 48 && cc <= 57) { 953 | num += this._source[this._index++]; 954 | cc = this._source.charCodeAt(this._index); 955 | } 956 | 957 | if (cc === 46) { 958 | num += this._source[this._index++]; 959 | cc = this._source.charCodeAt(this._index); 960 | 961 | if (cc < 48 || cc > 57) { 962 | throw this.error(`Unknown literal "${ num }"`); 963 | } 964 | 965 | while (cc >= 48 && cc <= 57) { 966 | num += this._source[this._index++]; 967 | cc = this._source.charCodeAt(this._index); 968 | } 969 | } 970 | 971 | return { 972 | type: 'num', 973 | val: num 974 | }; 975 | } 976 | 977 | getMemberExpression() { 978 | let exp = this.getLiteral(); 979 | 980 | while (this._source[this._index] === '[') { 981 | const keyword = this.getMemberKey(); 982 | exp = { 983 | type: 'mem', 984 | key: keyword, 985 | obj: exp 986 | }; 987 | } 988 | 989 | return exp; 990 | } 991 | 992 | getMembers() { 993 | const members = []; 994 | 995 | while (this._index < this._length) { 996 | if ((this._source[this._index] !== '[' || this._source[this._index + 1] === '[') && this._source[this._index] !== '*') { 997 | break; 998 | } 999 | let def = false; 1000 | if (this._source[this._index] === '*') { 1001 | this._index++; 1002 | def = true; 1003 | } 1004 | 1005 | if (this._source[this._index] !== '[') { 1006 | throw this.error('Expected "["'); 1007 | } 1008 | 1009 | const key = this.getMemberKey(); 1010 | 1011 | this.getLineWS(); 1012 | 1013 | const value = this.getPattern(); 1014 | 1015 | const member = { 1016 | key, 1017 | val: value 1018 | }; 1019 | if (def) { 1020 | member.def = true; 1021 | } 1022 | members.push(member); 1023 | 1024 | this.getWS(); 1025 | } 1026 | 1027 | return members; 1028 | } 1029 | 1030 | getMemberKey() { 1031 | this._index++; 1032 | 1033 | const cc = this._source.charCodeAt(this._index); 1034 | let literal; 1035 | 1036 | if (cc >= 48 && cc <= 57 || cc === 45) { 1037 | literal = this.getNumber(); 1038 | } else { 1039 | literal = this.getKeyword(); 1040 | } 1041 | 1042 | if (this._source[this._index] !== ']') { 1043 | throw this.error('Expected "]"'); 1044 | } 1045 | 1046 | this._index++; 1047 | return literal; 1048 | } 1049 | 1050 | getLiteral() { 1051 | const cc = this._source.charCodeAt(this._index); 1052 | if (cc >= 48 && cc <= 57 || cc === 45) { 1053 | return this.getNumber(); 1054 | } else if (cc === 34) { 1055 | // " 1056 | return this.getPattern(); 1057 | } else if (cc === 36) { 1058 | // $ 1059 | this._index++; 1060 | return { 1061 | type: 'ext', 1062 | name: this.getIdentifier() 1063 | }; 1064 | } 1065 | 1066 | return { 1067 | type: 'ref', 1068 | name: this.getIdentifier() 1069 | }; 1070 | } 1071 | 1072 | getComment() { 1073 | let eol = this._source.indexOf('\n', this._index); 1074 | 1075 | while (eol !== -1 && this._source[eol + 1] === '#') { 1076 | this._index = eol + 2; 1077 | 1078 | eol = this._source.indexOf('\n', this._index); 1079 | 1080 | if (eol === -1) { 1081 | break; 1082 | } 1083 | } 1084 | 1085 | if (eol === -1) { 1086 | this._index = this._length; 1087 | } else { 1088 | this._index = eol + 1; 1089 | } 1090 | } 1091 | 1092 | error(message, start = null) { 1093 | const pos = this._index; 1094 | 1095 | if (start === null) { 1096 | start = pos; 1097 | } 1098 | start = this._findEntityStart(start); 1099 | 1100 | const context = this._source.slice(start, pos + 10); 1101 | 1102 | const msg = '\n\n ' + message + '\nat pos ' + pos + ':\n------\n…' + context + '\n------'; 1103 | const err = new L10nError(msg); 1104 | 1105 | const row = this._source.slice(0, pos).split('\n').length; 1106 | const col = pos - this._source.lastIndexOf('\n', pos - 1); 1107 | err._pos = { start: pos, end: undefined, col: col, row: row }; 1108 | err.offset = pos - start; 1109 | err.description = message; 1110 | err.context = context; 1111 | return err; 1112 | } 1113 | 1114 | getJunkEntry() { 1115 | const pos = this._index; 1116 | 1117 | let nextEntity = this._findNextEntryStart(pos); 1118 | 1119 | if (nextEntity === -1) { 1120 | nextEntity = this._length; 1121 | } 1122 | 1123 | this._index = nextEntity; 1124 | 1125 | let entityStart = this._findEntityStart(pos); 1126 | 1127 | if (entityStart < this._lastGoodEntryEnd) { 1128 | entityStart = this._lastGoodEntryEnd; 1129 | } 1130 | } 1131 | 1132 | _findEntityStart(pos) { 1133 | let start = pos; 1134 | 1135 | while (true) { 1136 | start = this._source.lastIndexOf('\n', start - 2); 1137 | if (start === -1 || start === 0) { 1138 | start = 0; 1139 | break; 1140 | } 1141 | const cc = this._source.charCodeAt(start + 1); 1142 | 1143 | if (cc >= 97 && cc <= 122 || // a-z 1144 | cc >= 65 && cc <= 90 || // A-Z 1145 | cc === 95) { 1146 | // _ 1147 | start++; 1148 | break; 1149 | } 1150 | } 1151 | 1152 | return start; 1153 | } 1154 | 1155 | _findNextEntryStart(pos) { 1156 | let start = pos; 1157 | 1158 | while (true) { 1159 | if (start === 0 || this._source[start - 1] === '\n') { 1160 | const cc = this._source.charCodeAt(start); 1161 | 1162 | if (cc >= 97 && cc <= 122 || // a-z 1163 | cc >= 65 && cc <= 90 || // A-Z 1164 | cc === 95 || cc === 35 || cc === 91) { 1165 | // _#[ 1166 | break; 1167 | } 1168 | } 1169 | 1170 | start = this._source.indexOf('\n', start); 1171 | 1172 | if (start === -1) { 1173 | break; 1174 | } 1175 | start++; 1176 | } 1177 | 1178 | return start; 1179 | } 1180 | } 1181 | 1182 | var FTLRuntimeParser = { 1183 | parseResource: function (string) { 1184 | const parseContext = new ParseContext(string); 1185 | return parseContext.getResource(); 1186 | } 1187 | }; 1188 | 1189 | class ReadWrite { 1190 | constructor(fn) { 1191 | this.fn = fn; 1192 | } 1193 | 1194 | run(ctx) { 1195 | return this.fn(ctx); 1196 | } 1197 | 1198 | flatMap(fn) { 1199 | return new ReadWrite(ctx => { 1200 | const [cur, curErrs] = this.run(ctx); 1201 | const [val, valErrs] = fn(cur).run(ctx); 1202 | return [val, [...curErrs, ...valErrs]]; 1203 | }); 1204 | } 1205 | } 1206 | 1207 | function ask() { 1208 | return new ReadWrite(ctx => [ctx, []]); 1209 | } 1210 | 1211 | function tell(log) { 1212 | return new ReadWrite(() => [null, [log]]); 1213 | } 1214 | 1215 | function unit(val) { 1216 | return new ReadWrite(() => [val, []]); 1217 | } 1218 | 1219 | function resolve(iter) { 1220 | return function step(resume) { 1221 | const { value, done } = iter.next(resume); 1222 | const rw = value instanceof ReadWrite ? value : unit(value); 1223 | return done ? rw : rw.flatMap(step); 1224 | }(); 1225 | } 1226 | 1227 | class FTLBase { 1228 | constructor(value, opts) { 1229 | this.value = value; 1230 | this.opts = opts; 1231 | } 1232 | valueOf() { 1233 | return this.value; 1234 | } 1235 | } 1236 | 1237 | class FTLNone extends FTLBase { 1238 | toString() { 1239 | return this.value || '???'; 1240 | } 1241 | } 1242 | 1243 | class FTLNumber extends FTLBase { 1244 | constructor(value, opts) { 1245 | super(parseFloat(value), opts); 1246 | } 1247 | toString(ctx) { 1248 | const nf = ctx._memoizeIntlObject(Intl.NumberFormat, this.opts); 1249 | return nf.format(this.value); 1250 | } 1251 | } 1252 | 1253 | class FTLDateTime extends FTLBase { 1254 | constructor(value, opts) { 1255 | super(new Date(value), opts); 1256 | } 1257 | toString(ctx) { 1258 | const dtf = ctx._memoizeIntlObject(Intl.DateTimeFormat, this.opts); 1259 | return dtf.format(this.value); 1260 | } 1261 | } 1262 | 1263 | class FTLKeyword extends FTLBase { 1264 | toString() { 1265 | const { name, namespace } = this.value; 1266 | return namespace ? `${ namespace }:${ name }` : name; 1267 | } 1268 | match(ctx, other) { 1269 | const { name, namespace } = this.value; 1270 | if (other instanceof FTLKeyword) { 1271 | return name === other.value.name && namespace === other.value.namespace; 1272 | } else if (namespace) { 1273 | return false; 1274 | } else if (typeof other === 'string') { 1275 | return name === other; 1276 | } else if (other instanceof FTLNumber) { 1277 | const pr = ctx._memoizeIntlObject(Intl.PluralRules, other.opts); 1278 | return name === pr.select(other.valueOf()); 1279 | } else { 1280 | return false; 1281 | } 1282 | } 1283 | } 1284 | 1285 | class FTLList extends Array { 1286 | toString(ctx) { 1287 | const lf = ctx._memoizeIntlObject(Intl.ListFormat // XXX add this.opts 1288 | ); 1289 | const elems = this.map(elem => elem.toString(ctx)); 1290 | return lf.format(elems); 1291 | } 1292 | } 1293 | 1294 | // each builtin takes two arguments: 1295 | // - args = an array of positional args 1296 | // - opts = an object of key-value args 1297 | 1298 | var builtins = { 1299 | 'NUMBER': ([arg], opts) => new FTLNumber(arg.valueOf(), merge(arg.opts, opts)), 1300 | 'PLURAL': ([arg], opts) => new FTLNumber(arg.valueOf(), merge(arg.opts, opts)), 1301 | 'DATETIME': ([arg], opts) => new FTLDateTime(arg.valueOf(), merge(arg.opts, opts)), 1302 | 'LIST': args => FTLList.from(args), 1303 | 'LEN': ([arg]) => new FTLNumber(arg.valueOf().length), 1304 | 'TAKE': ([num, arg]) => FTLList.from(arg.valueOf().slice(0, num.value)), 1305 | 'DROP': ([num, arg]) => FTLList.from(arg.valueOf().slice(num.value)) 1306 | }; 1307 | 1308 | function merge(argopts, opts) { 1309 | return Object.assign({}, argopts, valuesOf(opts)); 1310 | } 1311 | 1312 | function valuesOf(opts) { 1313 | return Object.keys(opts).reduce((seq, cur) => Object.assign({}, seq, { 1314 | [cur]: opts[cur].valueOf() 1315 | }), {}); 1316 | } 1317 | 1318 | // Unicode bidi isolation characters 1319 | const FSI = '\u2068'; 1320 | const PDI = '\u2069'; 1321 | 1322 | const MAX_PLACEABLE_LENGTH = 2500; 1323 | 1324 | function* mapValues(arr) { 1325 | let values = new FTLList(); 1326 | for (let elem of arr) { 1327 | values.push((yield* Value(elem))); 1328 | } 1329 | return values; 1330 | } 1331 | 1332 | // Helper for choosing entity value 1333 | function* DefaultMember(members, allowNoDefault = false) { 1334 | for (let member of members) { 1335 | if (member.def) { 1336 | return member; 1337 | } 1338 | } 1339 | 1340 | if (!allowNoDefault) { 1341 | yield tell(new RangeError('No default')); 1342 | } 1343 | 1344 | return { val: new FTLNone() }; 1345 | } 1346 | 1347 | // Half-resolved expressions evaluate to raw Runtime AST nodes 1348 | 1349 | function* EntityReference({ name }) { 1350 | const { ctx } = yield ask(); 1351 | const entity = ctx.messages.get(name); 1352 | 1353 | if (!entity) { 1354 | yield tell(new ReferenceError(`Unknown entity: ${ name }`)); 1355 | return new FTLNone(name); 1356 | } 1357 | 1358 | return entity; 1359 | } 1360 | 1361 | function* MemberExpression({ obj, key }) { 1362 | const entity = yield* EntityReference(obj); 1363 | if (entity instanceof FTLNone) { 1364 | return { val: entity }; 1365 | } 1366 | 1367 | const { ctx } = yield ask(); 1368 | const keyword = yield* Value(key); 1369 | 1370 | for (let member of entity.traits) { 1371 | const memberKey = yield* Value(member.key); 1372 | if (keyword.match(ctx, memberKey)) { 1373 | return member; 1374 | } 1375 | } 1376 | 1377 | yield tell(new ReferenceError(`Unknown trait: ${ keyword.toString(ctx) }`)); 1378 | return { 1379 | val: yield* Entity(entity) 1380 | }; 1381 | } 1382 | 1383 | function* SelectExpression({ exp, vars }) { 1384 | const selector = yield* Value(exp); 1385 | if (selector instanceof FTLNone) { 1386 | return yield* DefaultMember(vars); 1387 | } 1388 | 1389 | for (let variant of vars) { 1390 | const key = yield* Value(variant.key); 1391 | 1392 | if (key instanceof FTLNumber && selector instanceof FTLNumber && key.valueOf() === selector.valueOf()) { 1393 | return variant; 1394 | } 1395 | 1396 | const { ctx } = yield ask(); 1397 | 1398 | if (key instanceof FTLKeyword && key.match(ctx, selector)) { 1399 | return variant; 1400 | } 1401 | } 1402 | 1403 | return yield* DefaultMember(vars); 1404 | } 1405 | 1406 | // Fully-resolved expressions evaluate to FTL types 1407 | 1408 | function* Value(expr) { 1409 | if (typeof expr === 'string' || expr instanceof FTLNone) { 1410 | return expr; 1411 | } 1412 | 1413 | if (Array.isArray(expr)) { 1414 | return yield* Pattern(expr); 1415 | } 1416 | 1417 | switch (expr.type) { 1418 | case 'kw': 1419 | return new FTLKeyword(expr); 1420 | case 'num': 1421 | return new FTLNumber(expr.val); 1422 | case 'ext': 1423 | return yield* ExternalArgument(expr); 1424 | case 'fun': 1425 | return yield* FunctionReference(expr); 1426 | case 'call': 1427 | return yield* CallExpression(expr); 1428 | case 'ref': 1429 | const ref = yield* EntityReference(expr); 1430 | return yield* Entity(ref); 1431 | case 'mem': 1432 | const mem = yield* MemberExpression(expr); 1433 | return yield* Value(mem.val); 1434 | case 'sel': 1435 | const sel = yield* SelectExpression(expr); 1436 | return yield* Value(sel.val); 1437 | default: 1438 | return yield* Value(expr.val); 1439 | } 1440 | } 1441 | 1442 | function* ExternalArgument({ name }) { 1443 | const { args } = yield ask(); 1444 | 1445 | if (!args || !args.hasOwnProperty(name)) { 1446 | yield tell(new ReferenceError(`Unknown external: ${ name }`)); 1447 | return new FTLNone(name); 1448 | } 1449 | 1450 | const arg = args[name]; 1451 | 1452 | if (arg instanceof FTLBase) { 1453 | return arg; 1454 | } 1455 | 1456 | switch (typeof arg) { 1457 | case 'string': 1458 | return arg; 1459 | case 'number': 1460 | return new FTLNumber(arg); 1461 | case 'object': 1462 | if (Array.isArray(arg)) { 1463 | return yield* mapValues(arg); 1464 | } 1465 | if (arg instanceof Date) { 1466 | return new FTLDateTime(arg); 1467 | } 1468 | default: 1469 | yield tell(new TypeError(`Unsupported external type: ${ name }, ${ typeof arg }`)); 1470 | return new FTLNone(name); 1471 | } 1472 | } 1473 | 1474 | function* FunctionReference({ name }) { 1475 | const { ctx: { functions } } = yield ask(); 1476 | const func = functions[name] || builtins[name]; 1477 | 1478 | if (!func) { 1479 | yield tell(new ReferenceError(`Unknown built-in: ${ name }()`)); 1480 | return new FTLNone(`${ name }()`); 1481 | } 1482 | 1483 | if (!(func instanceof Function)) { 1484 | yield tell(new TypeError(`Function ${ name }() is not callable`)); 1485 | return new FTLNone(`${ name }()`); 1486 | } 1487 | 1488 | return func; 1489 | } 1490 | 1491 | function* CallExpression({ name, args }) { 1492 | const callee = yield* FunctionReference(name); 1493 | 1494 | if (callee instanceof FTLNone) { 1495 | return callee; 1496 | } 1497 | 1498 | const posargs = []; 1499 | const keyargs = []; 1500 | 1501 | for (let arg of args) { 1502 | if (arg.type === 'kv') { 1503 | keyargs[arg.name] = yield* Value(arg.val); 1504 | } else { 1505 | posargs.push((yield* Value(arg))); 1506 | } 1507 | } 1508 | 1509 | // XXX builtins should also returns [val, errs] tuples 1510 | return callee(posargs, keyargs); 1511 | } 1512 | 1513 | function* Pattern(ptn) { 1514 | const { ctx, dirty } = yield ask(); 1515 | 1516 | if (dirty.has(ptn)) { 1517 | yield tell(new RangeError('Cyclic reference')); 1518 | return new FTLNone(); 1519 | } 1520 | 1521 | dirty.add(ptn); 1522 | let result = ''; 1523 | 1524 | for (let part of ptn) { 1525 | if (typeof part === 'string') { 1526 | result += part; 1527 | } else { 1528 | const value = part.length === 1 ? yield* Value(part[0]) : yield* mapValues(part); 1529 | 1530 | const str = value.toString(ctx); 1531 | if (str.length > MAX_PLACEABLE_LENGTH) { 1532 | yield tell(new RangeError('Too many characters in placeable ' + `(${ str.length }, max allowed is ${ MAX_PLACEABLE_LENGTH })`)); 1533 | result += FSI + str.substr(0, MAX_PLACEABLE_LENGTH) + PDI; 1534 | } else { 1535 | result += FSI + str + PDI; 1536 | } 1537 | } 1538 | } 1539 | 1540 | dirty.delete(ptn); 1541 | return result; 1542 | } 1543 | 1544 | function* Entity(entity, allowNoDefault = false) { 1545 | if (entity.val !== undefined) { 1546 | return yield* Value(entity.val); 1547 | } 1548 | 1549 | if (!entity.traits) { 1550 | return yield* Value(entity); 1551 | } 1552 | 1553 | const def = yield* DefaultMember(entity.traits, allowNoDefault); 1554 | return yield* Value(def); 1555 | } 1556 | 1557 | // evaluate `entity` to an FTL Value type: string or FTLNone 1558 | function* toFTLType(entity, opts) { 1559 | if (entity === undefined) { 1560 | return new FTLNone(); 1561 | } 1562 | 1563 | return yield* Entity(entity, opts.allowNoDefault); 1564 | } 1565 | 1566 | const _opts = { 1567 | allowNoDefault: false 1568 | }; 1569 | 1570 | function format(ctx, args, entity, opts = _opts) { 1571 | // optimization: many translations are simple strings and we can very easily 1572 | // avoid the cost of a proper resolution by having this shortcut here 1573 | if (typeof entity === 'string') { 1574 | return [entity, []]; 1575 | } 1576 | 1577 | return resolve(toFTLType(entity, opts)).run({ 1578 | ctx, args, dirty: new WeakSet() 1579 | }); 1580 | } 1581 | 1582 | const optsPrimitive = { allowNoDefault: true }; 1583 | 1584 | class MessageContext { 1585 | constructor(lang, { functions } = {}) { 1586 | this.lang = lang; 1587 | this.functions = functions || {}; 1588 | this.messages = new Map(); 1589 | this.intls = new WeakMap(); 1590 | } 1591 | 1592 | addMessages(source) { 1593 | const [entries, errors] = FTLRuntimeParser.parseResource(source); 1594 | for (let id in entries) { 1595 | this.messages.set(id, entries[id]); 1596 | } 1597 | 1598 | return errors; 1599 | } 1600 | 1601 | // format `entity` to a string or null 1602 | formatToPrimitive(entity, args) { 1603 | const result = format(this, args, entity, optsPrimitive); 1604 | return result[0] instanceof FTLNone ? [null, result[1]] : result; 1605 | } 1606 | 1607 | // format `entity` to a string 1608 | format(entity, args) { 1609 | const result = format(this, args, entity); 1610 | return [result[0].toString(), result[1]]; 1611 | } 1612 | 1613 | _memoizeIntlObject(ctor, opts) { 1614 | const cache = this.intls.get(ctor) || {}; 1615 | const id = JSON.stringify(opts); 1616 | 1617 | if (!cache[id]) { 1618 | cache[id] = new ctor(this.lang, opts); 1619 | this.intls.set(ctor, cache); 1620 | } 1621 | 1622 | return cache[id]; 1623 | } 1624 | 1625 | } 1626 | 1627 | Intl.MessageContext = MessageContext; 1628 | Intl.MessageNumberArgument = FTLNumber; 1629 | Intl.MessageDateTimeArgument = FTLDateTime; 1630 | 1631 | if (!Intl.NumberFormat) { 1632 | Intl.NumberFormat = function () { 1633 | return { 1634 | format(n) { 1635 | return n; 1636 | } 1637 | }; 1638 | }; 1639 | } 1640 | 1641 | if (!Intl.PluralRules) { 1642 | Intl.PluralRules = function (code) { 1643 | const fn = getPluralRule(code); 1644 | return { 1645 | select(n) { 1646 | return fn(n); 1647 | } 1648 | }; 1649 | }; 1650 | } 1651 | 1652 | if (!Intl.ListFormat) { 1653 | Intl.ListFormat = function () { 1654 | return { 1655 | format(list) { 1656 | return list.join(', '); 1657 | } 1658 | }; 1659 | }; 1660 | } 1661 | 1662 | class Node { 1663 | constructor() {} 1664 | } 1665 | 1666 | class Resource extends Node { 1667 | constructor(body = [], comment = null) { 1668 | super(); 1669 | this.type = 'Resource'; 1670 | this.body = body; 1671 | this.comment = comment; 1672 | } 1673 | } 1674 | 1675 | class Entry extends Node { 1676 | constructor() { 1677 | super(); 1678 | this.type = 'Entry'; 1679 | } 1680 | } 1681 | 1682 | class Identifier extends Node { 1683 | constructor(name) { 1684 | super(); 1685 | this.type = 'Identifier'; 1686 | this.name = name; 1687 | } 1688 | } 1689 | 1690 | class Section extends Node { 1691 | constructor(key, body = [], comment = null) { 1692 | super(); 1693 | this.type = 'Section'; 1694 | this.key = key; 1695 | this.body = body; 1696 | this.comment = comment; 1697 | } 1698 | } 1699 | 1700 | class Pattern$1 extends Node { 1701 | constructor(source, elements) { 1702 | super(); 1703 | this.type = 'Pattern'; 1704 | this.source = source; 1705 | this.elements = elements; 1706 | } 1707 | } 1708 | 1709 | class Member extends Node { 1710 | constructor(key, value, def = false) { 1711 | super(); 1712 | this.type = 'Member'; 1713 | this.key = key; 1714 | this.value = value; 1715 | this.default = def; 1716 | } 1717 | } 1718 | 1719 | class Entity$1 extends Entry { 1720 | constructor(id, value = null, traits = [], comment = null) { 1721 | super(); 1722 | this.type = 'Entity'; 1723 | this.id = id; 1724 | this.value = value; 1725 | this.traits = traits; 1726 | this.comment = comment; 1727 | } 1728 | } 1729 | 1730 | class Placeable extends Node { 1731 | constructor(expressions) { 1732 | super(); 1733 | this.type = 'Placeable'; 1734 | this.expressions = expressions; 1735 | } 1736 | } 1737 | 1738 | class SelectExpression$1 extends Node { 1739 | constructor(expression, variants = null) { 1740 | super(); 1741 | this.type = 'SelectExpression'; 1742 | this.expression = expression; 1743 | this.variants = variants; 1744 | } 1745 | } 1746 | 1747 | class MemberExpression$1 extends Node { 1748 | constructor(obj, keyword) { 1749 | super(); 1750 | this.type = 'MemberExpression'; 1751 | this.object = obj; 1752 | this.keyword = keyword; 1753 | } 1754 | } 1755 | 1756 | class CallExpression$1 extends Node { 1757 | constructor(callee, args) { 1758 | super(); 1759 | this.type = 'CallExpression'; 1760 | this.callee = callee; 1761 | this.args = args; 1762 | } 1763 | } 1764 | 1765 | class ExternalArgument$1 extends Node { 1766 | constructor(name) { 1767 | super(); 1768 | this.type = 'ExternalArgument'; 1769 | this.name = name; 1770 | } 1771 | } 1772 | 1773 | class KeyValueArg extends Node { 1774 | constructor(name, value) { 1775 | super(); 1776 | this.type = 'KeyValueArg'; 1777 | this.name = name; 1778 | this.value = value; 1779 | } 1780 | } 1781 | 1782 | class EntityReference$1 extends Identifier { 1783 | constructor(name) { 1784 | super(name); 1785 | this.type = 'EntityReference'; 1786 | } 1787 | } 1788 | 1789 | class FunctionReference$1 extends Identifier { 1790 | constructor(name) { 1791 | super(name); 1792 | this.type = 'FunctionReference'; 1793 | } 1794 | } 1795 | 1796 | class Keyword extends Identifier { 1797 | constructor(name, namespace = null) { 1798 | super(name); 1799 | this.type = 'Keyword'; 1800 | this.namespace = namespace; 1801 | } 1802 | } 1803 | 1804 | class Number extends Node { 1805 | constructor(value) { 1806 | super(); 1807 | this.type = 'Number'; 1808 | this.value = value; 1809 | } 1810 | } 1811 | 1812 | class TextElement extends Node { 1813 | constructor(value) { 1814 | super(); 1815 | this.type = 'TextElement'; 1816 | this.value = value; 1817 | } 1818 | } 1819 | 1820 | class Comment extends Node { 1821 | constructor(content) { 1822 | super(); 1823 | this.type = 'Comment'; 1824 | this.content = content; 1825 | } 1826 | } 1827 | 1828 | class JunkEntry extends Entry { 1829 | constructor(content) { 1830 | super(); 1831 | this.type = 'JunkEntry'; 1832 | this.content = content; 1833 | } 1834 | } 1835 | 1836 | var AST = { 1837 | Node, 1838 | Pattern: Pattern$1, 1839 | Member, 1840 | Identifier, 1841 | Entity: Entity$1, 1842 | Section, 1843 | Resource, 1844 | Placeable, 1845 | SelectExpression: SelectExpression$1, 1846 | MemberExpression: MemberExpression$1, 1847 | CallExpression: CallExpression$1, 1848 | ExternalArgument: ExternalArgument$1, 1849 | KeyValueArg, 1850 | Number, 1851 | EntityReference: EntityReference$1, 1852 | FunctionReference: FunctionReference$1, 1853 | Keyword, 1854 | TextElement, 1855 | Comment, 1856 | JunkEntry 1857 | }; 1858 | 1859 | class ParseContext$1 { 1860 | constructor(string) { 1861 | this._source = string; 1862 | this._index = 0; 1863 | this._length = string.length; 1864 | 1865 | this._lastGoodEntryEnd = 0; 1866 | } 1867 | 1868 | _isIdentifierStart(cc) { 1869 | return cc >= 97 && cc <= 122 || // a-z 1870 | cc >= 65 && cc <= 90 || // A-Z 1871 | cc === 95; // _ 1872 | } 1873 | 1874 | getResource() { 1875 | const resource = new AST.Resource(); 1876 | const errors = []; 1877 | let comment = null; 1878 | 1879 | let section = resource.body; 1880 | 1881 | if (this._source[this._index] === '#') { 1882 | comment = this.getComment(); 1883 | 1884 | const cc = this._source.charCodeAt(this._index); 1885 | if (!this._isIdentifierStart(cc)) { 1886 | resource.comment = comment; 1887 | comment = null; 1888 | } 1889 | } 1890 | 1891 | this.getWS(); 1892 | while (this._index < this._length) { 1893 | try { 1894 | const entry = this.getEntry(comment); 1895 | if (entry.type === 'Section') { 1896 | resource.body.push(entry); 1897 | section = entry.body; 1898 | } else { 1899 | section.push(entry); 1900 | } 1901 | this._lastGoodEntryEnd = this._index; 1902 | comment = null; 1903 | } catch (e) { 1904 | if (e instanceof L10nError) { 1905 | errors.push(e); 1906 | section.push(this.getJunkEntry()); 1907 | } else { 1908 | throw e; 1909 | } 1910 | } 1911 | this.getWS(); 1912 | } 1913 | 1914 | return [resource, errors]; 1915 | } 1916 | 1917 | getEntry(comment = null) { 1918 | if (this._index !== 0 && this._source[this._index - 1] !== '\n') { 1919 | throw this.error('Expected new line and a new entry'); 1920 | } 1921 | 1922 | if (comment === null && this._source[this._index] === '#') { 1923 | comment = this.getComment(); 1924 | } 1925 | 1926 | this.getLineWS(); 1927 | 1928 | if (this._source[this._index] === '[') { 1929 | return this.getSection(comment); 1930 | } 1931 | 1932 | if (this._index < this._length && this._source[this._index] !== '\n') { 1933 | return this.getEntity(comment); 1934 | } 1935 | return comment; 1936 | } 1937 | 1938 | getSection(comment = null) { 1939 | this._index += 1; 1940 | if (this._source[this._index] !== '[') { 1941 | throw this.error('Expected "[[" to open a section'); 1942 | } 1943 | 1944 | this._index += 1; 1945 | 1946 | this.getLineWS(); 1947 | 1948 | const key = this.getKeyword(); 1949 | 1950 | this.getLineWS(); 1951 | 1952 | if (this._source[this._index] !== ']' || this._source[this._index + 1] !== ']') { 1953 | throw this.error('Expected "]]" to close a section'); 1954 | } 1955 | 1956 | this._index += 2; 1957 | 1958 | return new AST.Section(key, [], comment); 1959 | } 1960 | 1961 | getEntity(comment = null) { 1962 | const id = this.getIdentifier(); 1963 | 1964 | let members = []; 1965 | let value = null; 1966 | 1967 | this.getLineWS(); 1968 | 1969 | let ch = this._source[this._index]; 1970 | 1971 | if (ch !== '=') { 1972 | throw this.error('Expected "=" after Entity ID'); 1973 | } 1974 | ch = this._source[++this._index]; 1975 | 1976 | this.getLineWS(); 1977 | 1978 | value = this.getPattern(); 1979 | 1980 | ch = this._source[this._index]; 1981 | 1982 | if (ch === '\n') { 1983 | this._index++; 1984 | this.getLineWS(); 1985 | ch = this._source[this._index]; 1986 | } 1987 | 1988 | if (ch === '[' && this._source[this._index + 1] !== '[' || ch === '*') { 1989 | members = this.getMembers(); 1990 | } else if (value === null) { 1991 | throw this.error('Expected a value (like: " = value") or a trait (like: "[key] value")'); 1992 | } 1993 | 1994 | return new AST.Entity(id, value, members, comment); 1995 | } 1996 | 1997 | getWS() { 1998 | let cc = this._source.charCodeAt(this._index); 1999 | // space, \n, \t, \r 2000 | while (cc === 32 || cc === 10 || cc === 9 || cc === 13) { 2001 | cc = this._source.charCodeAt(++this._index); 2002 | } 2003 | } 2004 | 2005 | getLineWS() { 2006 | let cc = this._source.charCodeAt(this._index); 2007 | // space, \t 2008 | while (cc === 32 || cc === 9) { 2009 | cc = this._source.charCodeAt(++this._index); 2010 | } 2011 | } 2012 | 2013 | getIdentifier() { 2014 | let name = ''; 2015 | 2016 | const start = this._index; 2017 | let cc = this._source.charCodeAt(this._index); 2018 | 2019 | if (this._isIdentifierStart(cc)) { 2020 | cc = this._source.charCodeAt(++this._index); 2021 | } else if (name.length === 0) { 2022 | throw this.error('Expected an identifier (starting with [a-zA-Z_])'); 2023 | } 2024 | 2025 | while (cc >= 97 && cc <= 122 || // a-z 2026 | cc >= 65 && cc <= 90 || // A-Z 2027 | cc >= 48 && cc <= 57 || // 0-9 2028 | cc === 95 || cc === 45) { 2029 | // _- 2030 | cc = this._source.charCodeAt(++this._index); 2031 | } 2032 | 2033 | name += this._source.slice(start, this._index); 2034 | 2035 | return new AST.Identifier(name); 2036 | } 2037 | 2038 | getKeyword() { 2039 | let name = ''; 2040 | let namespace = this.getIdentifier().name; 2041 | 2042 | if (this._source[this._index] === '/') { 2043 | this._index++; 2044 | } else if (namespace) { 2045 | name = namespace; 2046 | namespace = null; 2047 | } 2048 | 2049 | const start = this._index; 2050 | let cc = this._source.charCodeAt(this._index); 2051 | 2052 | if (this._isIdentifierStart(cc)) { 2053 | cc = this._source.charCodeAt(++this._index); 2054 | } else if (name.length === 0) { 2055 | throw this.error('Expected an identifier (starting with [a-zA-Z_])'); 2056 | } 2057 | 2058 | while (cc >= 97 && cc <= 122 || // a-z 2059 | cc >= 65 && cc <= 90 || // A-Z 2060 | cc >= 48 && cc <= 57 || // 0-9 2061 | cc === 95 || cc === 45 || cc === 32) { 2062 | // _- 2063 | cc = this._source.charCodeAt(++this._index); 2064 | } 2065 | 2066 | name += this._source.slice(start, this._index).trimRight(); 2067 | 2068 | return new AST.Keyword(name, namespace); 2069 | } 2070 | 2071 | getPattern() { 2072 | let buffer = ''; 2073 | let source = ''; 2074 | const content = []; 2075 | let quoteDelimited = null; 2076 | let firstLine = true; 2077 | 2078 | let ch = this._source[this._index]; 2079 | 2080 | if (ch === '\\' && (this._source[this._index + 1] === '"' || this._source[this._index + 1] === '{' || this._source[this._index + 1] === '\\')) { 2081 | buffer += this._source[this._index + 1]; 2082 | this._index += 2; 2083 | ch = this._source[this._index]; 2084 | } else if (ch === '"') { 2085 | quoteDelimited = true; 2086 | this._index++; 2087 | ch = this._source[this._index]; 2088 | } 2089 | 2090 | while (this._index < this._length) { 2091 | if (ch === '\n') { 2092 | if (quoteDelimited) { 2093 | throw this.error('Unclosed string'); 2094 | } 2095 | this._index++; 2096 | this.getLineWS(); 2097 | if (this._source[this._index] !== '|') { 2098 | break; 2099 | } 2100 | if (firstLine && buffer.length) { 2101 | throw this.error('Multiline string should have the ID line empty'); 2102 | } 2103 | firstLine = false; 2104 | this._index++; 2105 | if (this._source[this._index] === ' ') { 2106 | this._index++; 2107 | } 2108 | if (buffer.length) { 2109 | buffer += '\n'; 2110 | } 2111 | ch = this._source[this._index]; 2112 | continue; 2113 | } else if (ch === '\\') { 2114 | const ch2 = this._source[this._index + 1]; 2115 | if (quoteDelimited && ch2 === '"' || ch2 === '{') { 2116 | ch = ch2; 2117 | this._index++; 2118 | } 2119 | } else if (quoteDelimited && ch === '"') { 2120 | this._index++; 2121 | quoteDelimited = false; 2122 | break; 2123 | } else if (ch === '{') { 2124 | if (buffer.length) { 2125 | content.push(new AST.TextElement(buffer)); 2126 | } 2127 | source += buffer; 2128 | buffer = ''; 2129 | const start = this._index; 2130 | content.push(this.getPlaceable()); 2131 | source += this._source.substring(start, this._index); 2132 | ch = this._source[this._index]; 2133 | continue; 2134 | } 2135 | 2136 | if (ch) { 2137 | buffer += ch; 2138 | } 2139 | this._index++; 2140 | ch = this._source[this._index]; 2141 | } 2142 | 2143 | if (quoteDelimited) { 2144 | throw this.error('Unclosed string'); 2145 | } 2146 | 2147 | if (buffer.length) { 2148 | source += buffer; 2149 | content.push(new AST.TextElement(buffer)); 2150 | } 2151 | 2152 | if (content.length === 0) { 2153 | if (quoteDelimited !== null) { 2154 | content.push(new AST.TextElement(source)); 2155 | } else { 2156 | return null; 2157 | } 2158 | } 2159 | 2160 | const pattern = new AST.Pattern(source, content); 2161 | pattern._quoteDelim = quoteDelimited !== null; 2162 | return pattern; 2163 | } 2164 | 2165 | getPlaceable() { 2166 | this._index++; 2167 | 2168 | const expressions = []; 2169 | 2170 | this.getLineWS(); 2171 | 2172 | while (this._index < this._length) { 2173 | const start = this._index; 2174 | try { 2175 | expressions.push(this.getPlaceableExpression()); 2176 | } catch (e) { 2177 | throw this.error(e.description, start); 2178 | } 2179 | this.getWS(); 2180 | if (this._source[this._index] === '}') { 2181 | this._index++; 2182 | break; 2183 | } else if (this._source[this._index] === ',') { 2184 | this._index++; 2185 | this.getWS(); 2186 | } else { 2187 | throw this.error('Expected "}" or ","'); 2188 | } 2189 | } 2190 | 2191 | return new AST.Placeable(expressions); 2192 | } 2193 | 2194 | getPlaceableExpression() { 2195 | const selector = this.getCallExpression(); 2196 | let members = null; 2197 | 2198 | this.getWS(); 2199 | 2200 | if (this._source[this._index] !== '}' && this._source[this._index] !== ',') { 2201 | if (this._source[this._index] !== '-' || this._source[this._index + 1] !== '>') { 2202 | throw this.error('Expected "}", "," or "->"'); 2203 | } 2204 | this._index += 2; // -> 2205 | 2206 | this.getLineWS(); 2207 | 2208 | if (this._source[this._index] !== '\n') { 2209 | throw this.error('Members should be listed in a new line'); 2210 | } 2211 | 2212 | this.getWS(); 2213 | 2214 | members = this.getMembers(); 2215 | 2216 | if (members.length === 0) { 2217 | throw this.error('Expected members for the select expression'); 2218 | } 2219 | } 2220 | 2221 | if (members === null) { 2222 | return selector; 2223 | } 2224 | return new AST.SelectExpression(selector, members); 2225 | } 2226 | 2227 | getCallExpression() { 2228 | let exp = this.getMemberExpression(); 2229 | 2230 | if (this._source[this._index] !== '(') { 2231 | return exp; 2232 | } 2233 | 2234 | this._index++; 2235 | 2236 | const args = this.getCallArgs(); 2237 | 2238 | this._index++; 2239 | 2240 | if (exp instanceof AST.EntityReference) { 2241 | exp = new AST.FunctionReference(exp.name); 2242 | } 2243 | 2244 | return new AST.CallExpression(exp, args); 2245 | } 2246 | 2247 | getCallArgs() { 2248 | const args = []; 2249 | 2250 | if (this._source[this._index] === ')') { 2251 | return args; 2252 | } 2253 | 2254 | while (this._index < this._length) { 2255 | this.getLineWS(); 2256 | 2257 | const exp = this.getCallExpression(); 2258 | 2259 | if (!(exp instanceof AST.EntityReference)) { 2260 | args.push(exp); 2261 | } else { 2262 | this.getLineWS(); 2263 | 2264 | if (this._source[this._index] === ':') { 2265 | this._index++; 2266 | this.getLineWS(); 2267 | 2268 | const val = this.getCallExpression(); 2269 | 2270 | if (val instanceof AST.EntityReference || val instanceof AST.MemberExpression) { 2271 | this._index = this._source.lastIndexOf('=', this._index) + 1; 2272 | throw this.error('Expected string in quotes'); 2273 | } 2274 | 2275 | args.push(new AST.KeyValueArg(exp.name, val)); 2276 | } else { 2277 | args.push(exp); 2278 | } 2279 | } 2280 | 2281 | this.getLineWS(); 2282 | 2283 | if (this._source[this._index] === ')') { 2284 | break; 2285 | } else if (this._source[this._index] === ',') { 2286 | this._index++; 2287 | } else { 2288 | throw this.error('Expected "," or ")"'); 2289 | } 2290 | } 2291 | 2292 | return args; 2293 | } 2294 | 2295 | getNumber() { 2296 | let num = ''; 2297 | let cc = this._source.charCodeAt(this._index); 2298 | 2299 | if (cc === 45) { 2300 | num += '-'; 2301 | cc = this._source.charCodeAt(++this._index); 2302 | } 2303 | 2304 | if (cc < 48 || cc > 57) { 2305 | throw this.error(`Unknown literal "${ num }"`); 2306 | } 2307 | 2308 | while (cc >= 48 && cc <= 57) { 2309 | num += this._source[this._index++]; 2310 | cc = this._source.charCodeAt(this._index); 2311 | } 2312 | 2313 | if (cc === 46) { 2314 | num += this._source[this._index++]; 2315 | cc = this._source.charCodeAt(this._index); 2316 | 2317 | if (cc < 48 || cc > 57) { 2318 | throw this.error(`Unknown literal "${ num }"`); 2319 | } 2320 | 2321 | while (cc >= 48 && cc <= 57) { 2322 | num += this._source[this._index++]; 2323 | cc = this._source.charCodeAt(this._index); 2324 | } 2325 | } 2326 | 2327 | return new AST.Number(num); 2328 | } 2329 | 2330 | getMemberExpression() { 2331 | let exp = this.getLiteral(); 2332 | 2333 | while (this._source[this._index] === '[') { 2334 | const keyword = this.getMemberKey(); 2335 | exp = new AST.MemberExpression(exp, keyword); 2336 | } 2337 | 2338 | return exp; 2339 | } 2340 | 2341 | getMembers() { 2342 | const members = []; 2343 | 2344 | while (this._index < this._length) { 2345 | if ((this._source[this._index] !== '[' || this._source[this._index + 1] === '[') && this._source[this._index] !== '*') { 2346 | break; 2347 | } 2348 | let def = false; 2349 | if (this._source[this._index] === '*') { 2350 | this._index++; 2351 | def = true; 2352 | } 2353 | 2354 | if (this._source[this._index] !== '[') { 2355 | throw this.error('Expected "["'); 2356 | } 2357 | 2358 | const key = this.getMemberKey(); 2359 | 2360 | this.getLineWS(); 2361 | 2362 | const value = this.getPattern(); 2363 | 2364 | const member = new AST.Member(key, value, def); 2365 | 2366 | members.push(member); 2367 | 2368 | this.getWS(); 2369 | } 2370 | 2371 | return members; 2372 | } 2373 | 2374 | getMemberKey() { 2375 | this._index++; 2376 | 2377 | const cc = this._source.charCodeAt(this._index); 2378 | let literal; 2379 | 2380 | if (cc >= 48 && cc <= 57 || cc === 45) { 2381 | literal = this.getNumber(); 2382 | } else { 2383 | literal = this.getKeyword(); 2384 | } 2385 | 2386 | if (this._source[this._index] !== ']') { 2387 | throw this.error('Expected "]"'); 2388 | } 2389 | 2390 | this._index++; 2391 | return literal; 2392 | } 2393 | 2394 | getLiteral() { 2395 | const cc = this._source.charCodeAt(this._index); 2396 | if (cc >= 48 && cc <= 57 || cc === 45) { 2397 | return this.getNumber(); 2398 | } else if (cc === 34) { 2399 | // " 2400 | return this.getPattern(); 2401 | } else if (cc === 36) { 2402 | // $ 2403 | this._index++; 2404 | const name = this.getIdentifier().name; 2405 | return new AST.ExternalArgument(name); 2406 | } 2407 | 2408 | const name = this.getIdentifier().name; 2409 | return new AST.EntityReference(name); 2410 | } 2411 | 2412 | getComment() { 2413 | this._index++; 2414 | if (this._source[this._index] === ' ') { 2415 | this._index++; 2416 | } 2417 | 2418 | let content = ''; 2419 | 2420 | let eol = this._source.indexOf('\n', this._index); 2421 | 2422 | content += this._source.substring(this._index, eol); 2423 | 2424 | while (eol !== -1 && this._source[eol + 1] === '#') { 2425 | this._index = eol + 2; 2426 | 2427 | if (this._source[this._index] === ' ') { 2428 | this._index++; 2429 | } 2430 | 2431 | eol = this._source.indexOf('\n', this._index); 2432 | 2433 | if (eol === -1) { 2434 | break; 2435 | } 2436 | 2437 | content += '\n' + this._source.substring(this._index, eol); 2438 | } 2439 | 2440 | if (eol === -1) { 2441 | this._index = this._length; 2442 | } else { 2443 | this._index = eol + 1; 2444 | } 2445 | 2446 | return new AST.Comment(content); 2447 | } 2448 | 2449 | error(message, start = null) { 2450 | const pos = this._index; 2451 | 2452 | if (start === null) { 2453 | start = pos; 2454 | } 2455 | start = this._findEntityStart(start); 2456 | 2457 | const context = this._source.slice(start, pos + 10); 2458 | 2459 | const msg = '\n\n ' + message + '\nat pos ' + pos + ':\n------\n…' + context + '\n------'; 2460 | const err = new L10nError(msg); 2461 | 2462 | const row = this._source.slice(0, pos).split('\n').length; 2463 | const col = pos - this._source.lastIndexOf('\n', pos - 1); 2464 | err._pos = { start: pos, end: undefined, col: col, row: row }; 2465 | err.offset = pos - start; 2466 | err.description = message; 2467 | err.context = context; 2468 | return err; 2469 | } 2470 | 2471 | getJunkEntry() { 2472 | const pos = this._index; 2473 | 2474 | let nextEntity = this._findNextEntryStart(pos); 2475 | 2476 | if (nextEntity === -1) { 2477 | nextEntity = this._length; 2478 | } 2479 | 2480 | this._index = nextEntity; 2481 | 2482 | let entityStart = this._findEntityStart(pos); 2483 | 2484 | if (entityStart < this._lastGoodEntryEnd) { 2485 | entityStart = this._lastGoodEntryEnd; 2486 | } 2487 | 2488 | const junk = new AST.JunkEntry(this._source.slice(entityStart, nextEntity)); 2489 | return junk; 2490 | } 2491 | 2492 | _findEntityStart(pos) { 2493 | let start = pos; 2494 | 2495 | while (true) { 2496 | start = this._source.lastIndexOf('\n', start - 2); 2497 | if (start === -1 || start === 0) { 2498 | start = 0; 2499 | break; 2500 | } 2501 | const cc = this._source.charCodeAt(start + 1); 2502 | 2503 | if (this._isIdentifierStart(cc)) { 2504 | start++; 2505 | break; 2506 | } 2507 | } 2508 | 2509 | return start; 2510 | } 2511 | 2512 | _findNextEntryStart(pos) { 2513 | let start = pos; 2514 | 2515 | while (true) { 2516 | if (start === 0 || this._source[start - 1] === '\n') { 2517 | const cc = this._source.charCodeAt(start); 2518 | 2519 | if (this._isIdentifierStart(cc) || cc === 35 || cc === 91) { 2520 | break; 2521 | } 2522 | } 2523 | 2524 | start = this._source.indexOf('\n', start); 2525 | 2526 | if (start === -1) { 2527 | break; 2528 | } 2529 | start++; 2530 | } 2531 | 2532 | return start; 2533 | } 2534 | } 2535 | 2536 | var parser = { 2537 | parseResource: function (string) { 2538 | const parseContext = new ParseContext$1(string); 2539 | return parseContext.getResource(); 2540 | } 2541 | }; 2542 | 2543 | function transformEntity(entity) { 2544 | if (entity.traits.length === 0) { 2545 | return transformPattern(entity.value); 2546 | } 2547 | 2548 | const ret = { 2549 | traits: entity.traits.map(transformMember) 2550 | }; 2551 | 2552 | return entity.value !== null ? Object.assign(ret, { val: transformPattern(entity.value) }) : ret; 2553 | } 2554 | 2555 | function transformExpression(exp) { 2556 | switch (exp.type) { 2557 | case 'EntityReference': 2558 | return { 2559 | type: 'ref', 2560 | name: exp.name 2561 | }; 2562 | case 'FunctionReference': 2563 | return { 2564 | type: 'fun', 2565 | name: exp.name 2566 | }; 2567 | case 'ExternalArgument': 2568 | return { 2569 | type: 'ext', 2570 | name: exp.name 2571 | }; 2572 | case 'Pattern': 2573 | return transformPattern(exp); 2574 | case 'Number': 2575 | return { 2576 | type: 'num', 2577 | val: exp.value 2578 | }; 2579 | case 'Keyword': 2580 | const kw = { 2581 | type: 'kw', 2582 | name: exp.name 2583 | }; 2584 | 2585 | return exp.namespace ? Object.assign(kw, { ns: exp.namespace }) : kw; 2586 | case 'KeyValueArg': 2587 | return { 2588 | type: 'kv', 2589 | name: exp.name, 2590 | val: transformExpression(exp.value) 2591 | }; 2592 | case 'SelectExpression': 2593 | return { 2594 | type: 'sel', 2595 | exp: transformExpression(exp.expression), 2596 | vars: exp.variants.map(transformMember) 2597 | }; 2598 | case 'MemberExpression': 2599 | return { 2600 | type: 'mem', 2601 | obj: transformExpression(exp.object), 2602 | key: transformExpression(exp.keyword) 2603 | }; 2604 | case 'CallExpression': 2605 | return { 2606 | type: 'call', 2607 | name: transformExpression(exp.callee), 2608 | args: exp.args.map(transformExpression) 2609 | }; 2610 | default: 2611 | return exp; 2612 | } 2613 | } 2614 | 2615 | function transformPattern(pattern) { 2616 | if (pattern === null) { 2617 | return null; 2618 | } 2619 | 2620 | if (pattern.elements.length === 1 && pattern.elements[0].type === 'TextElement') { 2621 | return pattern.source; 2622 | } 2623 | 2624 | return pattern.elements.map(chunk => { 2625 | if (chunk.type === 'TextElement') { 2626 | return chunk.value; 2627 | } 2628 | if (chunk.type === 'Placeable') { 2629 | return chunk.expressions.map(transformExpression); 2630 | } 2631 | return chunk; 2632 | }); 2633 | } 2634 | 2635 | function transformMember(member) { 2636 | const ret = { 2637 | key: transformExpression(member.key), 2638 | val: transformPattern(member.value) 2639 | }; 2640 | 2641 | if (member.default) { 2642 | ret.def = true; 2643 | } 2644 | 2645 | return ret; 2646 | } 2647 | 2648 | function getEntitiesFromBody(body) { 2649 | const entities = {}; 2650 | body.forEach(entry => { 2651 | if (entry.type === 'Entity') { 2652 | entities[entry.id.name] = transformEntity(entry); 2653 | } else if (entry.type === 'Section') { 2654 | Object.assign(entities, getEntitiesFromBody(entry.body)); 2655 | } 2656 | }); 2657 | return entities; 2658 | } 2659 | 2660 | function createEntriesFromAST([resource, errors]) { 2661 | const entities = getEntitiesFromBody(resource.body); 2662 | return [entities, errors]; 2663 | } 2664 | 2665 | exports.FTLASTParser = parser; 2666 | exports.FTLEntriesParser = FTLRuntimeParser; 2667 | exports.createEntriesFromAST = createEntriesFromAST; 2668 | 2669 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-l20n", 3 | "version": "0.0.8", 4 | "description": "Mozilla's L20n localization framework for React Native", 5 | "license": "MIT", 6 | "keywords": [ 7 | "l10n", 8 | "l20n", 9 | "localization", 10 | "i18n", 11 | "internationalization", 12 | "messageformat", 13 | "gettext", 14 | "react", 15 | "react-native" 16 | ], 17 | "author": { 18 | "name": "James Reggio", 19 | "email": "james.reggio@gmail.com", 20 | "url": "http://regg.io" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/jamesreggio/react-native-l20n.git" 25 | }, 26 | "homepage": "https://github.com/jamesreggio/react-native-l20n", 27 | "bugs": "https://github.com/jamesreggio/react-native-l20n/issues", 28 | "main": "index.js", 29 | "dependencies": { 30 | "intl": "1.2.4", 31 | "weakset": "1.0.0" 32 | } 33 | } 34 | --------------------------------------------------------------------------------