├── .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 | [](https://travis-ci.com/i18next/i18next-fluent)
4 | [](https://www.npmjs.com/package/i18next-fluent)
5 | [](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 |
--------------------------------------------------------------------------------