├── .babelrc ├── .coveralls.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── i18nextFluent.js ├── i18nextFluent.min.js ├── index.d.ts ├── index.js ├── mocha_setup.js ├── package.json ├── rollup.config.js ├── src ├── index.js └── utils.js └── test └── fuent.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "development": { 4 | "presets": ["@babel/preset-env"] 5 | }, 6 | "jsnext": { 7 | "presets": [["@babel/preset-env", { "modules": false }]] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: VzJPIPevBD8BGTlLx3n2GRYf4bawEZsu4 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | root = true 3 | 4 | [*.{js,jsx,json}] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/dist/* 2 | **/node_modules/* 3 | **/*.min.* 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | parser: babel-eslint 2 | extends: airbnb 3 | 4 | rules: 5 | max-len: [0, 100] 6 | no-constant-condition: 0 7 | arrow-body-style: [1, "as-needed"] 8 | comma-dangle: [2, "never"] 9 | padded-blocks: [0, "never"] 10 | no-unused-vars: [2, {vars: all, args: none}] 11 | react/prop-types: 12 | - 0 13 | - ignore: #coming from hoc 14 | - location 15 | - fields 16 | - handleSubmit 17 | 18 | globals: 19 | expect: false 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore specific files 2 | .settings.xml 3 | .monitor 4 | .idea 5 | .DS_Store 6 | *.orig 7 | npm-debug.log 8 | npm-debug.log.* 9 | *.dat 10 | package-lock.json 11 | 12 | # Ignore various temporary files 13 | *~ 14 | *.swp 15 | 16 | 17 | # Ignore various Node.js related directories and files 18 | node_modules 19 | node_modules/**/* 20 | coverage/**/* 21 | dist/**/* 22 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | src/ 3 | coverage/ 4 | .babelrc 5 | .editorconfig 6 | .eslintignore 7 | .eslintrc 8 | .gitignore 9 | bower.json 10 | gulpfile.js 11 | karma.conf.js 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "10" 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 2.0.0 2 | 3 | - update fluent to spec 1.0 [7](https://github.com/i18next/i18next-fluent/pull/7) 4 | - bindI18nextStore was renamed to bindI18nStore 5 | 6 | ### 1.0.1 7 | 8 | - fix serverside usage by loading to fluent by preloaded languages - not current [6](https://github.com/i18next/i18next-fluent/pull/6) 9 | 10 | ### 1.0.0 11 | 12 | - Return undefined when unable to get bundle in getResource [5](https://github.com/i18next/i18next-fluent/pull/5) 13 | 14 | ### 0.0.4 15 | 16 | - Fix using i18next's fallback language support [3](https://github.com/i18next/i18next-fluent/pull/3) 17 | 18 | ### 0.0.3 19 | 20 | - check if bundle exists before getMessage call 21 | 22 | ### 0.0.2 23 | 24 | - support accessing attributes on fluent segment using keys like `login.placeholder` 25 | 26 | ### 0.0.1 27 | 28 | - initial version 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 i18next 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | [![Travis](https://img.shields.io/travis/com/i18next/i18next-fluent/master.svg?style=flat-square)](https://travis-ci.com/i18next/i18next-fluent) 4 | [![npm version](https://img.shields.io/npm/v/i18next-fluent.svg?style=flat-square)](https://www.npmjs.com/package/i18next-fluent) 5 | [![David](https://img.shields.io/david/i18next/i18next-fluent.svg?style=flat-square)](https://david-dm.org/i18next/i18next-fluent) 6 | 7 | This changes i18n format from i18next json to [fluent](https://projectfluent.org) Spec version 1.0.0 8 | 9 | # Getting started 10 | 11 | Source can be loaded via [npm](https://www.npmjs.com/package/i18next-fluent) or [downloaded](https://github.com/i18next/i18next-fluent/blob/master/i18nextFluent.min.js) from this repo. 12 | 13 | ``` 14 | # npm package 15 | $ npm install i18next-fluent 16 | ``` 17 | 18 | Wiring up: 19 | 20 | ```js 21 | import i18next from "i18next"; 22 | import Fluent from "i18next-fluent"; 23 | 24 | i18next.use(Fluent).init(i18nextOptions); 25 | ``` 26 | 27 | - As with all modules you can either pass the constructor function (class) to the i18next.use or a concrete instance. 28 | - If you don't use a module loader it will be added to `window.i18nextFluent` 29 | 30 | ## Advice 31 | 32 | When using this module, only the fluent format is respected, this means the i18next format interpolation etc. will not work. 33 | So for example instead of `Hy {{name}}!` it is `Hi {$name}!` 34 | 35 | ## Samples 36 | 37 | - [with react and react-i18next](https://github.com/i18next/react-i18next/tree/master/example/v9.x.x/react-fluent) 38 | 39 | ## Options 40 | 41 | ```js 42 | { 43 | bindI18nextStore: true, 44 | fluentBundleOptions: { useIsolating: false } 45 | } 46 | ``` 47 | 48 | Options can be passed in by setting options.i18nFormat in i18next.init: 49 | 50 | ```js 51 | import i18next from "i18next"; 52 | import Fluent from "i18next-fluent"; 53 | 54 | i18next.use(Fluent).init({ 55 | i18nFormat: options 56 | }); 57 | ``` 58 | 59 | ### loading .ftl fluent flavored textfiles 60 | 61 | You can use the [i18next-fluent-backend](https://github.com/i18next/i18next-fluent-backend) to directly load fluent files in fluent syntax from the server. 62 | 63 | ### more complete sample 64 | 65 | ```js 66 | import i18next from "i18next"; 67 | import Fluent from "i18next-fluent"; 68 | 69 | i18next.use(Fluent).init({ 70 | lng: "en", 71 | resources: { 72 | en: { 73 | translation: { 74 | hello: "Hello { $name }." 75 | } 76 | } 77 | } 78 | }); 79 | 80 | i18next.t("hello", { name: "fluent" }); // -> Hello fluent. 81 | ``` 82 | 83 | --- 84 | 85 |

Gold Sponsors

86 | 87 |

88 | 89 | 90 | 91 |

92 | -------------------------------------------------------------------------------- /i18nextFluent.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : 3 | typeof define === 'function' && define.amd ? define(factory) : 4 | (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.i18nextFluent = factory()); 5 | })(this, (function () { 'use strict'; 6 | 7 | function getLastOfPath(object, path, Empty) { 8 | function cleanKey(key) { 9 | return key && key.indexOf('###') > -1 ? key.replace(/###/g, '.') : key; 10 | } 11 | 12 | function canNotTraverseDeeper() { 13 | return !object || typeof object === 'string'; 14 | } 15 | 16 | const stack = typeof path !== 'string' ? [].concat(path) : path.split('.'); 17 | 18 | while (stack.length > 1) { 19 | if (canNotTraverseDeeper()) return {}; 20 | const key = cleanKey(stack.shift()); 21 | if (!object[key] && Empty) object[key] = new Empty(); 22 | object = object[key]; 23 | } 24 | 25 | if (canNotTraverseDeeper()) return {}; 26 | return { 27 | obj: object, 28 | k: cleanKey(stack.shift()) 29 | }; 30 | } 31 | 32 | function setPath(object, path, newValue) { 33 | const { 34 | obj, 35 | k 36 | } = getLastOfPath(object, path, Object); 37 | obj[k] = newValue; 38 | } 39 | function getPath(object, path) { 40 | const { 41 | obj, 42 | k 43 | } = getLastOfPath(object, path); 44 | if (!obj) return undefined; 45 | return obj[k]; 46 | } 47 | let arr = []; 48 | let each = arr.forEach; 49 | let slice = arr.slice; 50 | function defaults(obj) { 51 | each.call(slice.call(arguments, 1), function (source) { 52 | if (source) { 53 | for (var prop in source) { 54 | if (obj[prop] === undefined) obj[prop] = source[prop]; 55 | } 56 | } 57 | }); 58 | return obj; 59 | } 60 | 61 | /* global Intl */ 62 | 63 | /** 64 | * The `FluentType` class is the base of Fluent's type system. 65 | * 66 | * Fluent types wrap JavaScript values and store additional configuration for 67 | * them, which can then be used in the `toString` method together with a proper 68 | * `Intl` formatter. 69 | */ 70 | class FluentType { 71 | /** 72 | * Create an `FluentType` instance. 73 | * 74 | * @param {Any} value - JavaScript value to wrap. 75 | * @param {Object} opts - Configuration. 76 | * @returns {FluentType} 77 | */ 78 | constructor(value, opts) { 79 | this.value = value; 80 | this.opts = opts; 81 | } 82 | /** 83 | * Unwrap the raw value stored by this `FluentType`. 84 | * 85 | * @returns {Any} 86 | */ 87 | 88 | 89 | valueOf() { 90 | return this.value; 91 | } 92 | /** 93 | * Format this instance of `FluentType` to a string. 94 | * 95 | * Formatted values are suitable for use outside of the `FluentBundle`. 96 | * This method can use `Intl` formatters memoized by the `FluentBundle` 97 | * instance passed as an argument. 98 | * 99 | * @param {FluentBundle} [bundle] 100 | * @returns {string} 101 | */ 102 | 103 | 104 | toString() { 105 | throw new Error("Subclasses of FluentType must implement toString."); 106 | } 107 | 108 | } 109 | class FluentNone extends FluentType { 110 | valueOf() { 111 | return null; 112 | } 113 | 114 | toString() { 115 | return `{${this.value || "???"}}`; 116 | } 117 | 118 | } 119 | class FluentNumber extends FluentType { 120 | constructor(value, opts) { 121 | super(parseFloat(value), opts); 122 | } 123 | 124 | toString(bundle) { 125 | try { 126 | const nf = bundle._memoizeIntlObject(Intl.NumberFormat, this.opts); 127 | 128 | return nf.format(this.value); 129 | } catch (e) { 130 | // XXX Report the error. 131 | return this.value; 132 | } 133 | } 134 | 135 | } 136 | class FluentDateTime extends FluentType { 137 | constructor(value, opts) { 138 | super(new Date(value), opts); 139 | } 140 | 141 | toString(bundle) { 142 | try { 143 | const dtf = bundle._memoizeIntlObject(Intl.DateTimeFormat, this.opts); 144 | 145 | return dtf.format(this.value); 146 | } catch (e) { 147 | // XXX Report the error. 148 | return this.value; 149 | } 150 | } 151 | 152 | } 153 | 154 | /** 155 | * @overview 156 | * 157 | * The FTL resolver ships with a number of functions built-in. 158 | * 159 | * Each function take two arguments: 160 | * - args - an array of positional args 161 | * - opts - an object of key-value args 162 | * 163 | * Arguments to functions are guaranteed to already be instances of 164 | * `FluentType`. Functions must return `FluentType` objects as well. 165 | */ 166 | 167 | function merge(argopts, opts) { 168 | return Object.assign({}, argopts, values(opts)); 169 | } 170 | 171 | function values(opts) { 172 | const unwrapped = {}; 173 | 174 | for (const [name, opt] of Object.entries(opts)) { 175 | unwrapped[name] = opt.valueOf(); 176 | } 177 | 178 | return unwrapped; 179 | } 180 | 181 | function NUMBER([arg], opts) { 182 | if (arg instanceof FluentNone) { 183 | return arg; 184 | } 185 | 186 | if (arg instanceof FluentNumber) { 187 | return new FluentNumber(arg.valueOf(), merge(arg.opts, opts)); 188 | } 189 | 190 | return new FluentNone("NUMBER()"); 191 | } 192 | function DATETIME([arg], opts) { 193 | if (arg instanceof FluentNone) { 194 | return arg; 195 | } 196 | 197 | if (arg instanceof FluentDateTime) { 198 | return new FluentDateTime(arg.valueOf(), merge(arg.opts, opts)); 199 | } 200 | 201 | return new FluentNone("DATETIME()"); 202 | } 203 | 204 | var builtins = /*#__PURE__*/Object.freeze({ 205 | __proto__: null, 206 | NUMBER: NUMBER, 207 | DATETIME: DATETIME 208 | }); 209 | 210 | /* global Intl */ 211 | 212 | const MAX_PLACEABLE_LENGTH = 2500; // Unicode bidi isolation characters. 213 | 214 | const FSI = "\u2068"; 215 | const PDI = "\u2069"; // Helper: match a variant key to the given selector. 216 | 217 | function match(bundle, selector, key) { 218 | if (key === selector) { 219 | // Both are strings. 220 | return true; 221 | } // XXX Consider comparing options too, e.g. minimumFractionDigits. 222 | 223 | 224 | if (key instanceof FluentNumber && selector instanceof FluentNumber && key.value === selector.value) { 225 | return true; 226 | } 227 | 228 | if (selector instanceof FluentNumber && typeof key === "string") { 229 | let category = bundle._memoizeIntlObject(Intl.PluralRules, selector.opts).select(selector.value); 230 | 231 | if (key === category) { 232 | return true; 233 | } 234 | } 235 | 236 | return false; 237 | } // Helper: resolve the default variant from a list of variants. 238 | 239 | 240 | function getDefault(scope, variants, star) { 241 | if (variants[star]) { 242 | return Type(scope, variants[star]); 243 | } 244 | 245 | scope.errors.push(new RangeError("No default")); 246 | return new FluentNone(); 247 | } // Helper: resolve arguments to a call expression. 248 | 249 | 250 | function getArguments(scope, args) { 251 | const positional = []; 252 | const named = {}; 253 | 254 | for (const arg of args) { 255 | if (arg.type === "narg") { 256 | named[arg.name] = Type(scope, arg.value); 257 | } else { 258 | positional.push(Type(scope, arg)); 259 | } 260 | } 261 | 262 | return [positional, named]; 263 | } // Resolve an expression to a Fluent type. 264 | 265 | 266 | function Type(scope, expr) { 267 | // A fast-path for strings which are the most common case. Since they 268 | // natively have the `toString` method they can be used as if they were 269 | // a FluentType instance without incurring the cost of creating one. 270 | if (typeof expr === "string") { 271 | return scope.bundle._transform(expr); 272 | } // A fast-path for `FluentNone` which doesn't require any additional logic. 273 | 274 | 275 | if (expr instanceof FluentNone) { 276 | return expr; 277 | } // The Runtime AST (Entries) encodes patterns (complex strings with 278 | // placeables) as Arrays. 279 | 280 | 281 | if (Array.isArray(expr)) { 282 | return Pattern(scope, expr); 283 | } 284 | 285 | switch (expr.type) { 286 | case "str": 287 | return expr.value; 288 | 289 | case "num": 290 | return new FluentNumber(expr.value, { 291 | minimumFractionDigits: expr.precision 292 | }); 293 | 294 | case "var": 295 | return VariableReference(scope, expr); 296 | 297 | case "mesg": 298 | return MessageReference(scope, expr); 299 | 300 | case "term": 301 | return TermReference(scope, expr); 302 | 303 | case "func": 304 | return FunctionReference(scope, expr); 305 | 306 | case "select": 307 | return SelectExpression(scope, expr); 308 | 309 | case undefined: 310 | { 311 | // If it's a node with a value, resolve the value. 312 | if (expr.value !== null && expr.value !== undefined) { 313 | return Type(scope, expr.value); 314 | } 315 | 316 | scope.errors.push(new RangeError("No value")); 317 | return new FluentNone(); 318 | } 319 | 320 | default: 321 | return new FluentNone(); 322 | } 323 | } // Resolve a reference to a variable. 324 | 325 | 326 | function VariableReference(scope, { 327 | name 328 | }) { 329 | if (!scope.args || !scope.args.hasOwnProperty(name)) { 330 | if (scope.insideTermReference === false) { 331 | scope.errors.push(new ReferenceError(`Unknown variable: ${name}`)); 332 | } 333 | 334 | return new FluentNone(`$${name}`); 335 | } 336 | 337 | const arg = scope.args[name]; // Return early if the argument already is an instance of FluentType. 338 | 339 | if (arg instanceof FluentType) { 340 | return arg; 341 | } // Convert the argument to a Fluent type. 342 | 343 | 344 | switch (typeof arg) { 345 | case "string": 346 | return arg; 347 | 348 | case "number": 349 | return new FluentNumber(arg); 350 | 351 | case "object": 352 | if (arg instanceof Date) { 353 | return new FluentDateTime(arg); 354 | } 355 | 356 | default: 357 | scope.errors.push(new TypeError(`Unsupported variable type: ${name}, ${typeof arg}`)); 358 | return new FluentNone(`$${name}`); 359 | } 360 | } // Resolve a reference to another message. 361 | 362 | 363 | function MessageReference(scope, { 364 | name, 365 | attr 366 | }) { 367 | const message = scope.bundle._messages.get(name); 368 | 369 | if (!message) { 370 | const err = new ReferenceError(`Unknown message: ${name}`); 371 | scope.errors.push(err); 372 | return new FluentNone(name); 373 | } 374 | 375 | if (attr) { 376 | const attribute = message.attrs && message.attrs[attr]; 377 | 378 | if (attribute) { 379 | return Type(scope, attribute); 380 | } 381 | 382 | scope.errors.push(new ReferenceError(`Unknown attribute: ${attr}`)); 383 | return new FluentNone(`${name}.${attr}`); 384 | } 385 | 386 | return Type(scope, message); 387 | } // Resolve a call to a Term with key-value arguments. 388 | 389 | 390 | function TermReference(scope, { 391 | name, 392 | attr, 393 | args 394 | }) { 395 | const id = `-${name}`; 396 | 397 | const term = scope.bundle._terms.get(id); 398 | 399 | if (!term) { 400 | const err = new ReferenceError(`Unknown term: ${id}`); 401 | scope.errors.push(err); 402 | return new FluentNone(id); 403 | } // Every TermReference has its own args. 404 | 405 | 406 | const [, keyargs] = getArguments(scope, args); 407 | const local = { ...scope, 408 | args: keyargs, 409 | insideTermReference: true 410 | }; 411 | 412 | if (attr) { 413 | const attribute = term.attrs && term.attrs[attr]; 414 | 415 | if (attribute) { 416 | return Type(local, attribute); 417 | } 418 | 419 | scope.errors.push(new ReferenceError(`Unknown attribute: ${attr}`)); 420 | return new FluentNone(`${id}.${attr}`); 421 | } 422 | 423 | return Type(local, term); 424 | } // Resolve a call to a Function with positional and key-value arguments. 425 | 426 | 427 | function FunctionReference(scope, { 428 | name, 429 | args 430 | }) { 431 | // Some functions are built-in. Others may be provided by the runtime via 432 | // the `FluentBundle` constructor. 433 | const func = scope.bundle._functions[name] || builtins[name]; 434 | 435 | if (!func) { 436 | scope.errors.push(new ReferenceError(`Unknown function: ${name}()`)); 437 | return new FluentNone(`${name}()`); 438 | } 439 | 440 | if (typeof func !== "function") { 441 | scope.errors.push(new TypeError(`Function ${name}() is not callable`)); 442 | return new FluentNone(`${name}()`); 443 | } 444 | 445 | try { 446 | return func(...getArguments(scope, args)); 447 | } catch (e) { 448 | // XXX Report errors. 449 | return new FluentNone(`${name}()`); 450 | } 451 | } // Resolve a select expression to the member object. 452 | 453 | 454 | function SelectExpression(scope, { 455 | selector, 456 | variants, 457 | star 458 | }) { 459 | let sel = Type(scope, selector); 460 | 461 | if (sel instanceof FluentNone) { 462 | const variant = getDefault(scope, variants, star); 463 | return Type(scope, variant); 464 | } // Match the selector against keys of each variant, in order. 465 | 466 | 467 | for (const variant of variants) { 468 | const key = Type(scope, variant.key); 469 | 470 | if (match(scope.bundle, sel, key)) { 471 | return Type(scope, variant); 472 | } 473 | } 474 | 475 | const variant = getDefault(scope, variants, star); 476 | return Type(scope, variant); 477 | } // Resolve a pattern (a complex string with placeables). 478 | 479 | 480 | function Pattern(scope, ptn) { 481 | if (scope.dirty.has(ptn)) { 482 | scope.errors.push(new RangeError("Cyclic reference")); 483 | return new FluentNone(); 484 | } // Tag the pattern as dirty for the purpose of the current resolution. 485 | 486 | 487 | scope.dirty.add(ptn); 488 | const result = []; // Wrap interpolations with Directional Isolate Formatting characters 489 | // only when the pattern has more than one element. 490 | 491 | const useIsolating = scope.bundle._useIsolating && ptn.length > 1; 492 | 493 | for (const elem of ptn) { 494 | if (typeof elem === "string") { 495 | result.push(scope.bundle._transform(elem)); 496 | continue; 497 | } 498 | 499 | const part = Type(scope, elem).toString(scope.bundle); 500 | 501 | if (useIsolating) { 502 | result.push(FSI); 503 | } 504 | 505 | if (part.length > MAX_PLACEABLE_LENGTH) { 506 | scope.errors.push(new RangeError("Too many characters in placeable " + `(${part.length}, max allowed is ${MAX_PLACEABLE_LENGTH})`)); 507 | result.push(part.slice(MAX_PLACEABLE_LENGTH)); 508 | } else { 509 | result.push(part); 510 | } 511 | 512 | if (useIsolating) { 513 | result.push(PDI); 514 | } 515 | } 516 | 517 | scope.dirty.delete(ptn); 518 | return result.join(""); 519 | } 520 | /** 521 | * Format a translation into a string. 522 | * 523 | * @param {FluentBundle} bundle 524 | * A FluentBundle instance which will be used to resolve the 525 | * contextual information of the message. 526 | * @param {Object} args 527 | * List of arguments provided by the developer which can be accessed 528 | * from the message. 529 | * @param {Object} message 530 | * An object with the Message to be resolved. 531 | * @param {Array} errors 532 | * An error array that any encountered errors will be appended to. 533 | * @returns {FluentType} 534 | */ 535 | 536 | 537 | function resolve(bundle, args, message, errors = []) { 538 | const scope = { 539 | bundle, 540 | args, 541 | errors, 542 | dirty: new WeakSet(), 543 | // TermReferences are resolved in a new scope. 544 | insideTermReference: false 545 | }; 546 | return Type(scope, message).toString(bundle); 547 | } 548 | 549 | class FluentError extends Error {} 550 | 551 | // With the /m flag, the ^ matches at the beginning of every line. 552 | 553 | const RE_MESSAGE_START = /^(-?[a-zA-Z][\w-]*) *= */mg; // Both Attributes and Variants are parsed in while loops. These regexes are 554 | // used to break out of them. 555 | 556 | const RE_ATTRIBUTE_START = /\.([a-zA-Z][\w-]*) *= */y; 557 | const RE_VARIANT_START = /\*?\[/y; 558 | const RE_NUMBER_LITERAL = /(-?[0-9]+(?:\.([0-9]+))?)/y; 559 | const RE_IDENTIFIER = /([a-zA-Z][\w-]*)/y; 560 | const RE_REFERENCE = /([$-])?([a-zA-Z][\w-]*)(?:\.([a-zA-Z][\w-]*))?/y; 561 | const RE_FUNCTION_NAME = /^[A-Z][A-Z0-9_-]*$/; // A "run" is a sequence of text or string literal characters which don't 562 | // require any special handling. For TextElements such special characters are: { 563 | // (starts a placeable), and line breaks which require additional logic to check 564 | // if the next line is indented. For StringLiterals they are: \ (starts an 565 | // escape sequence), " (ends the literal), and line breaks which are not allowed 566 | // in StringLiterals. Note that string runs may be empty; text runs may not. 567 | 568 | const RE_TEXT_RUN = /([^{}\n\r]+)/y; 569 | const RE_STRING_RUN = /([^\\"\n\r]*)/y; // Escape sequences. 570 | 571 | const RE_STRING_ESCAPE = /\\([\\"])/y; 572 | const RE_UNICODE_ESCAPE = /\\u([a-fA-F0-9]{4})|\\U([a-fA-F0-9]{6})/y; // Used for trimming TextElements and indents. 573 | 574 | const RE_LEADING_NEWLINES = /^\n+/; 575 | const RE_TRAILING_SPACES = / +$/; // Used in makeIndent to strip spaces from blank lines and normalize CRLF to LF. 576 | 577 | const RE_BLANK_LINES = / *\r?\n/g; // Used in makeIndent to measure the indentation. 578 | 579 | const RE_INDENT = /( *)$/; // Common tokens. 580 | 581 | const TOKEN_BRACE_OPEN = /{\s*/y; 582 | const TOKEN_BRACE_CLOSE = /\s*}/y; 583 | const TOKEN_BRACKET_OPEN = /\[\s*/y; 584 | const TOKEN_BRACKET_CLOSE = /\s*] */y; 585 | const TOKEN_PAREN_OPEN = /\s*\(\s*/y; 586 | const TOKEN_ARROW = /\s*->\s*/y; 587 | const TOKEN_COLON = /\s*:\s*/y; // Note the optional comma. As a deviation from the Fluent EBNF, the parser 588 | // doesn't enforce commas between call arguments. 589 | 590 | const TOKEN_COMMA = /\s*,?\s*/y; 591 | const TOKEN_BLANK = /\s+/y; // Maximum number of placeables in a single Pattern to protect against Quadratic 592 | // Blowup attacks. See https://msdn.microsoft.com/en-us/magazine/ee335713.aspx. 593 | 594 | const MAX_PLACEABLES = 100; 595 | /** 596 | * Fluent Resource is a structure storing a map of parsed localization entries. 597 | */ 598 | 599 | class FluentResource extends Map { 600 | /** 601 | * Create a new FluentResource from Fluent code. 602 | */ 603 | static fromString(source) { 604 | RE_MESSAGE_START.lastIndex = 0; 605 | let resource = new this(); 606 | let cursor = 0; // Iterate over the beginnings of messages and terms to efficiently skip 607 | // comments and recover from errors. 608 | 609 | while (true) { 610 | let next = RE_MESSAGE_START.exec(source); 611 | 612 | if (next === null) { 613 | break; 614 | } 615 | 616 | cursor = RE_MESSAGE_START.lastIndex; 617 | 618 | try { 619 | resource.set(next[1], parseMessage()); 620 | } catch (err) { 621 | if (err instanceof FluentError) { 622 | // Don't report any Fluent syntax errors. Skip directly to the 623 | // beginning of the next message or term. 624 | continue; 625 | } 626 | 627 | throw err; 628 | } 629 | } 630 | 631 | return resource; // The parser implementation is inlined below for performance reasons. 632 | // The parser focuses on minimizing the number of false negatives at the 633 | // expense of increasing the risk of false positives. In other words, it 634 | // aims at parsing valid Fluent messages with a success rate of 100%, but it 635 | // may also parse a few invalid messages which the reference parser would 636 | // reject. The parser doesn't perform any validation and may produce entries 637 | // which wouldn't make sense in the real world. For best results users are 638 | // advised to validate translations with the fluent-syntax parser 639 | // pre-runtime. 640 | // The parser makes an extensive use of sticky regexes which can be anchored 641 | // to any offset of the source string without slicing it. Errors are thrown 642 | // to bail out of parsing of ill-formed messages. 643 | 644 | function test(re) { 645 | re.lastIndex = cursor; 646 | return re.test(source); 647 | } // Advance the cursor by the char if it matches. May be used as a predicate 648 | // (was the match found?) or, if errorClass is passed, as an assertion. 649 | 650 | 651 | function consumeChar(char, errorClass) { 652 | if (source[cursor] === char) { 653 | cursor++; 654 | return true; 655 | } 656 | 657 | if (errorClass) { 658 | throw new errorClass(`Expected ${char}`); 659 | } 660 | 661 | return false; 662 | } // Advance the cursor by the token if it matches. May be used as a predicate 663 | // (was the match found?) or, if errorClass is passed, as an assertion. 664 | 665 | 666 | function consumeToken(re, errorClass) { 667 | if (test(re)) { 668 | cursor = re.lastIndex; 669 | return true; 670 | } 671 | 672 | if (errorClass) { 673 | throw new errorClass(`Expected ${re.toString()}`); 674 | } 675 | 676 | return false; 677 | } // Execute a regex, advance the cursor, and return all capture groups. 678 | 679 | 680 | function match(re) { 681 | re.lastIndex = cursor; 682 | let result = re.exec(source); 683 | 684 | if (result === null) { 685 | throw new FluentError(`Expected ${re.toString()}`); 686 | } 687 | 688 | cursor = re.lastIndex; 689 | return result; 690 | } // Execute a regex, advance the cursor, and return the capture group. 691 | 692 | 693 | function match1(re) { 694 | return match(re)[1]; 695 | } 696 | 697 | function parseMessage() { 698 | let value = parsePattern(); 699 | let attrs = parseAttributes(); 700 | 701 | if (attrs === null) { 702 | if (value === null) { 703 | throw new FluentError("Expected message value or attributes"); 704 | } 705 | 706 | return value; 707 | } 708 | 709 | return { 710 | value, 711 | attrs 712 | }; 713 | } 714 | 715 | function parseAttributes() { 716 | let attrs = {}; 717 | 718 | while (test(RE_ATTRIBUTE_START)) { 719 | let name = match1(RE_ATTRIBUTE_START); 720 | let value = parsePattern(); 721 | 722 | if (value === null) { 723 | throw new FluentError("Expected attribute value"); 724 | } 725 | 726 | attrs[name] = value; 727 | } 728 | 729 | return Object.keys(attrs).length > 0 ? attrs : null; 730 | } 731 | 732 | function parsePattern() { 733 | // First try to parse any simple text on the same line as the id. 734 | if (test(RE_TEXT_RUN)) { 735 | var first = match1(RE_TEXT_RUN); 736 | } // If there's a placeable on the first line, parse a complex pattern. 737 | 738 | 739 | if (source[cursor] === "{" || source[cursor] === "}") { 740 | // Re-use the text parsed above, if possible. 741 | return parsePatternElements(first ? [first] : [], Infinity); 742 | } // RE_TEXT_VALUE stops at newlines. Only continue parsing the pattern if 743 | // what comes after the newline is indented. 744 | 745 | 746 | let indent = parseIndent(); 747 | 748 | if (indent) { 749 | if (first) { 750 | // If there's text on the first line, the blank block is part of the 751 | // translation content in its entirety. 752 | return parsePatternElements([first, indent], indent.length); 753 | } // Otherwise, we're dealing with a block pattern, i.e. a pattern which 754 | // starts on a new line. Discrad the leading newlines but keep the 755 | // inline indent; it will be used by the dedentation logic. 756 | 757 | 758 | indent.value = trim(indent.value, RE_LEADING_NEWLINES); 759 | return parsePatternElements([indent], indent.length); 760 | } 761 | 762 | if (first) { 763 | // It was just a simple inline text after all. 764 | return trim(first, RE_TRAILING_SPACES); 765 | } 766 | 767 | return null; 768 | } // Parse a complex pattern as an array of elements. 769 | 770 | 771 | function parsePatternElements(elements = [], commonIndent) { 772 | let placeableCount = 0; 773 | 774 | while (true) { 775 | if (test(RE_TEXT_RUN)) { 776 | elements.push(match1(RE_TEXT_RUN)); 777 | continue; 778 | } 779 | 780 | if (source[cursor] === "{") { 781 | if (++placeableCount > MAX_PLACEABLES) { 782 | throw new FluentError("Too many placeables"); 783 | } 784 | 785 | elements.push(parsePlaceable()); 786 | continue; 787 | } 788 | 789 | if (source[cursor] === "}") { 790 | throw new FluentError("Unbalanced closing brace"); 791 | } 792 | 793 | let indent = parseIndent(); 794 | 795 | if (indent) { 796 | elements.push(indent); 797 | commonIndent = Math.min(commonIndent, indent.length); 798 | continue; 799 | } 800 | 801 | break; 802 | } 803 | 804 | let lastIndex = elements.length - 1; // Trim the trailing spaces in the last element if it's a TextElement. 805 | 806 | if (typeof elements[lastIndex] === "string") { 807 | elements[lastIndex] = trim(elements[lastIndex], RE_TRAILING_SPACES); 808 | } 809 | 810 | let baked = []; 811 | 812 | for (let element of elements) { 813 | if (element.type === "indent") { 814 | // Dedent indented lines by the maximum common indent. 815 | element = element.value.slice(0, element.value.length - commonIndent); 816 | } else if (element.type === "str") { 817 | // Optimize StringLiterals into their value. 818 | element = element.value; 819 | } 820 | 821 | if (element) { 822 | baked.push(element); 823 | } 824 | } 825 | 826 | return baked; 827 | } 828 | 829 | function parsePlaceable() { 830 | consumeToken(TOKEN_BRACE_OPEN, FluentError); 831 | let selector = parseInlineExpression(); 832 | 833 | if (consumeToken(TOKEN_BRACE_CLOSE)) { 834 | return selector; 835 | } 836 | 837 | if (consumeToken(TOKEN_ARROW)) { 838 | let variants = parseVariants(); 839 | consumeToken(TOKEN_BRACE_CLOSE, FluentError); 840 | return { 841 | type: "select", 842 | selector, 843 | ...variants 844 | }; 845 | } 846 | 847 | throw new FluentError("Unclosed placeable"); 848 | } 849 | 850 | function parseInlineExpression() { 851 | if (source[cursor] === "{") { 852 | // It's a nested placeable. 853 | return parsePlaceable(); 854 | } 855 | 856 | if (test(RE_REFERENCE)) { 857 | let [, sigil, name, attr = null] = match(RE_REFERENCE); 858 | 859 | if (sigil === "$") { 860 | return { 861 | type: "var", 862 | name 863 | }; 864 | } 865 | 866 | if (consumeToken(TOKEN_PAREN_OPEN)) { 867 | let args = parseArguments(); 868 | 869 | if (sigil === "-") { 870 | // A parameterized term: -term(...). 871 | return { 872 | type: "term", 873 | name, 874 | attr, 875 | args 876 | }; 877 | } 878 | 879 | if (RE_FUNCTION_NAME.test(name)) { 880 | return { 881 | type: "func", 882 | name, 883 | args 884 | }; 885 | } 886 | 887 | throw new FluentError("Function names must be all upper-case"); 888 | } 889 | 890 | if (sigil === "-") { 891 | // A non-parameterized term: -term. 892 | return { 893 | type: "term", 894 | name, 895 | attr, 896 | args: [] 897 | }; 898 | } 899 | 900 | return { 901 | type: "mesg", 902 | name, 903 | attr 904 | }; 905 | } 906 | 907 | return parseLiteral(); 908 | } 909 | 910 | function parseArguments() { 911 | let args = []; 912 | 913 | while (true) { 914 | switch (source[cursor]) { 915 | case ")": 916 | // End of the argument list. 917 | cursor++; 918 | return args; 919 | 920 | case undefined: 921 | // EOF 922 | throw new FluentError("Unclosed argument list"); 923 | } 924 | 925 | args.push(parseArgument()); // Commas between arguments are treated as whitespace. 926 | 927 | consumeToken(TOKEN_COMMA); 928 | } 929 | } 930 | 931 | function parseArgument() { 932 | let expr = parseInlineExpression(); 933 | 934 | if (expr.type !== "mesg") { 935 | return expr; 936 | } 937 | 938 | if (consumeToken(TOKEN_COLON)) { 939 | // The reference is the beginning of a named argument. 940 | return { 941 | type: "narg", 942 | name: expr.name, 943 | value: parseLiteral() 944 | }; 945 | } // It's a regular message reference. 946 | 947 | 948 | return expr; 949 | } 950 | 951 | function parseVariants() { 952 | let variants = []; 953 | let count = 0; 954 | let star; 955 | 956 | while (test(RE_VARIANT_START)) { 957 | if (consumeChar("*")) { 958 | star = count; 959 | } 960 | 961 | let key = parseVariantKey(); 962 | let value = parsePattern(); 963 | 964 | if (value === null) { 965 | throw new FluentError("Expected variant value"); 966 | } 967 | 968 | variants[count++] = { 969 | key, 970 | value 971 | }; 972 | } 973 | 974 | if (count === 0) { 975 | return null; 976 | } 977 | 978 | if (star === undefined) { 979 | throw new FluentError("Expected default variant"); 980 | } 981 | 982 | return { 983 | variants, 984 | star 985 | }; 986 | } 987 | 988 | function parseVariantKey() { 989 | consumeToken(TOKEN_BRACKET_OPEN, FluentError); 990 | let key = test(RE_NUMBER_LITERAL) ? parseNumberLiteral() : match1(RE_IDENTIFIER); 991 | consumeToken(TOKEN_BRACKET_CLOSE, FluentError); 992 | return key; 993 | } 994 | 995 | function parseLiteral() { 996 | if (test(RE_NUMBER_LITERAL)) { 997 | return parseNumberLiteral(); 998 | } 999 | 1000 | if (source[cursor] === "\"") { 1001 | return parseStringLiteral(); 1002 | } 1003 | 1004 | throw new FluentError("Invalid expression"); 1005 | } 1006 | 1007 | function parseNumberLiteral() { 1008 | let [, value, fraction = ""] = match(RE_NUMBER_LITERAL); 1009 | let precision = fraction.length; 1010 | return { 1011 | type: "num", 1012 | value: parseFloat(value), 1013 | precision 1014 | }; 1015 | } 1016 | 1017 | function parseStringLiteral() { 1018 | consumeChar("\"", FluentError); 1019 | let value = ""; 1020 | 1021 | while (true) { 1022 | value += match1(RE_STRING_RUN); 1023 | 1024 | if (source[cursor] === "\\") { 1025 | value += parseEscapeSequence(); 1026 | continue; 1027 | } 1028 | 1029 | if (consumeChar("\"")) { 1030 | return { 1031 | type: "str", 1032 | value 1033 | }; 1034 | } // We've reached an EOL of EOF. 1035 | 1036 | 1037 | throw new FluentError("Unclosed string literal"); 1038 | } 1039 | } // Unescape known escape sequences. 1040 | 1041 | 1042 | function parseEscapeSequence() { 1043 | if (test(RE_STRING_ESCAPE)) { 1044 | return match1(RE_STRING_ESCAPE); 1045 | } 1046 | 1047 | if (test(RE_UNICODE_ESCAPE)) { 1048 | let [, codepoint4, codepoint6] = match(RE_UNICODE_ESCAPE); 1049 | let codepoint = parseInt(codepoint4 || codepoint6, 16); 1050 | return codepoint <= 0xD7FF || 0xE000 <= codepoint // It's a Unicode scalar value. 1051 | ? String.fromCodePoint(codepoint) // Lonely surrogates can cause trouble when the parsing result is 1052 | // saved using UTF-8. Use U+FFFD REPLACEMENT CHARACTER instead. 1053 | : "�"; 1054 | } 1055 | 1056 | throw new FluentError("Unknown escape sequence"); 1057 | } // Parse blank space. Return it if it looks like indent before a pattern 1058 | // line. Skip it othwerwise. 1059 | 1060 | 1061 | function parseIndent() { 1062 | let start = cursor; 1063 | consumeToken(TOKEN_BLANK); // Check the first non-blank character after the indent. 1064 | 1065 | switch (source[cursor]) { 1066 | case ".": 1067 | case "[": 1068 | case "*": 1069 | case "}": 1070 | case undefined: 1071 | // EOF 1072 | // A special character. End the Pattern. 1073 | return false; 1074 | 1075 | case "{": 1076 | // Placeables don't require indentation (in EBNF: block-placeable). 1077 | // Continue the Pattern. 1078 | return makeIndent(source.slice(start, cursor)); 1079 | } // If the first character on the line is not one of the special characters 1080 | // listed above, it's a regular text character. Check if there's at least 1081 | // one space of indent before it. 1082 | 1083 | 1084 | if (source[cursor - 1] === " ") { 1085 | // It's an indented text character (in EBNF: indented-char). Continue 1086 | // the Pattern. 1087 | return makeIndent(source.slice(start, cursor)); 1088 | } // A not-indented text character is likely the identifier of the next 1089 | // message. End the Pattern. 1090 | 1091 | 1092 | return false; 1093 | } // Trim blanks in text according to the given regex. 1094 | 1095 | 1096 | function trim(text, re) { 1097 | return text.replace(re, ""); 1098 | } // Normalize a blank block and extract the indent details. 1099 | 1100 | 1101 | function makeIndent(blank) { 1102 | let value = blank.replace(RE_BLANK_LINES, "\n"); 1103 | let length = RE_INDENT.exec(blank)[1].length; 1104 | return { 1105 | type: "indent", 1106 | value, 1107 | length 1108 | }; 1109 | } 1110 | } 1111 | 1112 | } 1113 | 1114 | /** 1115 | * Message bundles are single-language stores of translations. They are 1116 | * responsible for parsing translation resources in the Fluent syntax and can 1117 | * format translation units (entities) to strings. 1118 | * 1119 | * Always use `FluentBundle.format` to retrieve translation units from a 1120 | * bundle. Translations can contain references to other entities or variables, 1121 | * conditional logic in form of select expressions, traits which describe their 1122 | * grammatical features, and can use Fluent builtins which make use of the 1123 | * `Intl` formatters to format numbers, dates, lists and more into the 1124 | * bundle's language. See the documentation of the Fluent syntax for more 1125 | * information. 1126 | */ 1127 | 1128 | class FluentBundle { 1129 | /** 1130 | * Create an instance of `FluentBundle`. 1131 | * 1132 | * The `locales` argument is used to instantiate `Intl` formatters used by 1133 | * translations. The `options` object can be used to configure the bundle. 1134 | * 1135 | * Examples: 1136 | * 1137 | * const bundle = new FluentBundle(locales); 1138 | * 1139 | * const bundle = new FluentBundle(locales, { useIsolating: false }); 1140 | * 1141 | * const bundle = new FluentBundle(locales, { 1142 | * useIsolating: true, 1143 | * functions: { 1144 | * NODE_ENV: () => process.env.NODE_ENV 1145 | * } 1146 | * }); 1147 | * 1148 | * Available options: 1149 | * 1150 | * - `functions` - an object of additional functions available to 1151 | * translations as builtins. 1152 | * 1153 | * - `useIsolating` - boolean specifying whether to use Unicode isolation 1154 | * marks (FSI, PDI) for bidi interpolations. 1155 | * Default: true 1156 | * 1157 | * - `transform` - a function used to transform string parts of patterns. 1158 | * 1159 | * @param {string|Array} locales - Locale or locales of the bundle 1160 | * @param {Object} [options] 1161 | * @returns {FluentBundle} 1162 | */ 1163 | constructor(locales, { 1164 | functions = {}, 1165 | useIsolating = true, 1166 | transform = v => v 1167 | } = {}) { 1168 | this.locales = Array.isArray(locales) ? locales : [locales]; 1169 | this._terms = new Map(); 1170 | this._messages = new Map(); 1171 | this._functions = functions; 1172 | this._useIsolating = useIsolating; 1173 | this._transform = transform; 1174 | this._intls = new WeakMap(); 1175 | } 1176 | /* 1177 | * Return an iterator over public `[id, message]` pairs. 1178 | * 1179 | * @returns {Iterator} 1180 | */ 1181 | 1182 | 1183 | get messages() { 1184 | return this._messages[Symbol.iterator](); 1185 | } 1186 | /* 1187 | * Check if a message is present in the bundle. 1188 | * 1189 | * @param {string} id - The identifier of the message to check. 1190 | * @returns {bool} 1191 | */ 1192 | 1193 | 1194 | hasMessage(id) { 1195 | return this._messages.has(id); 1196 | } 1197 | /* 1198 | * Return the internal representation of a message. 1199 | * 1200 | * The internal representation should only be used as an argument to 1201 | * `FluentBundle.format`. 1202 | * 1203 | * @param {string} id - The identifier of the message to check. 1204 | * @returns {Any} 1205 | */ 1206 | 1207 | 1208 | getMessage(id) { 1209 | return this._messages.get(id); 1210 | } 1211 | /** 1212 | * Add a translation resource to the bundle. 1213 | * 1214 | * The translation resource must use the Fluent syntax. It will be parsed by 1215 | * the bundle and each translation unit (message) will be available in the 1216 | * bundle by its identifier. 1217 | * 1218 | * bundle.addMessages('foo = Foo'); 1219 | * bundle.getMessage('foo'); 1220 | * 1221 | * // Returns a raw representation of the 'foo' message. 1222 | * 1223 | * bundle.addMessages('bar = Bar'); 1224 | * bundle.addMessages('bar = Newbar', { allowOverrides: true }); 1225 | * bundle.getMessage('bar'); 1226 | * 1227 | * // Returns a raw representation of the 'bar' message: Newbar. 1228 | * 1229 | * Parsed entities should be formatted with the `format` method in case they 1230 | * contain logic (references, select expressions etc.). 1231 | * 1232 | * Available options: 1233 | * 1234 | * - `allowOverrides` - boolean specifying whether it's allowed to override 1235 | * an existing message or term with a new value. 1236 | * Default: false 1237 | * 1238 | * @param {string} source - Text resource with translations. 1239 | * @param {Object} [options] 1240 | * @returns {Array} 1241 | */ 1242 | 1243 | 1244 | addMessages(source, options) { 1245 | const res = FluentResource.fromString(source); 1246 | return this.addResource(res, options); 1247 | } 1248 | /** 1249 | * Add a translation resource to the bundle. 1250 | * 1251 | * The translation resource must be an instance of FluentResource, 1252 | * e.g. parsed by `FluentResource.fromString`. 1253 | * 1254 | * let res = FluentResource.fromString("foo = Foo"); 1255 | * bundle.addResource(res); 1256 | * bundle.getMessage('foo'); 1257 | * 1258 | * // Returns a raw representation of the 'foo' message. 1259 | * 1260 | * let res = FluentResource.fromString("bar = Bar"); 1261 | * bundle.addResource(res); 1262 | * res = FluentResource.fromString("bar = Newbar"); 1263 | * bundle.addResource(res, { allowOverrides: true }); 1264 | * bundle.getMessage('bar'); 1265 | * 1266 | * // Returns a raw representation of the 'bar' message: Newbar. 1267 | * 1268 | * Parsed entities should be formatted with the `format` method in case they 1269 | * contain logic (references, select expressions etc.). 1270 | * 1271 | * Available options: 1272 | * 1273 | * - `allowOverrides` - boolean specifying whether it's allowed to override 1274 | * an existing message or term with a new value. 1275 | * Default: false 1276 | * 1277 | * @param {FluentResource} res - FluentResource object. 1278 | * @param {Object} [options] 1279 | * @returns {Array} 1280 | */ 1281 | 1282 | 1283 | addResource(res, { 1284 | allowOverrides = false 1285 | } = {}) { 1286 | const errors = []; 1287 | 1288 | for (const [id, value] of res) { 1289 | if (id.startsWith("-")) { 1290 | // Identifiers starting with a dash (-) define terms. Terms are private 1291 | // and cannot be retrieved from FluentBundle. 1292 | if (allowOverrides === false && this._terms.has(id)) { 1293 | errors.push(`Attempt to override an existing term: "${id}"`); 1294 | continue; 1295 | } 1296 | 1297 | this._terms.set(id, value); 1298 | } else { 1299 | if (allowOverrides === false && this._messages.has(id)) { 1300 | errors.push(`Attempt to override an existing message: "${id}"`); 1301 | continue; 1302 | } 1303 | 1304 | this._messages.set(id, value); 1305 | } 1306 | } 1307 | 1308 | return errors; 1309 | } 1310 | /** 1311 | * Format a message to a string or null. 1312 | * 1313 | * Format a raw `message` from the bundle into a string (or a null if it has 1314 | * a null value). `args` will be used to resolve references to variables 1315 | * passed as arguments to the translation. 1316 | * 1317 | * In case of errors `format` will try to salvage as much of the translation 1318 | * as possible and will still return a string. For performance reasons, the 1319 | * encountered errors are not returned but instead are appended to the 1320 | * `errors` array passed as the third argument. 1321 | * 1322 | * const errors = []; 1323 | * bundle.addMessages('hello = Hello, { $name }!'); 1324 | * const hello = bundle.getMessage('hello'); 1325 | * bundle.format(hello, { name: 'Jane' }, errors); 1326 | * 1327 | * // Returns 'Hello, Jane!' and `errors` is empty. 1328 | * 1329 | * bundle.format(hello, undefined, errors); 1330 | * 1331 | * // Returns 'Hello, name!' and `errors` is now: 1332 | * 1333 | * [] 1334 | * 1335 | * @param {Object | string} message 1336 | * @param {Object | undefined} args 1337 | * @param {Array} errors 1338 | * @returns {?string} 1339 | */ 1340 | 1341 | 1342 | format(message, args, errors) { 1343 | // optimize entities which are simple strings with no attributes 1344 | if (typeof message === "string") { 1345 | return this._transform(message); 1346 | } // optimize entities with null values 1347 | 1348 | 1349 | if (message === null || message.value === null) { 1350 | return null; 1351 | } // optimize simple-string entities with attributes 1352 | 1353 | 1354 | if (typeof message.value === "string") { 1355 | return this._transform(message.value); 1356 | } 1357 | 1358 | return resolve(this, args, message, errors); 1359 | } 1360 | 1361 | _memoizeIntlObject(ctor, opts) { 1362 | const cache = this._intls.get(ctor) || {}; 1363 | const id = JSON.stringify(opts); 1364 | 1365 | if (!cache[id]) { 1366 | cache[id] = new ctor(this.locales, opts); 1367 | 1368 | this._intls.set(ctor, cache); 1369 | } 1370 | 1371 | return cache[id]; 1372 | } 1373 | 1374 | } 1375 | 1376 | function addValue(k, value) { 1377 | var ftl = ''; 1378 | ftl = ftl + k + ' ='; 1379 | 1380 | if (value && value.indexOf('\n') > -1) { 1381 | ftl = ftl + '\n '; 1382 | ftl = ftl + value.split('\n').join('\n '); 1383 | } else { 1384 | ftl = ftl + ' ' + value; 1385 | } 1386 | 1387 | return ftl; 1388 | } 1389 | 1390 | function addComment(comment) { 1391 | var ftl = ''; 1392 | ftl = ftl + '# ' + comment.split('\n').join('\n# '); 1393 | ftl = ftl + '\n'; 1394 | return ftl; 1395 | } 1396 | 1397 | function js2ftl(resources, cb) { 1398 | var ftl = ''; 1399 | Object.keys(resources).forEach(function (k) { 1400 | var value = resources[k]; 1401 | 1402 | if (typeof value === 'string') { 1403 | ftl = ftl + addValue(k, value); 1404 | ftl = ftl + '\n\n'; 1405 | } else { 1406 | if (value.comment) ftl = ftl + addComment(value.comment); 1407 | ftl = ftl + addValue(k, value.val); 1408 | Object.keys(value).forEach(function (innerK) { 1409 | if (innerK === 'comment' || innerK === 'val') return; 1410 | var innerValue = value[innerK]; 1411 | ftl = ftl + addValue('\n .' + innerK, innerValue); 1412 | }); 1413 | ftl = ftl + '\n\n'; 1414 | } 1415 | }); 1416 | if (cb) cb(null, ftl); 1417 | return ftl; 1418 | } 1419 | 1420 | function getDefaults() { 1421 | return { 1422 | bindI18nStore: true, 1423 | fluentBundleOptions: { 1424 | useIsolating: false 1425 | } 1426 | }; 1427 | } 1428 | 1429 | function nonBlank(line) { 1430 | return !/^\s*$/.test(line); 1431 | } 1432 | 1433 | function countIndent(line) { 1434 | const [indent] = line.match(/^\s*/); 1435 | return indent.length; 1436 | } 1437 | 1438 | function ftl(code) { 1439 | const lines = code.split("\n").filter(nonBlank); 1440 | const indents = lines.map(countIndent); 1441 | const common = Math.min(...indents); 1442 | const indent = new RegExp(`^\\s{${common}}`); 1443 | return lines.map(line => line.replace(indent, "")).join("\n"); 1444 | } 1445 | 1446 | class BundleStore { 1447 | constructor(i18next, options) { 1448 | this.i18next = i18next; 1449 | this.options = options; 1450 | this.bundles = {}; // this.createBundleFromI18next = this.createBundleFromI18next.bind(this); 1451 | // this.createBundle = this.createBundle.bind(this); 1452 | // this.bind = this.bind.bind(this); 1453 | } 1454 | 1455 | createBundle(lng, ns, json) { 1456 | const ftlStr = json ? js2ftl(json) : ""; 1457 | const bundle = new FluentBundle(lng, this.options.fluentBundleOptions); 1458 | bundle.addMessages(ftl(ftlStr)); 1459 | setPath(this.bundles, [lng, ns], bundle); 1460 | } 1461 | 1462 | createBundleFromI18next(lng, ns) { 1463 | this.createBundle(lng, ns, getPath(this.i18next.store.data, [lng, ns])); 1464 | } 1465 | 1466 | getBundle(lng, ns) { 1467 | return getPath(this.bundles, [lng, ns]); 1468 | } 1469 | 1470 | bind() { 1471 | this.i18next.store.on('added', (lng, ns) => { 1472 | if (!this.i18next.isInitialized) return; 1473 | this.createBundleFromI18next(lng, ns); 1474 | }); 1475 | this.i18next.on('initialized', () => { 1476 | var lngs = this.i18next.languages || []; 1477 | var preload = this.i18next.options.preload || []; 1478 | lngs.filter(l => !preload.includes(l)).concat(preload).forEach(lng => { 1479 | this.i18next.options.ns.forEach(ns => { 1480 | this.createBundleFromI18next(lng, ns); 1481 | }); 1482 | }); 1483 | }); 1484 | } 1485 | 1486 | } 1487 | 1488 | class Fluent { 1489 | constructor(options) { 1490 | this.type = 'i18nFormat'; 1491 | this.handleAsObject = false; 1492 | this.init(null, options); 1493 | } 1494 | 1495 | init(i18next, options) { 1496 | const i18nextOptions = i18next && i18next.options && i18next.options.i18nFormat || {}; 1497 | this.options = defaults(i18nextOptions, options, this.options || {}, getDefaults()); 1498 | 1499 | if (i18next) { 1500 | this.store = new BundleStore(i18next, this.options); 1501 | if (this.options.bindI18nStore) this.store.bind(); 1502 | i18next.fluent = this; 1503 | } else { 1504 | this.store = new BundleStore(null, this.options); 1505 | } 1506 | } 1507 | 1508 | parse(res, options, lng, ns, key, info) { 1509 | const bundle = this.store.getBundle(lng, ns); 1510 | const isAttr = key.indexOf('.') > -1; 1511 | if (!res) return key; 1512 | const useRes = isAttr ? res.attrs[key.split('.')[1]] : res; 1513 | if (!bundle) return key; 1514 | return bundle.format(useRes, options); 1515 | } 1516 | 1517 | getResource(lng, ns, key, options) { 1518 | let bundle = this.store.getBundle(lng, ns); 1519 | const useKey = key.indexOf('.') > -1 ? key.split('.')[0] : key; 1520 | if (!bundle) return undefined; 1521 | return bundle.getMessage(useKey); 1522 | } 1523 | 1524 | addLookupKeys(finalKeys, key, code, ns, options) { 1525 | // no additional keys needed for select or plural 1526 | // so there is no need to add keys to that finalKeys array 1527 | return finalKeys; 1528 | } 1529 | 1530 | } 1531 | 1532 | Fluent.type = 'i18nFormat'; 1533 | 1534 | return Fluent; 1535 | 1536 | })); 1537 | -------------------------------------------------------------------------------- /i18nextFluent.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).i18nextFluent=t()}(this,(function(){"use strict";function e(e,t,n){function r(e){return e&&e.indexOf("###")>-1?e.replace(/###/g,"."):e}function s(){return!e||"string"==typeof e}const i="string"!=typeof t?[].concat(t):t.split(".");for(;i.length>1;){if(s())return{};const t=r(i.shift());!e[t]&&n&&(e[t]=new n),e=e[t]}return s()?{}:{obj:e,k:r(i.shift())}}function t(t,n){const{obj:r,k:s}=e(t,n);if(r)return r[s]}let n=[],r=n.forEach,s=n.slice;class i{constructor(e,t){this.value=e,this.opts=t}valueOf(){return this.value}toString(){throw new Error("Subclasses of FluentType must implement toString.")}}class o extends i{valueOf(){return null}toString(){return`{${this.value||"???"}}`}}class u extends i{constructor(e,t){super(parseFloat(e),t)}toString(e){try{return e._memoizeIntlObject(Intl.NumberFormat,this.opts).format(this.value)}catch(e){return this.value}}}class a extends i{constructor(e,t){super(new Date(e),t)}toString(e){try{return e._memoizeIntlObject(Intl.DateTimeFormat,this.opts).format(this.value)}catch(e){return this.value}}}function c(e,t){return Object.assign({},e,function(e){const t={};for(const[n,r]of Object.entries(e))t[n]=r.valueOf();return t}(t))}var l=Object.freeze({__proto__:null,NUMBER:function([e],t){return e instanceof o?e:e instanceof u?new u(e.valueOf(),c(e.opts,t)):new o("NUMBER()")},DATETIME:function([e],t){return e instanceof o?e:e instanceof a?new a(e.valueOf(),c(e.opts,t)):new o("DATETIME()")}});const f=2500;function h(e,t,n){if(n===t)return!0;if(n instanceof u&&t instanceof u&&n.value===t.value)return!0;if(t instanceof u&&"string"==typeof n){if(n===e._memoizeIntlObject(Intl.PluralRules,t.opts).select(t.value))return!0}return!1}function p(e,t,n){return t[n]?g(e,t[n]):(e.errors.push(new RangeError("No default")),new o)}function d(e,t){const n=[],r={};for(const s of t)"narg"===s.type?r[s.name]=g(e,s.value):n.push(g(e,s));return[n,r]}function g(e,t){if("string"==typeof t)return e.bundle._transform(t);if(t instanceof o)return t;if(Array.isArray(t))return function(e,t){if(e.dirty.has(t))return e.errors.push(new RangeError("Cyclic reference")),new o;e.dirty.add(t);const n=[],r=e.bundle._useIsolating&&t.length>1;for(const s of t){if("string"==typeof s){n.push(e.bundle._transform(s));continue}const t=g(e,s).toString(e.bundle);r&&n.push("⁨"),t.length>f?(e.errors.push(new RangeError(`Too many characters in placeable (${t.length}, max allowed is 2500)`)),n.push(t.slice(f))):n.push(t),r&&n.push("⁩")}return e.dirty.delete(t),n.join("")}(e,t);switch(t.type){case"str":return t.value;case"num":return new u(t.value,{minimumFractionDigits:t.precision});case"var":return function(e,{name:t}){if(!e.args||!e.args.hasOwnProperty(t))return!1===e.insideTermReference&&e.errors.push(new ReferenceError(`Unknown variable: ${t}`)),new o(`$${t}`);const n=e.args[t];if(n instanceof i)return n;switch(typeof n){case"string":return n;case"number":return new u(n);case"object":if(n instanceof Date)return new a(n);default:return e.errors.push(new TypeError(`Unsupported variable type: ${t}, ${typeof n}`)),new o(`$${t}`)}}(e,t);case"mesg":return function(e,{name:t,attr:n}){const r=e.bundle._messages.get(t);if(!r){const n=new ReferenceError(`Unknown message: ${t}`);return e.errors.push(n),new o(t)}if(n){const s=r.attrs&&r.attrs[n];return s?g(e,s):(e.errors.push(new ReferenceError(`Unknown attribute: ${n}`)),new o(`${t}.${n}`))}return g(e,r)}(e,t);case"term":return function(e,{name:t,attr:n,args:r}){const s=`-${t}`,i=e.bundle._terms.get(s);if(!i){const t=new ReferenceError(`Unknown term: ${s}`);return e.errors.push(t),new o(s)}const[,u]=d(e,r),a={...e,args:u,insideTermReference:!0};if(n){const t=i.attrs&&i.attrs[n];return t?g(a,t):(e.errors.push(new ReferenceError(`Unknown attribute: ${n}`)),new o(`${s}.${n}`))}return g(a,i)}(e,t);case"func":return function(e,{name:t,args:n}){const r=e.bundle._functions[t]||l[t];if(!r)return e.errors.push(new ReferenceError(`Unknown function: ${t}()`)),new o(`${t}()`);if("function"!=typeof r)return e.errors.push(new TypeError(`Function ${t}() is not callable`)),new o(`${t}()`);try{return r(...d(e,n))}catch(e){return new o(`${t}()`)}}(e,t);case"select":return function(e,{selector:t,variants:n,star:r}){let s=g(e,t);if(s instanceof o){return g(e,p(e,n,r))}for(const t of n){const n=g(e,t.key);if(h(e.bundle,s,n))return g(e,t)}const i=p(e,n,r);return g(e,i)}(e,t);case void 0:return null!==t.value&&void 0!==t.value?g(e,t.value):(e.errors.push(new RangeError("No value")),new o);default:return new o}}class m extends Error{}const w=/^(-?[a-zA-Z][\w-]*) *= */gm,y=/\.([a-zA-Z][\w-]*) *= */y,v=/\*?\[/y,b=/(-?[0-9]+(?:\.([0-9]+))?)/y,x=/([a-zA-Z][\w-]*)/y,$=/([$-])?([a-zA-Z][\w-]*)(?:\.([a-zA-Z][\w-]*))?/y,E=/^[A-Z][A-Z0-9_-]*$/,_=/([^{}\n\r]+)/y,I=/([^\\"\n\r]*)/y,O=/\\([\\"])/y,j=/\\u([a-fA-F0-9]{4})|\\U([a-fA-F0-9]{6})/y,k=/^\n+/,R=/ +$/,A=/ *\r?\n/g,S=/( *)$/,F=/{\s*/y,M=/\s*}/y,T=/\[\s*/y,U=/\s*] */y,z=/\s*\(\s*/y,B=/\s*->\s*/y,Z=/\s*:\s*/y,D=/\s*,?\s*/y,N=/\s+/y;class P extends Map{static fromString(e){w.lastIndex=0;let t=new this,n=0;for(;;){let r=w.exec(e);if(null===r)break;n=w.lastIndex;try{t.set(r[1],a())}catch(e){if(e instanceof m)continue;throw e}}return t;function r(t){return t.lastIndex=n,t.test(e)}function s(t,r){if(e[n]===t)return n++,!0;if(r)throw new r(`Expected ${t}`);return!1}function i(e,t){if(r(e))return n=e.lastIndex,!0;if(t)throw new t(`Expected ${e.toString()}`);return!1}function o(t){t.lastIndex=n;let r=t.exec(e);if(null===r)throw new m(`Expected ${t.toString()}`);return n=t.lastIndex,r}function u(e){return o(e)[1]}function a(){let e=c(),t=function(){let e={};for(;r(y);){let t=u(y),n=c();if(null===n)throw new m("Expected attribute value");e[t]=n}return Object.keys(e).length>0?e:null}();if(null===t){if(null===e)throw new m("Expected message value or attributes");return e}return{value:e,attrs:t}}function c(){if(r(_))var t=u(_);if("{"===e[n]||"}"===e[n])return l(t?[t]:[],1/0);let s=C();return s?t?l([t,s],s.length):(s.value=q(s.value,k),l([s],s.length)):t?q(t,R):null}function l(t=[],s){let i=0;for(;;){if(r(_)){t.push(u(_));continue}if("{"===e[n]){if(++i>100)throw new m("Too many placeables");t.push(f());continue}if("}"===e[n])throw new m("Unbalanced closing brace");let o=C();if(!o)break;t.push(o),s=Math.min(s,o.length)}let o=t.length-1;"string"==typeof t[o]&&(t[o]=q(t[o],R));let a=[];for(let e of t)"indent"===e.type?e=e.value.slice(0,e.value.length-s):"str"===e.type&&(e=e.value),e&&a.push(e);return a}function f(){i(F,m);let e=h();if(i(M))return e;if(i(B)){let t=function(){let e,t=[],n=0;for(;r(v);){s("*")&&(e=n);let r=d(),i=c();if(null===i)throw new m("Expected variant value");t[n++]={key:r,value:i}}if(0===n)return null;if(void 0===e)throw new m("Expected default variant");return{variants:t,star:e}}();return i(M,m),{type:"select",selector:e,...t}}throw new m("Unclosed placeable")}function h(){if("{"===e[n])return f();if(r($)){let[,t,r,s=null]=o($);if("$"===t)return{type:"var",name:r};if(i(z)){let o=function(){let t=[];for(;;){switch(e[n]){case")":return n++,t;case void 0:throw new m("Unclosed argument list")}t.push(p()),i(D)}}();if("-"===t)return{type:"term",name:r,attr:s,args:o};if(E.test(r))return{type:"func",name:r,args:o};throw new m("Function names must be all upper-case")}return"-"===t?{type:"term",name:r,attr:s,args:[]}:{type:"mesg",name:r,attr:s}}return g()}function p(){let e=h();return"mesg"!==e.type?e:i(Z)?{type:"narg",name:e.name,value:g()}:e}function d(){i(T,m);let e=r(b)?P():u(x);return i(U,m),e}function g(){if(r(b))return P();if('"'===e[n])return function(){s('"',m);let t="";for(;;){if(t+=u(I),"\\"!==e[n]){if(s('"'))return{type:"str",value:t};throw new m("Unclosed string literal")}t+=W()}}();throw new m("Invalid expression")}function P(){let[,e,t=""]=o(b),n=t.length;return{type:"num",value:parseFloat(e),precision:n}}function W(){if(r(O))return u(O);if(r(j)){let[,e,t]=o(j),n=parseInt(e||t,16);return n<=55295||57344<=n?String.fromCodePoint(n):"�"}throw new m("Unknown escape sequence")}function C(){let t=n;switch(i(N),e[n]){case".":case"[":case"*":case"}":case void 0:return!1;case"{":return J(e.slice(t,n))}return" "===e[n-1]&&J(e.slice(t,n))}function q(e,t){return e.replace(t,"")}function J(e){return{type:"indent",value:e.replace(A,"\n"),length:S.exec(e)[1].length}}}}class W{constructor(e,{functions:t={},useIsolating:n=!0,transform:r=(e=>e)}={}){this.locales=Array.isArray(e)?e:[e],this._terms=new Map,this._messages=new Map,this._functions=t,this._useIsolating=n,this._transform=r,this._intls=new WeakMap}get messages(){return this._messages[Symbol.iterator]()}hasMessage(e){return this._messages.has(e)}getMessage(e){return this._messages.get(e)}addMessages(e,t){const n=P.fromString(e);return this.addResource(n,t)}addResource(e,{allowOverrides:t=!1}={}){const n=[];for(const[r,s]of e)if(r.startsWith("-")){if(!1===t&&this._terms.has(r)){n.push(`Attempt to override an existing term: "${r}"`);continue}this._terms.set(r,s)}else{if(!1===t&&this._messages.has(r)){n.push(`Attempt to override an existing message: "${r}"`);continue}this._messages.set(r,s)}return n}format(e,t,n){return"string"==typeof e?this._transform(e):null===e||null===e.value?null:"string"==typeof e.value?this._transform(e.value):function(e,t,n,r=[]){return g({bundle:e,args:t,errors:r,dirty:new WeakSet,insideTermReference:!1},n).toString(e)}(this,t,e,n)}_memoizeIntlObject(e,t){const n=this._intls.get(e)||{},r=JSON.stringify(t);return n[r]||(n[r]=new e(this.locales,t),this._intls.set(e,n)),n[r]}}function C(e,t){var n="";return n=n+e+" =",t&&t.indexOf("\n")>-1?(n+="\n ",n+=t.split("\n").join("\n ")):n=n+" "+t,n}function q(e){return!/^\s*$/.test(e)}function J(e){const[t]=e.match(/^\s*/);return t.length}class K{constructor(e,t){this.i18next=e,this.options=t,this.bundles={}}createBundle(t,n,r){const s=r?function(e,t){var n="";return Object.keys(e).forEach((function(t){var r=e[t];"string"==typeof r?(n+=C(t,r),n+="\n\n"):(r.comment&&(n+=function(e){var t="";return(t=t+"# "+e.split("\n").join("\n# "))+"\n"}(r.comment)),n+=C(t,r.val),Object.keys(r).forEach((function(e){if("comment"!==e&&"val"!==e){var t=r[e];n+=C("\n ."+e,t)}})),n+="\n\n")})),t&&t(null,n),n}(r):"",i=new W(t,this.options.fluentBundleOptions);i.addMessages(function(e){const t=e.split("\n").filter(q),n=t.map(J),r=Math.min(...n),s=new RegExp(`^\\s{${r}}`);return t.map((e=>e.replace(s,""))).join("\n")}(s)),function(t,n,r){const{obj:s,k:i}=e(t,n,Object);s[i]=r}(this.bundles,[t,n],i)}createBundleFromI18next(e,n){this.createBundle(e,n,t(this.i18next.store.data,[e,n]))}getBundle(e,n){return t(this.bundles,[e,n])}bind(){this.i18next.store.on("added",((e,t)=>{this.i18next.isInitialized&&this.createBundleFromI18next(e,t)})),this.i18next.on("initialized",(()=>{var e=this.i18next.languages||[],t=this.i18next.options.preload||[];e.filter((e=>!t.includes(e))).concat(t).forEach((e=>{this.i18next.options.ns.forEach((t=>{this.createBundleFromI18next(e,t)}))}))}))}}class L{constructor(e){this.type="i18nFormat",this.handleAsObject=!1,this.init(null,e)}init(e,t){const n=e&&e.options&&e.options.i18nFormat||{};this.options=function(e){return r.call(s.call(arguments,1),(function(t){if(t)for(var n in t)void 0===e[n]&&(e[n]=t[n])})),e}(n,t,this.options||{},{bindI18nStore:!0,fluentBundleOptions:{useIsolating:!1}}),e?(this.store=new K(e,this.options),this.options.bindI18nStore&&this.store.bind(),e.fluent=this):this.store=new K(null,this.options)}parse(e,t,n,r,s,i){const o=this.store.getBundle(n,r),u=s.indexOf(".")>-1;if(!e)return s;const a=u?e.attrs[s.split(".")[1]]:e;return o?o.format(a,t):s}getResource(e,t,n,r){let s=this.store.getBundle(e,t);const i=n.indexOf(".")>-1?n.split(".")[0]:n;if(s)return s.getMessage(i)}addLookupKeys(e,t,n,r,s){return e}}return L.type="i18nFormat",L})); 2 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "i18next-fluent" { 2 | import { i18n, ThirdPartyModule } from "i18next"; 3 | 4 | 5 | export interface FluentConfig { 6 | bindI18nStore?: boolean, 7 | fluentBundleOptions?: { 8 | useIsolating?: boolean 9 | } 10 | } 11 | 12 | export interface FluentInstance extends ThirdPartyModule { 13 | init(i18next: i18n, options?: TOptions): void; 14 | } 15 | 16 | interface FluentConstructor { 17 | new (config?: FluentConfig): FluentInstance; 18 | type: "i18nFormat"; 19 | } 20 | 21 | const Fluent: FluentConstructor; 22 | 23 | 24 | export default Fluent; 25 | } 26 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/commonjs/index.js').default; 2 | -------------------------------------------------------------------------------- /mocha_setup.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | require("@babel/register")({ 4 | ignore: [ 5 | // Ignore node_modules other than own Fluent dependencies. 6 | (path) => 7 | /node_modules/.test(path) && !/node_modules\/@fluent\/bundle/.test(path), 8 | ], 9 | plugins: [ 10 | "@babel/plugin-proposal-async-generator-functions", 11 | "@babel/plugin-proposal-object-rest-spread", 12 | "@babel/plugin-transform-modules-commonjs" 13 | ] 14 | }); 15 | 16 | var chai = require("chai"); 17 | global.expect = chai.expect; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "i18next-fluent", 3 | "version": "2.0.0", 4 | "description": "i18nFormat plugin to use fluent format with i18next", 5 | "main": "./index.js", 6 | "jsnext:main": "dist/es/index.js", 7 | "keywords": [ 8 | "i18next", 9 | "i18next-format", 10 | "fluent" 11 | ], 12 | "homepage": "https://github.com/i18next/i18next-fluent", 13 | "bugs": "https://github.com/i18next/i18next-fluent/issues", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/i18next/i18next-fluent" 17 | }, 18 | "dependencies": { 19 | "@fluent/bundle": "^0.13.0", 20 | "fluent_conv": "^3.1.0" 21 | }, 22 | "devDependencies": { 23 | "@babel/cli": "^7.15.7", 24 | "@babel/core": "^7.15.5", 25 | "@babel/plugin-proposal-async-generator-functions": "^7.15.0", 26 | "@babel/plugin-proposal-object-rest-spread": "^7.15.6", 27 | "@babel/plugin-transform-modules-commonjs": "^7.15.4-", 28 | "@babel/preset-env": "^7.15.6", 29 | "@babel/register": "^7.15.3", 30 | "@rollup/plugin-babel": "^5.3.0", 31 | "@rollup/plugin-commonjs": "^20.0.0", 32 | "@rollup/plugin-node-resolve": "13.0.5", 33 | "babel-eslint": "^10.1.0", 34 | "chai": "^4.3.4", 35 | "eslint": "^7.32.0", 36 | "eslint-plugin-mocha": "^9.0.0", 37 | "i18next": "^21.0.2", 38 | "mkdirp": "^1.0.4", 39 | "mocha": "^9.1.1", 40 | "rimraf": "3.0.2", 41 | "rollup": "2.57.0", 42 | "rollup-plugin-terser": "^7.0.2", 43 | "sinon": "11.1.2", 44 | "yargs": "17.2.0" 45 | }, 46 | "scripts": { 47 | "test": "mocha -r ./mocha_setup.js", 48 | "tdd": "karma start karma.conf.js", 49 | "clean": "rimraf dist && mkdirp dist", 50 | "copy": "cp ./dist/umd/i18nextFluent.min.js ./i18nextFluent.min.js && cp ./dist/umd/i18nextFluent.js ./i18nextFluent.js", 51 | "copy-win": "xcopy .\\dist\\umd\\i18nextFluent.min.js .\\i18nextFluent.min.js /y && xcopy .\\dist\\umd\\i18nextFluent.js .\\i18nextFluent.js /y", 52 | "build:es": "BABEL_ENV=jsnext babel src --out-dir dist/es", 53 | "build:es-win": "SET BABEL_ENV=jsnext babel src --out-dir dist/es", 54 | "build:cjs": "babel src --out-dir dist/commonjs", 55 | "build:umd": "rollup -c rollup.config.js --format umd && rollup -c rollup.config.js --format umd --uglify", 56 | "build:amd": "rollup -c rollup.config.js --format amd && rollup -c rollup.config.js --format umd --uglify", 57 | "build:iife": "rollup -c rollup.config.js --format iife && rollup -c rollup.config.js --format iife --uglify", 58 | "build": "npm run clean && npm run build:cjs && npm run build:es && npm run build:umd && npm run copy", 59 | "build-win": "npm run clean && npm run build:cjs && npm run build:es-win && npm run build:umd && npm run copy-win", 60 | "preversion": "npm run test && npm run build && git push", 61 | "postversion": "git push && git push --tags" 62 | }, 63 | "author": "Jan Mühlemann (https://github.com/jamuhl)", 64 | "license": "MIT", 65 | "lock": false 66 | } 67 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import nodeResolve from '@rollup/plugin-node-resolve'; 4 | import { terser } from "rollup-plugin-terser"; 5 | import { argv } from 'yargs'; 6 | 7 | const format = argv.format || argv.f || 'iife'; 8 | const compress = argv.uglify; 9 | 10 | const babelOptions = { 11 | babelrc: false 12 | }; 13 | 14 | const file = { 15 | amd: `dist/amd/i18nextFluent${compress ? '.min' : ''}.js`, 16 | umd: `dist/umd/i18nextFluent${compress ? '.min' : ''}.js`, 17 | iife: `dist/iife/i18nextFluent${compress ? '.min' : ''}.js` 18 | }[format]; 19 | 20 | export default { 21 | input: 'src/index.js', 22 | plugins: [ 23 | babel(babelOptions), 24 | nodeResolve({ jsnext: true, main: true }), 25 | commonjs({}) 26 | ].concat(compress ? terser() : []), 27 | //moduleId: 'i18nextXHRBackend', 28 | output: { 29 | name: 'i18nextFluent', 30 | format, 31 | file 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as utils from './utils.js'; 2 | import { FluentBundle } from '@fluent/bundle'; 3 | import { js2ftl } from 'fluent_conv'; 4 | 5 | function getDefaults() { 6 | return { 7 | bindI18nStore: true, 8 | fluentBundleOptions: { useIsolating: false }, 9 | }; 10 | } 11 | 12 | function nonBlank(line) { 13 | return !/^\s*$/.test(line); 14 | } 15 | 16 | function countIndent(line) { 17 | const [indent] = line.match(/^\s*/); 18 | return indent.length; 19 | } 20 | 21 | function ftl(code) { 22 | const lines = code.split("\n").filter(nonBlank); 23 | const indents = lines.map(countIndent); 24 | const common = Math.min(...indents); 25 | const indent = new RegExp(`^\\s{${common}}`); 26 | 27 | return lines.map( 28 | line => line.replace(indent, "") 29 | ).join("\n"); 30 | } 31 | 32 | class BundleStore { 33 | constructor(i18next, options) { 34 | this.i18next = i18next; 35 | this.options = options; 36 | this.bundles = {}; 37 | 38 | // this.createBundleFromI18next = this.createBundleFromI18next.bind(this); 39 | // this.createBundle = this.createBundle.bind(this); 40 | // this.bind = this.bind.bind(this); 41 | } 42 | 43 | createBundle(lng, ns, json) { 44 | const ftlStr = json ? js2ftl(json) : ""; 45 | const bundle = new FluentBundle(lng, this.options.fluentBundleOptions); 46 | const errors = bundle.addMessages(ftl(ftlStr)); 47 | 48 | utils.setPath(this.bundles, [lng, ns], bundle); 49 | } 50 | 51 | createBundleFromI18next(lng, ns) { 52 | this.createBundle(lng, ns, utils.getPath(this.i18next.store.data, [lng, ns])); 53 | } 54 | 55 | getBundle(lng, ns) { 56 | return utils.getPath(this.bundles, [lng, ns]); 57 | } 58 | 59 | bind() { 60 | this.i18next.store.on('added', (lng, ns) => { 61 | if (!this.i18next.isInitialized) return; 62 | this.createBundleFromI18next(lng, ns); 63 | }); 64 | 65 | this.i18next.on('initialized', () => { 66 | var lngs = this.i18next.languages || []; 67 | var preload = this.i18next.options.preload || []; 68 | 69 | lngs 70 | .filter((l) => !preload.includes(l)) 71 | .concat(preload) 72 | .forEach((lng) => { 73 | this.i18next.options.ns.forEach((ns) => { 74 | this.createBundleFromI18next(lng, ns); 75 | }); 76 | }); 77 | }); 78 | } 79 | } 80 | 81 | class Fluent { 82 | constructor(options) { 83 | this.type = 'i18nFormat'; 84 | this.handleAsObject = false; 85 | 86 | this.init(null, options); 87 | } 88 | 89 | init(i18next, options) { 90 | const i18nextOptions = 91 | (i18next && i18next.options && i18next.options.i18nFormat) || {}; 92 | this.options = utils.defaults(i18nextOptions, options, this.options || {}, getDefaults()); 93 | 94 | if (i18next) { 95 | this.store = new BundleStore(i18next, this.options); 96 | if (this.options.bindI18nStore) this.store.bind(); 97 | 98 | i18next.fluent = this; 99 | } else { 100 | this.store = new BundleStore(null, this.options); 101 | } 102 | } 103 | 104 | parse(res, options, lng, ns, key, info) { 105 | const bundle = this.store.getBundle(lng, ns); 106 | const isAttr = key.indexOf('.') > -1; 107 | 108 | if (!res) return key; 109 | 110 | const useRes = isAttr ? res.attrs[key.split('.')[1]] : res; 111 | if (!bundle) return key; 112 | return bundle.format(useRes, options); 113 | } 114 | 115 | getResource(lng, ns, key, options) { 116 | let bundle = this.store.getBundle(lng, ns); 117 | const useKey = key.indexOf('.') > -1 ? key.split('.')[0] : key; 118 | 119 | if (!bundle) return undefined; 120 | return bundle.getMessage(useKey); 121 | } 122 | 123 | addLookupKeys(finalKeys, key, code, ns, options) { 124 | // no additional keys needed for select or plural 125 | // so there is no need to add keys to that finalKeys array 126 | return finalKeys; 127 | } 128 | } 129 | 130 | Fluent.type = 'i18nFormat'; 131 | 132 | export default Fluent; 133 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | function getLastOfPath(object, path, Empty) { 2 | function cleanKey(key) { 3 | return (key && key.indexOf('###') > -1) ? key.replace(/###/g, '.') : key; 4 | } 5 | 6 | function canNotTraverseDeeper() { 7 | return !object || typeof object === 'string'; 8 | } 9 | 10 | const stack = (typeof path !== 'string') ? [].concat(path) : path.split('.'); 11 | while (stack.length > 1) { 12 | if (canNotTraverseDeeper()) return {}; 13 | 14 | const key = cleanKey(stack.shift()); 15 | if (!object[key] && Empty) object[key] = new Empty(); 16 | object = object[key]; 17 | } 18 | 19 | if (canNotTraverseDeeper()) return {}; 20 | return { 21 | obj: object, 22 | k: cleanKey(stack.shift()) 23 | }; 24 | } 25 | 26 | export function setPath(object, path, newValue) { 27 | const { obj, k } = getLastOfPath(object, path, Object); 28 | 29 | obj[k] = newValue; 30 | } 31 | 32 | export function pushPath(object, path, newValue, concat) { 33 | const { obj, k } = getLastOfPath(object, path, Object); 34 | 35 | obj[k] = obj[k] || []; 36 | if (concat) obj[k] = obj[k].concat(newValue); 37 | if (!concat) obj[k].push(newValue); 38 | } 39 | 40 | export function getPath(object, path) { 41 | const { obj, k } = getLastOfPath(object, path); 42 | 43 | if (!obj) return undefined; 44 | return obj[k]; 45 | } 46 | 47 | 48 | 49 | let arr = []; 50 | let each = arr.forEach; 51 | let slice = arr.slice; 52 | 53 | export function defaults(obj) { 54 | each.call(slice.call(arguments, 1), function(source) { 55 | if (source) { 56 | for (var prop in source) { 57 | if (obj[prop] === undefined) obj[prop] = source[prop]; 58 | } 59 | } 60 | }); 61 | return obj; 62 | } 63 | 64 | export function extend(obj) { 65 | each.call(slice.call(arguments, 1), function(source) { 66 | if (source) { 67 | for (var prop in source) { 68 | obj[prop] = source[prop]; 69 | } 70 | } 71 | }); 72 | return obj; 73 | } 74 | -------------------------------------------------------------------------------- /test/fuent.spec.js: -------------------------------------------------------------------------------- 1 | import Fluent from "../src/"; 2 | import i18next from "i18next"; 3 | 4 | import { FluentBundle, ftl } from "@fluent/bundle"; 5 | 6 | const testJSON = { 7 | emails: 8 | "{ $unreadEmails ->\n [0] You have no unread emails.\n [one] You have one unread email.\n *[other] You have { $unreadEmails } unread emails.\n}", 9 | "-brand-name": "{\n $case -> *[nominative] Firefox\n [accusative] Firefoxa\n}", 10 | "-another-term": "another term", 11 | "app-title": "{ -brand-name }", 12 | "restart-app": 'Zrestartuj { -brand-name(case: "accusative") }.', 13 | login: { 14 | comment: 15 | "Note: { $title } is a placeholder for the title of the web page\ncaptured in the screenshot. The default, for pages without titles, is\ncreating-page-title-default.", 16 | val: "Predefined value", 17 | placeholder: "example@email.com", 18 | "aria-label": "Login input value", 19 | title: "Type your login email" 20 | }, 21 | logout: "Logout", 22 | hello: "Hello { $name }." 23 | }; 24 | 25 | describe("fluent format", () => { 26 | describe("basic parse", () => { 27 | let fluent; 28 | 29 | before(() => { 30 | fluent = new Fluent({ 31 | bindI18nStore: false 32 | }); 33 | 34 | fluent.store.createBundle("en", "translations", testJSON); 35 | }); 36 | 37 | it("should parse", () => { 38 | const res0 = fluent.getResource("en", "translations", "emails"); 39 | expect( 40 | fluent.parse(res0, { unreadEmails: 10 }, "en", "translations", "emails") 41 | ).to.eql("You have 10 unread emails."); 42 | 43 | const res1 = fluent.getResource("en", "translations", "logout"); 44 | expect(fluent.parse(res1, {}, "en", "translations", "logout")).to.eql( 45 | "Logout" 46 | ); 47 | 48 | const res2 = fluent.getResource("en", "translations", "hello"); 49 | expect( 50 | fluent.parse(res2, { name: "Jan" }, "en", "translations", "hello") 51 | ).to.eql("Hello Jan."); 52 | 53 | const res3 = fluent.getResource("en", "translations", "restart-app"); 54 | expect( 55 | fluent.parse(res3, {}, "en", "translations", "restart-app") 56 | ).to.eql("Zrestartuj Firefoxa."); 57 | 58 | const res4 = fluent.getResource( 59 | "en", 60 | "translations", 61 | "login.placeholder" 62 | ); 63 | expect( 64 | fluent.parse(res4, {}, "en", "translations", "login.placeholder") 65 | ).to.eql("example@email.com"); 66 | }); 67 | }); 68 | 69 | describe("with i18next", () => { 70 | before(() => { 71 | i18next.use(Fluent).init({ 72 | lng: "en-CA", 73 | fallbackLng: "en", 74 | resources: { 75 | en: { 76 | translation: testJSON 77 | } 78 | } 79 | }); 80 | }); 81 | 82 | it("should parse", () => { 83 | expect(i18next.t("emails", { unreadEmails: 10 })).to.eql( 84 | "You have 10 unread emails." 85 | ); 86 | expect(i18next.t("emails", { unreadEmails: 0 })).to.eql( 87 | "You have no unread emails." 88 | ); 89 | expect(i18next.t("logout")).to.eql("Logout"); 90 | expect(i18next.t("hello", { name: "Jan" })).to.eql("Hello Jan."); 91 | expect(i18next.t("restart-app")).to.eql("Zrestartuj Firefoxa."); 92 | expect(i18next.t("login.placeholder")).to.eql("example@email.com"); 93 | }); 94 | }); 95 | }); 96 | --------------------------------------------------------------------------------