@vaadin-component-factory/vcf-pdf-viewer 4.0.2 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -2
- package/pdfjs/dist/event_utils.js +207 -0
- package/pdfjs/dist/genericl10n.js +2420 -0
- package/pdfjs/dist/message_handler.js +327 -192
- package/pdfjs/dist/node_stream.js +2 -494
- package/pdfjs/dist/node_stream2.js +1759 -0
- package/pdfjs/dist/pdf.js +17910 -9761
- package/pdfjs/dist/pdf.worker.js +24 -0
- package/pdfjs/dist/pdf_link_service.js +223 -378
- package/pdfjs/dist/pdf_rendering_queue.js +62 -62
- package/pdfjs/dist/pdf_thumbnail_viewer.js +216 -399
- package/pdfjs/dist/pdf_viewer.js +3450 -2305
- package/pdfjs/dist/ui_utils.js +209 -480
- package/pdfjs/dist/util.js +384 -595
- package/pdfjs/dist/worker.js +44555 -45401
- package/src/vcf-pdf-viewer.js +153 -16
- package/pdfjs/dist/display_utils.js +0 -848
- package/pdfjs/dist/fetch_stream.js +0 -306
- package/pdfjs/dist/l10n_utils.js +0 -140
- package/pdfjs/dist/network.js +0 -565
- package/pdfjs/dist/network_utils.js +0 -340
|
@@ -0,0 +1,2420 @@
|
|
|
1
|
+
import './pdf.js';
|
|
2
|
+
import { f as fetchData } from './node_stream2.js';
|
|
3
|
+
import './util.js';
|
|
4
|
+
import './message_handler.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `FluentType` class is the base of Fluent's type system.
|
|
8
|
+
*
|
|
9
|
+
* Fluent types wrap JavaScript values and store additional configuration for
|
|
10
|
+
* them, which can then be used in the `toString` method together with a proper
|
|
11
|
+
* `Intl` formatter.
|
|
12
|
+
*/
|
|
13
|
+
class FluentType {
|
|
14
|
+
/**
|
|
15
|
+
* Create a `FluentType` instance.
|
|
16
|
+
*
|
|
17
|
+
* @param value The JavaScript value to wrap.
|
|
18
|
+
*/
|
|
19
|
+
constructor(value) {
|
|
20
|
+
this.value = value;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Unwrap the raw value stored by this `FluentType`.
|
|
24
|
+
*/
|
|
25
|
+
valueOf() {
|
|
26
|
+
return this.value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A `FluentType` representing no correct value.
|
|
31
|
+
*/
|
|
32
|
+
class FluentNone extends FluentType {
|
|
33
|
+
/**
|
|
34
|
+
* Create an instance of `FluentNone` with an optional fallback value.
|
|
35
|
+
* @param value The fallback value of this `FluentNone`.
|
|
36
|
+
*/
|
|
37
|
+
constructor(value = "???") {
|
|
38
|
+
super(value);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Format this `FluentNone` to the fallback string.
|
|
42
|
+
*/
|
|
43
|
+
toString(scope) {
|
|
44
|
+
return `{${this.value}}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A `FluentType` representing a number.
|
|
49
|
+
*
|
|
50
|
+
* A `FluentNumber` instance stores the number value of the number it
|
|
51
|
+
* represents. It may also store an option bag of options which will be passed
|
|
52
|
+
* to `Intl.NumerFormat` when the `FluentNumber` is formatted to a string.
|
|
53
|
+
*/
|
|
54
|
+
class FluentNumber extends FluentType {
|
|
55
|
+
/**
|
|
56
|
+
* Create an instance of `FluentNumber` with options to the
|
|
57
|
+
* `Intl.NumberFormat` constructor.
|
|
58
|
+
*
|
|
59
|
+
* @param value The number value of this `FluentNumber`.
|
|
60
|
+
* @param opts Options which will be passed to `Intl.NumberFormat`.
|
|
61
|
+
*/
|
|
62
|
+
constructor(value, opts = {}) {
|
|
63
|
+
super(value);
|
|
64
|
+
this.opts = opts;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Format this `FluentNumber` to a string.
|
|
68
|
+
*/
|
|
69
|
+
toString(scope) {
|
|
70
|
+
try {
|
|
71
|
+
const nf = scope.memoizeIntlObject(Intl.NumberFormat, this.opts);
|
|
72
|
+
return nf.format(this.value);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
scope.reportError(err);
|
|
76
|
+
return this.value.toString(10);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A `FluentType` representing a date and time.
|
|
82
|
+
*
|
|
83
|
+
* A `FluentDateTime` instance stores the number value of the date it
|
|
84
|
+
* represents, as a numerical timestamp in milliseconds. It may also store an
|
|
85
|
+
* option bag of options which will be passed to `Intl.DateTimeFormat` when the
|
|
86
|
+
* `FluentDateTime` is formatted to a string.
|
|
87
|
+
*/
|
|
88
|
+
class FluentDateTime extends FluentType {
|
|
89
|
+
/**
|
|
90
|
+
* Create an instance of `FluentDateTime` with options to the
|
|
91
|
+
* `Intl.DateTimeFormat` constructor.
|
|
92
|
+
*
|
|
93
|
+
* @param value The number value of this `FluentDateTime`, in milliseconds.
|
|
94
|
+
* @param opts Options which will be passed to `Intl.DateTimeFormat`.
|
|
95
|
+
*/
|
|
96
|
+
constructor(value, opts = {}) {
|
|
97
|
+
super(value);
|
|
98
|
+
this.opts = opts;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Format this `FluentDateTime` to a string.
|
|
102
|
+
*/
|
|
103
|
+
toString(scope) {
|
|
104
|
+
try {
|
|
105
|
+
const dtf = scope.memoizeIntlObject(Intl.DateTimeFormat, this.opts);
|
|
106
|
+
return dtf.format(this.value);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
scope.reportError(err);
|
|
110
|
+
return new Date(this.value).toISOString();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* global Intl */
|
|
116
|
+
/**
|
|
117
|
+
* @overview
|
|
118
|
+
*
|
|
119
|
+
* The role of the Fluent resolver is to format a `Pattern` to an instance of
|
|
120
|
+
* `FluentValue`. For performance reasons, primitive strings are considered
|
|
121
|
+
* such instances, too.
|
|
122
|
+
*
|
|
123
|
+
* Translations can contain references to other messages or variables,
|
|
124
|
+
* conditional logic in form of select expressions, traits which describe their
|
|
125
|
+
* grammatical features, and can use Fluent builtins which make use of the
|
|
126
|
+
* `Intl` formatters to format numbers and dates into the bundle's languages.
|
|
127
|
+
* See the documentation of the Fluent syntax for more information.
|
|
128
|
+
*
|
|
129
|
+
* In case of errors the resolver will try to salvage as much of the
|
|
130
|
+
* translation as possible. In rare situations where the resolver didn't know
|
|
131
|
+
* how to recover from an error it will return an instance of `FluentNone`.
|
|
132
|
+
*
|
|
133
|
+
* All expressions resolve to an instance of `FluentValue`. The caller should
|
|
134
|
+
* use the `toString` method to convert the instance to a native value.
|
|
135
|
+
*
|
|
136
|
+
* Functions in this file pass around an instance of the `Scope` class, which
|
|
137
|
+
* stores the data required for successful resolution and error recovery.
|
|
138
|
+
*/
|
|
139
|
+
/**
|
|
140
|
+
* The maximum number of placeables which can be expanded in a single call to
|
|
141
|
+
* `formatPattern`. The limit protects against the Billion Laughs and Quadratic
|
|
142
|
+
* Blowup attacks. See https://msdn.microsoft.com/en-us/magazine/ee335713.aspx.
|
|
143
|
+
*/
|
|
144
|
+
const MAX_PLACEABLES = 100;
|
|
145
|
+
/** Unicode bidi isolation characters. */
|
|
146
|
+
const FSI = "\u2068";
|
|
147
|
+
const PDI = "\u2069";
|
|
148
|
+
/** Helper: match a variant key to the given selector. */
|
|
149
|
+
function match(scope, selector, key) {
|
|
150
|
+
if (key === selector) {
|
|
151
|
+
// Both are strings.
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
// XXX Consider comparing options too, e.g. minimumFractionDigits.
|
|
155
|
+
if (key instanceof FluentNumber &&
|
|
156
|
+
selector instanceof FluentNumber &&
|
|
157
|
+
key.value === selector.value) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
if (selector instanceof FluentNumber && typeof key === "string") {
|
|
161
|
+
let category = scope
|
|
162
|
+
.memoizeIntlObject(Intl.PluralRules, selector.opts)
|
|
163
|
+
.select(selector.value);
|
|
164
|
+
if (key === category) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
/** Helper: resolve the default variant from a list of variants. */
|
|
171
|
+
function getDefault(scope, variants, star) {
|
|
172
|
+
if (variants[star]) {
|
|
173
|
+
return resolvePattern(scope, variants[star].value);
|
|
174
|
+
}
|
|
175
|
+
scope.reportError(new RangeError("No default"));
|
|
176
|
+
return new FluentNone();
|
|
177
|
+
}
|
|
178
|
+
/** Helper: resolve arguments to a call expression. */
|
|
179
|
+
function getArguments(scope, args) {
|
|
180
|
+
const positional = [];
|
|
181
|
+
const named = Object.create(null);
|
|
182
|
+
for (const arg of args) {
|
|
183
|
+
if (arg.type === "narg") {
|
|
184
|
+
named[arg.name] = resolveExpression(scope, arg.value);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
positional.push(resolveExpression(scope, arg));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { positional, named };
|
|
191
|
+
}
|
|
192
|
+
/** Resolve an expression to a Fluent type. */
|
|
193
|
+
function resolveExpression(scope, expr) {
|
|
194
|
+
switch (expr.type) {
|
|
195
|
+
case "str":
|
|
196
|
+
return expr.value;
|
|
197
|
+
case "num":
|
|
198
|
+
return new FluentNumber(expr.value, {
|
|
199
|
+
minimumFractionDigits: expr.precision,
|
|
200
|
+
});
|
|
201
|
+
case "var":
|
|
202
|
+
return resolveVariableReference(scope, expr);
|
|
203
|
+
case "mesg":
|
|
204
|
+
return resolveMessageReference(scope, expr);
|
|
205
|
+
case "term":
|
|
206
|
+
return resolveTermReference(scope, expr);
|
|
207
|
+
case "func":
|
|
208
|
+
return resolveFunctionReference(scope, expr);
|
|
209
|
+
case "select":
|
|
210
|
+
return resolveSelectExpression(scope, expr);
|
|
211
|
+
default:
|
|
212
|
+
return new FluentNone();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Resolve a reference to a variable. */
|
|
216
|
+
function resolveVariableReference(scope, { name }) {
|
|
217
|
+
let arg;
|
|
218
|
+
if (scope.params) {
|
|
219
|
+
// We're inside a TermReference. It's OK to reference undefined parameters.
|
|
220
|
+
if (Object.prototype.hasOwnProperty.call(scope.params, name)) {
|
|
221
|
+
arg = scope.params[name];
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
return new FluentNone(`$${name}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else if (scope.args &&
|
|
228
|
+
Object.prototype.hasOwnProperty.call(scope.args, name)) {
|
|
229
|
+
// We're in the top-level Pattern or inside a MessageReference. Missing
|
|
230
|
+
// variables references produce ReferenceErrors.
|
|
231
|
+
arg = scope.args[name];
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
scope.reportError(new ReferenceError(`Unknown variable: $${name}`));
|
|
235
|
+
return new FluentNone(`$${name}`);
|
|
236
|
+
}
|
|
237
|
+
// Return early if the argument already is an instance of FluentType.
|
|
238
|
+
if (arg instanceof FluentType) {
|
|
239
|
+
return arg;
|
|
240
|
+
}
|
|
241
|
+
// Convert the argument to a Fluent type.
|
|
242
|
+
switch (typeof arg) {
|
|
243
|
+
case "string":
|
|
244
|
+
return arg;
|
|
245
|
+
case "number":
|
|
246
|
+
return new FluentNumber(arg);
|
|
247
|
+
case "object":
|
|
248
|
+
if (arg instanceof Date) {
|
|
249
|
+
return new FluentDateTime(arg.getTime());
|
|
250
|
+
}
|
|
251
|
+
// eslint-disable-next-line no-fallthrough
|
|
252
|
+
default:
|
|
253
|
+
scope.reportError(new TypeError(`Variable type not supported: $${name}, ${typeof arg}`));
|
|
254
|
+
return new FluentNone(`$${name}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** Resolve a reference to another message. */
|
|
258
|
+
function resolveMessageReference(scope, { name, attr }) {
|
|
259
|
+
const message = scope.bundle._messages.get(name);
|
|
260
|
+
if (!message) {
|
|
261
|
+
scope.reportError(new ReferenceError(`Unknown message: ${name}`));
|
|
262
|
+
return new FluentNone(name);
|
|
263
|
+
}
|
|
264
|
+
if (attr) {
|
|
265
|
+
const attribute = message.attributes[attr];
|
|
266
|
+
if (attribute) {
|
|
267
|
+
return resolvePattern(scope, attribute);
|
|
268
|
+
}
|
|
269
|
+
scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`));
|
|
270
|
+
return new FluentNone(`${name}.${attr}`);
|
|
271
|
+
}
|
|
272
|
+
if (message.value) {
|
|
273
|
+
return resolvePattern(scope, message.value);
|
|
274
|
+
}
|
|
275
|
+
scope.reportError(new ReferenceError(`No value: ${name}`));
|
|
276
|
+
return new FluentNone(name);
|
|
277
|
+
}
|
|
278
|
+
/** Resolve a call to a Term with key-value arguments. */
|
|
279
|
+
function resolveTermReference(scope, { name, attr, args }) {
|
|
280
|
+
const id = `-${name}`;
|
|
281
|
+
const term = scope.bundle._terms.get(id);
|
|
282
|
+
if (!term) {
|
|
283
|
+
scope.reportError(new ReferenceError(`Unknown term: ${id}`));
|
|
284
|
+
return new FluentNone(id);
|
|
285
|
+
}
|
|
286
|
+
if (attr) {
|
|
287
|
+
const attribute = term.attributes[attr];
|
|
288
|
+
if (attribute) {
|
|
289
|
+
// Every TermReference has its own variables.
|
|
290
|
+
scope.params = getArguments(scope, args).named;
|
|
291
|
+
const resolved = resolvePattern(scope, attribute);
|
|
292
|
+
scope.params = null;
|
|
293
|
+
return resolved;
|
|
294
|
+
}
|
|
295
|
+
scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`));
|
|
296
|
+
return new FluentNone(`${id}.${attr}`);
|
|
297
|
+
}
|
|
298
|
+
scope.params = getArguments(scope, args).named;
|
|
299
|
+
const resolved = resolvePattern(scope, term.value);
|
|
300
|
+
scope.params = null;
|
|
301
|
+
return resolved;
|
|
302
|
+
}
|
|
303
|
+
/** Resolve a call to a Function with positional and key-value arguments. */
|
|
304
|
+
function resolveFunctionReference(scope, { name, args }) {
|
|
305
|
+
// Some functions are built-in. Others may be provided by the runtime via
|
|
306
|
+
// the `FluentBundle` constructor.
|
|
307
|
+
let func = scope.bundle._functions[name];
|
|
308
|
+
if (!func) {
|
|
309
|
+
scope.reportError(new ReferenceError(`Unknown function: ${name}()`));
|
|
310
|
+
return new FluentNone(`${name}()`);
|
|
311
|
+
}
|
|
312
|
+
if (typeof func !== "function") {
|
|
313
|
+
scope.reportError(new TypeError(`Function ${name}() is not callable`));
|
|
314
|
+
return new FluentNone(`${name}()`);
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
let resolved = getArguments(scope, args);
|
|
318
|
+
return func(resolved.positional, resolved.named);
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
scope.reportError(err);
|
|
322
|
+
return new FluentNone(`${name}()`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** Resolve a select expression to the member object. */
|
|
326
|
+
function resolveSelectExpression(scope, { selector, variants, star }) {
|
|
327
|
+
let sel = resolveExpression(scope, selector);
|
|
328
|
+
if (sel instanceof FluentNone) {
|
|
329
|
+
return getDefault(scope, variants, star);
|
|
330
|
+
}
|
|
331
|
+
// Match the selector against keys of each variant, in order.
|
|
332
|
+
for (const variant of variants) {
|
|
333
|
+
const key = resolveExpression(scope, variant.key);
|
|
334
|
+
if (match(scope, sel, key)) {
|
|
335
|
+
return resolvePattern(scope, variant.value);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return getDefault(scope, variants, star);
|
|
339
|
+
}
|
|
340
|
+
/** Resolve a pattern (a complex string with placeables). */
|
|
341
|
+
function resolveComplexPattern(scope, ptn) {
|
|
342
|
+
if (scope.dirty.has(ptn)) {
|
|
343
|
+
scope.reportError(new RangeError("Cyclic reference"));
|
|
344
|
+
return new FluentNone();
|
|
345
|
+
}
|
|
346
|
+
// Tag the pattern as dirty for the purpose of the current resolution.
|
|
347
|
+
scope.dirty.add(ptn);
|
|
348
|
+
const result = [];
|
|
349
|
+
// Wrap interpolations with Directional Isolate Formatting characters
|
|
350
|
+
// only when the pattern has more than one element.
|
|
351
|
+
const useIsolating = scope.bundle._useIsolating && ptn.length > 1;
|
|
352
|
+
for (const elem of ptn) {
|
|
353
|
+
if (typeof elem === "string") {
|
|
354
|
+
result.push(scope.bundle._transform(elem));
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
scope.placeables++;
|
|
358
|
+
if (scope.placeables > MAX_PLACEABLES) {
|
|
359
|
+
scope.dirty.delete(ptn);
|
|
360
|
+
// This is a fatal error which causes the resolver to instantly bail out
|
|
361
|
+
// on this pattern. The length check protects against excessive memory
|
|
362
|
+
// usage, and throwing protects against eating up the CPU when long
|
|
363
|
+
// placeables are deeply nested.
|
|
364
|
+
throw new RangeError(`Too many placeables expanded: ${scope.placeables}, ` +
|
|
365
|
+
`max allowed is ${MAX_PLACEABLES}`);
|
|
366
|
+
}
|
|
367
|
+
if (useIsolating) {
|
|
368
|
+
result.push(FSI);
|
|
369
|
+
}
|
|
370
|
+
result.push(resolveExpression(scope, elem).toString(scope));
|
|
371
|
+
if (useIsolating) {
|
|
372
|
+
result.push(PDI);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
scope.dirty.delete(ptn);
|
|
376
|
+
return result.join("");
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Resolve a simple or a complex Pattern to a FluentString
|
|
380
|
+
* (which is really the string primitive).
|
|
381
|
+
*/
|
|
382
|
+
function resolvePattern(scope, value) {
|
|
383
|
+
// Resolve a simple pattern.
|
|
384
|
+
if (typeof value === "string") {
|
|
385
|
+
return scope.bundle._transform(value);
|
|
386
|
+
}
|
|
387
|
+
return resolveComplexPattern(scope, value);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
class Scope {
|
|
391
|
+
constructor(bundle, errors, args) {
|
|
392
|
+
/**
|
|
393
|
+
* The Set of patterns already encountered during this resolution.
|
|
394
|
+
* Used to detect and prevent cyclic resolutions.
|
|
395
|
+
* @ignore
|
|
396
|
+
*/
|
|
397
|
+
this.dirty = new WeakSet();
|
|
398
|
+
/** A dict of parameters passed to a TermReference. */
|
|
399
|
+
this.params = null;
|
|
400
|
+
/**
|
|
401
|
+
* The running count of placeables resolved so far.
|
|
402
|
+
* Used to detect the Billion Laughs and Quadratic Blowup attacks.
|
|
403
|
+
* @ignore
|
|
404
|
+
*/
|
|
405
|
+
this.placeables = 0;
|
|
406
|
+
this.bundle = bundle;
|
|
407
|
+
this.errors = errors;
|
|
408
|
+
this.args = args;
|
|
409
|
+
}
|
|
410
|
+
reportError(error) {
|
|
411
|
+
if (!this.errors || !(error instanceof Error)) {
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
this.errors.push(error);
|
|
415
|
+
}
|
|
416
|
+
memoizeIntlObject(ctor, opts) {
|
|
417
|
+
let cache = this.bundle._intls.get(ctor);
|
|
418
|
+
if (!cache) {
|
|
419
|
+
cache = {};
|
|
420
|
+
this.bundle._intls.set(ctor, cache);
|
|
421
|
+
}
|
|
422
|
+
let id = JSON.stringify(opts);
|
|
423
|
+
if (!cache[id]) {
|
|
424
|
+
cache[id] = new ctor(this.bundle.locales, opts);
|
|
425
|
+
}
|
|
426
|
+
return cache[id];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* @overview
|
|
432
|
+
*
|
|
433
|
+
* The FTL resolver ships with a number of functions built-in.
|
|
434
|
+
*
|
|
435
|
+
* Each function take two arguments:
|
|
436
|
+
* - args - an array of positional args
|
|
437
|
+
* - opts - an object of key-value args
|
|
438
|
+
*
|
|
439
|
+
* Arguments to functions are guaranteed to already be instances of
|
|
440
|
+
* `FluentValue`. Functions must return `FluentValues` as well.
|
|
441
|
+
*/
|
|
442
|
+
function values(opts, allowed) {
|
|
443
|
+
const unwrapped = Object.create(null);
|
|
444
|
+
for (const [name, opt] of Object.entries(opts)) {
|
|
445
|
+
if (allowed.includes(name)) {
|
|
446
|
+
unwrapped[name] = opt.valueOf();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return unwrapped;
|
|
450
|
+
}
|
|
451
|
+
const NUMBER_ALLOWED = [
|
|
452
|
+
"unitDisplay",
|
|
453
|
+
"currencyDisplay",
|
|
454
|
+
"useGrouping",
|
|
455
|
+
"minimumIntegerDigits",
|
|
456
|
+
"minimumFractionDigits",
|
|
457
|
+
"maximumFractionDigits",
|
|
458
|
+
"minimumSignificantDigits",
|
|
459
|
+
"maximumSignificantDigits",
|
|
460
|
+
];
|
|
461
|
+
/**
|
|
462
|
+
* The implementation of the `NUMBER()` builtin available to translations.
|
|
463
|
+
*
|
|
464
|
+
* Translations may call the `NUMBER()` builtin in order to specify formatting
|
|
465
|
+
* options of a number. For example:
|
|
466
|
+
*
|
|
467
|
+
* pi = The value of π is {NUMBER($pi, maximumFractionDigits: 2)}.
|
|
468
|
+
*
|
|
469
|
+
* The implementation expects an array of `FluentValues` representing the
|
|
470
|
+
* positional arguments, and an object of named `FluentValues` representing the
|
|
471
|
+
* named parameters.
|
|
472
|
+
*
|
|
473
|
+
* The following options are recognized:
|
|
474
|
+
*
|
|
475
|
+
* unitDisplay
|
|
476
|
+
* currencyDisplay
|
|
477
|
+
* useGrouping
|
|
478
|
+
* minimumIntegerDigits
|
|
479
|
+
* minimumFractionDigits
|
|
480
|
+
* maximumFractionDigits
|
|
481
|
+
* minimumSignificantDigits
|
|
482
|
+
* maximumSignificantDigits
|
|
483
|
+
*
|
|
484
|
+
* Other options are ignored.
|
|
485
|
+
*
|
|
486
|
+
* @param args The positional arguments passed to this `NUMBER()`.
|
|
487
|
+
* @param opts The named argments passed to this `NUMBER()`.
|
|
488
|
+
*/
|
|
489
|
+
function NUMBER(args, opts) {
|
|
490
|
+
let arg = args[0];
|
|
491
|
+
if (arg instanceof FluentNone) {
|
|
492
|
+
return new FluentNone(`NUMBER(${arg.valueOf()})`);
|
|
493
|
+
}
|
|
494
|
+
if (arg instanceof FluentNumber) {
|
|
495
|
+
return new FluentNumber(arg.valueOf(), {
|
|
496
|
+
...arg.opts,
|
|
497
|
+
...values(opts, NUMBER_ALLOWED),
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
if (arg instanceof FluentDateTime) {
|
|
501
|
+
return new FluentNumber(arg.valueOf(), {
|
|
502
|
+
...values(opts, NUMBER_ALLOWED),
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
throw new TypeError("Invalid argument to NUMBER");
|
|
506
|
+
}
|
|
507
|
+
const DATETIME_ALLOWED = [
|
|
508
|
+
"dateStyle",
|
|
509
|
+
"timeStyle",
|
|
510
|
+
"fractionalSecondDigits",
|
|
511
|
+
"dayPeriod",
|
|
512
|
+
"hour12",
|
|
513
|
+
"weekday",
|
|
514
|
+
"era",
|
|
515
|
+
"year",
|
|
516
|
+
"month",
|
|
517
|
+
"day",
|
|
518
|
+
"hour",
|
|
519
|
+
"minute",
|
|
520
|
+
"second",
|
|
521
|
+
"timeZoneName",
|
|
522
|
+
];
|
|
523
|
+
/**
|
|
524
|
+
* The implementation of the `DATETIME()` builtin available to translations.
|
|
525
|
+
*
|
|
526
|
+
* Translations may call the `DATETIME()` builtin in order to specify
|
|
527
|
+
* formatting options of a number. For example:
|
|
528
|
+
*
|
|
529
|
+
* now = It's {DATETIME($today, month: "long")}.
|
|
530
|
+
*
|
|
531
|
+
* The implementation expects an array of `FluentValues` representing the
|
|
532
|
+
* positional arguments, and an object of named `FluentValues` representing the
|
|
533
|
+
* named parameters.
|
|
534
|
+
*
|
|
535
|
+
* The following options are recognized:
|
|
536
|
+
*
|
|
537
|
+
* dateStyle
|
|
538
|
+
* timeStyle
|
|
539
|
+
* fractionalSecondDigits
|
|
540
|
+
* dayPeriod
|
|
541
|
+
* hour12
|
|
542
|
+
* weekday
|
|
543
|
+
* era
|
|
544
|
+
* year
|
|
545
|
+
* month
|
|
546
|
+
* day
|
|
547
|
+
* hour
|
|
548
|
+
* minute
|
|
549
|
+
* second
|
|
550
|
+
* timeZoneName
|
|
551
|
+
*
|
|
552
|
+
* Other options are ignored.
|
|
553
|
+
*
|
|
554
|
+
* @param args The positional arguments passed to this `DATETIME()`.
|
|
555
|
+
* @param opts The named argments passed to this `DATETIME()`.
|
|
556
|
+
*/
|
|
557
|
+
function DATETIME(args, opts) {
|
|
558
|
+
let arg = args[0];
|
|
559
|
+
if (arg instanceof FluentNone) {
|
|
560
|
+
return new FluentNone(`DATETIME(${arg.valueOf()})`);
|
|
561
|
+
}
|
|
562
|
+
if (arg instanceof FluentDateTime) {
|
|
563
|
+
return new FluentDateTime(arg.valueOf(), {
|
|
564
|
+
...arg.opts,
|
|
565
|
+
...values(opts, DATETIME_ALLOWED),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
if (arg instanceof FluentNumber) {
|
|
569
|
+
return new FluentDateTime(arg.valueOf(), {
|
|
570
|
+
...values(opts, DATETIME_ALLOWED),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
throw new TypeError("Invalid argument to DATETIME");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const cache = new Map();
|
|
577
|
+
function getMemoizerForLocale(locales) {
|
|
578
|
+
const stringLocale = Array.isArray(locales) ? locales.join(" ") : locales;
|
|
579
|
+
let memoizer = cache.get(stringLocale);
|
|
580
|
+
if (memoizer === undefined) {
|
|
581
|
+
memoizer = new Map();
|
|
582
|
+
cache.set(stringLocale, memoizer);
|
|
583
|
+
}
|
|
584
|
+
return memoizer;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Message bundles are single-language stores of translation resources. They are
|
|
589
|
+
* responsible for formatting message values and attributes to strings.
|
|
590
|
+
*/
|
|
591
|
+
class FluentBundle {
|
|
592
|
+
/**
|
|
593
|
+
* Create an instance of `FluentBundle`.
|
|
594
|
+
*
|
|
595
|
+
* @example
|
|
596
|
+
* ```js
|
|
597
|
+
* let bundle = new FluentBundle(["en-US", "en"]);
|
|
598
|
+
*
|
|
599
|
+
* let bundle = new FluentBundle(locales, {useIsolating: false});
|
|
600
|
+
*
|
|
601
|
+
* let bundle = new FluentBundle(locales, {
|
|
602
|
+
* useIsolating: true,
|
|
603
|
+
* functions: {
|
|
604
|
+
* NODE_ENV: () => process.env.NODE_ENV
|
|
605
|
+
* }
|
|
606
|
+
* });
|
|
607
|
+
* ```
|
|
608
|
+
*
|
|
609
|
+
* @param locales - Used to instantiate `Intl` formatters used by translations.
|
|
610
|
+
* @param options - Optional configuration for the bundle.
|
|
611
|
+
*/
|
|
612
|
+
constructor(locales, { functions, useIsolating = true, transform = (v) => v, } = {}) {
|
|
613
|
+
/** @ignore */
|
|
614
|
+
this._terms = new Map();
|
|
615
|
+
/** @ignore */
|
|
616
|
+
this._messages = new Map();
|
|
617
|
+
this.locales = Array.isArray(locales) ? locales : [locales];
|
|
618
|
+
this._functions = {
|
|
619
|
+
NUMBER,
|
|
620
|
+
DATETIME,
|
|
621
|
+
...functions,
|
|
622
|
+
};
|
|
623
|
+
this._useIsolating = useIsolating;
|
|
624
|
+
this._transform = transform;
|
|
625
|
+
this._intls = getMemoizerForLocale(locales);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Check if a message is present in the bundle.
|
|
629
|
+
*
|
|
630
|
+
* @param id - The identifier of the message to check.
|
|
631
|
+
*/
|
|
632
|
+
hasMessage(id) {
|
|
633
|
+
return this._messages.has(id);
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Return a raw unformatted message object from the bundle.
|
|
637
|
+
*
|
|
638
|
+
* Raw messages are `{value, attributes}` shapes containing translation units
|
|
639
|
+
* called `Patterns`. `Patterns` are implementation-specific; they should be
|
|
640
|
+
* treated as black boxes and formatted with `FluentBundle.formatPattern`.
|
|
641
|
+
*
|
|
642
|
+
* @param id - The identifier of the message to check.
|
|
643
|
+
*/
|
|
644
|
+
getMessage(id) {
|
|
645
|
+
return this._messages.get(id);
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Add a translation resource to the bundle.
|
|
649
|
+
*
|
|
650
|
+
* @example
|
|
651
|
+
* ```js
|
|
652
|
+
* let res = new FluentResource("foo = Foo");
|
|
653
|
+
* bundle.addResource(res);
|
|
654
|
+
* bundle.getMessage("foo");
|
|
655
|
+
* // → {value: .., attributes: {..}}
|
|
656
|
+
* ```
|
|
657
|
+
*
|
|
658
|
+
* @param res
|
|
659
|
+
* @param options
|
|
660
|
+
*/
|
|
661
|
+
addResource(res, { allowOverrides = false, } = {}) {
|
|
662
|
+
const errors = [];
|
|
663
|
+
for (let i = 0; i < res.body.length; i++) {
|
|
664
|
+
let entry = res.body[i];
|
|
665
|
+
if (entry.id.startsWith("-")) {
|
|
666
|
+
// Identifiers starting with a dash (-) define terms. Terms are private
|
|
667
|
+
// and cannot be retrieved from FluentBundle.
|
|
668
|
+
if (allowOverrides === false && this._terms.has(entry.id)) {
|
|
669
|
+
errors.push(new Error(`Attempt to override an existing term: "${entry.id}"`));
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
this._terms.set(entry.id, entry);
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
if (allowOverrides === false && this._messages.has(entry.id)) {
|
|
676
|
+
errors.push(new Error(`Attempt to override an existing message: "${entry.id}"`));
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
this._messages.set(entry.id, entry);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return errors;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Format a `Pattern` to a string.
|
|
686
|
+
*
|
|
687
|
+
* Format a raw `Pattern` into a string. `args` will be used to resolve
|
|
688
|
+
* references to variables passed as arguments to the translation.
|
|
689
|
+
*
|
|
690
|
+
* In case of errors `formatPattern` will try to salvage as much of the
|
|
691
|
+
* translation as possible and will still return a string. For performance
|
|
692
|
+
* reasons, the encountered errors are not returned but instead are appended
|
|
693
|
+
* to the `errors` array passed as the third argument.
|
|
694
|
+
*
|
|
695
|
+
* If `errors` is omitted, the first encountered error will be thrown.
|
|
696
|
+
*
|
|
697
|
+
* @example
|
|
698
|
+
* ```js
|
|
699
|
+
* let errors = [];
|
|
700
|
+
* bundle.addResource(
|
|
701
|
+
* new FluentResource("hello = Hello, {$name}!"));
|
|
702
|
+
*
|
|
703
|
+
* let hello = bundle.getMessage("hello");
|
|
704
|
+
* if (hello.value) {
|
|
705
|
+
* bundle.formatPattern(hello.value, {name: "Jane"}, errors);
|
|
706
|
+
* // Returns "Hello, Jane!" and `errors` is empty.
|
|
707
|
+
*
|
|
708
|
+
* bundle.formatPattern(hello.value, undefined, errors);
|
|
709
|
+
* // Returns "Hello, {$name}!" and `errors` is now:
|
|
710
|
+
* // [<ReferenceError: Unknown variable: name>]
|
|
711
|
+
* }
|
|
712
|
+
* ```
|
|
713
|
+
*/
|
|
714
|
+
formatPattern(pattern, args = null, errors = null) {
|
|
715
|
+
// Resolve a simple pattern without creating a scope. No error handling is
|
|
716
|
+
// required; by definition simple patterns don't have placeables.
|
|
717
|
+
if (typeof pattern === "string") {
|
|
718
|
+
return this._transform(pattern);
|
|
719
|
+
}
|
|
720
|
+
// Resolve a complex pattern.
|
|
721
|
+
let scope = new Scope(this, errors, args);
|
|
722
|
+
try {
|
|
723
|
+
let value = resolveComplexPattern(scope, pattern);
|
|
724
|
+
return value.toString(scope);
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
if (scope.errors && err instanceof Error) {
|
|
728
|
+
scope.errors.push(err);
|
|
729
|
+
return new FluentNone().toString(scope);
|
|
730
|
+
}
|
|
731
|
+
throw err;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// This regex is used to iterate through the beginnings of messages and terms.
|
|
737
|
+
// With the /m flag, the ^ matches at the beginning of every line.
|
|
738
|
+
const RE_MESSAGE_START = /^(-?[a-zA-Z][\w-]*) *= */gm;
|
|
739
|
+
// Both Attributes and Variants are parsed in while loops. These regexes are
|
|
740
|
+
// used to break out of them.
|
|
741
|
+
const RE_ATTRIBUTE_START = /\.([a-zA-Z][\w-]*) *= */y;
|
|
742
|
+
const RE_VARIANT_START = /\*?\[/y;
|
|
743
|
+
const RE_NUMBER_LITERAL = /(-?[0-9]+(?:\.([0-9]+))?)/y;
|
|
744
|
+
const RE_IDENTIFIER = /([a-zA-Z][\w-]*)/y;
|
|
745
|
+
const RE_REFERENCE = /([$-])?([a-zA-Z][\w-]*)(?:\.([a-zA-Z][\w-]*))?/y;
|
|
746
|
+
const RE_FUNCTION_NAME = /^[A-Z][A-Z0-9_-]*$/;
|
|
747
|
+
// A "run" is a sequence of text or string literal characters which don't
|
|
748
|
+
// require any special handling. For TextElements such special characters are: {
|
|
749
|
+
// (starts a placeable), and line breaks which require additional logic to check
|
|
750
|
+
// if the next line is indented. For StringLiterals they are: \ (starts an
|
|
751
|
+
// escape sequence), " (ends the literal), and line breaks which are not allowed
|
|
752
|
+
// in StringLiterals. Note that string runs may be empty; text runs may not.
|
|
753
|
+
const RE_TEXT_RUN = /([^{}\n\r]+)/y;
|
|
754
|
+
const RE_STRING_RUN = /([^\\"\n\r]*)/y;
|
|
755
|
+
// Escape sequences.
|
|
756
|
+
const RE_STRING_ESCAPE = /\\([\\"])/y;
|
|
757
|
+
const RE_UNICODE_ESCAPE = /\\u([a-fA-F0-9]{4})|\\U([a-fA-F0-9]{6})/y;
|
|
758
|
+
// Used for trimming TextElements and indents.
|
|
759
|
+
const RE_LEADING_NEWLINES = /^\n+/;
|
|
760
|
+
const RE_TRAILING_SPACES = / +$/;
|
|
761
|
+
// Used in makeIndent to strip spaces from blank lines and normalize CRLF to LF.
|
|
762
|
+
const RE_BLANK_LINES = / *\r?\n/g;
|
|
763
|
+
// Used in makeIndent to measure the indentation.
|
|
764
|
+
const RE_INDENT = /( *)$/;
|
|
765
|
+
// Common tokens.
|
|
766
|
+
const TOKEN_BRACE_OPEN = /{\s*/y;
|
|
767
|
+
const TOKEN_BRACE_CLOSE = /\s*}/y;
|
|
768
|
+
const TOKEN_BRACKET_OPEN = /\[\s*/y;
|
|
769
|
+
const TOKEN_BRACKET_CLOSE = /\s*] */y;
|
|
770
|
+
const TOKEN_PAREN_OPEN = /\s*\(\s*/y;
|
|
771
|
+
const TOKEN_ARROW = /\s*->\s*/y;
|
|
772
|
+
const TOKEN_COLON = /\s*:\s*/y;
|
|
773
|
+
// Note the optional comma. As a deviation from the Fluent EBNF, the parser
|
|
774
|
+
// doesn't enforce commas between call arguments.
|
|
775
|
+
const TOKEN_COMMA = /\s*,?\s*/y;
|
|
776
|
+
const TOKEN_BLANK = /\s+/y;
|
|
777
|
+
/**
|
|
778
|
+
* Fluent Resource is a structure storing parsed localization entries.
|
|
779
|
+
*/
|
|
780
|
+
class FluentResource {
|
|
781
|
+
constructor(source) {
|
|
782
|
+
this.body = [];
|
|
783
|
+
RE_MESSAGE_START.lastIndex = 0;
|
|
784
|
+
let cursor = 0;
|
|
785
|
+
// Iterate over the beginnings of messages and terms to efficiently skip
|
|
786
|
+
// comments and recover from errors.
|
|
787
|
+
while (true) {
|
|
788
|
+
let next = RE_MESSAGE_START.exec(source);
|
|
789
|
+
if (next === null) {
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
cursor = RE_MESSAGE_START.lastIndex;
|
|
793
|
+
try {
|
|
794
|
+
this.body.push(parseMessage(next[1]));
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
if (err instanceof SyntaxError) {
|
|
798
|
+
// Don't report any Fluent syntax errors. Skip directly to the
|
|
799
|
+
// beginning of the next message or term.
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
throw err;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
// The parser implementation is inlined below for performance reasons,
|
|
806
|
+
// as well as for convenience of accessing `source` and `cursor`.
|
|
807
|
+
// The parser focuses on minimizing the number of false negatives at the
|
|
808
|
+
// expense of increasing the risk of false positives. In other words, it
|
|
809
|
+
// aims at parsing valid Fluent messages with a success rate of 100%, but it
|
|
810
|
+
// may also parse a few invalid messages which the reference parser would
|
|
811
|
+
// reject. The parser doesn't perform any validation and may produce entries
|
|
812
|
+
// which wouldn't make sense in the real world. For best results users are
|
|
813
|
+
// advised to validate translations with the fluent-syntax parser
|
|
814
|
+
// pre-runtime.
|
|
815
|
+
// The parser makes an extensive use of sticky regexes which can be anchored
|
|
816
|
+
// to any offset of the source string without slicing it. Errors are thrown
|
|
817
|
+
// to bail out of parsing of ill-formed messages.
|
|
818
|
+
function test(re) {
|
|
819
|
+
re.lastIndex = cursor;
|
|
820
|
+
return re.test(source);
|
|
821
|
+
}
|
|
822
|
+
// Advance the cursor by the char if it matches. May be used as a predicate
|
|
823
|
+
// (was the match found?) or, if errorClass is passed, as an assertion.
|
|
824
|
+
function consumeChar(char, errorClass) {
|
|
825
|
+
if (source[cursor] === char) {
|
|
826
|
+
cursor++;
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
if (errorClass) {
|
|
830
|
+
throw new errorClass(`Expected ${char}`);
|
|
831
|
+
}
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
// Advance the cursor by the token if it matches. May be used as a predicate
|
|
835
|
+
// (was the match found?) or, if errorClass is passed, as an assertion.
|
|
836
|
+
function consumeToken(re, errorClass) {
|
|
837
|
+
if (test(re)) {
|
|
838
|
+
cursor = re.lastIndex;
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
if (errorClass) {
|
|
842
|
+
throw new errorClass(`Expected ${re.toString()}`);
|
|
843
|
+
}
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
// Execute a regex, advance the cursor, and return all capture groups.
|
|
847
|
+
function match(re) {
|
|
848
|
+
re.lastIndex = cursor;
|
|
849
|
+
let result = re.exec(source);
|
|
850
|
+
if (result === null) {
|
|
851
|
+
throw new SyntaxError(`Expected ${re.toString()}`);
|
|
852
|
+
}
|
|
853
|
+
cursor = re.lastIndex;
|
|
854
|
+
return result;
|
|
855
|
+
}
|
|
856
|
+
// Execute a regex, advance the cursor, and return the capture group.
|
|
857
|
+
function match1(re) {
|
|
858
|
+
return match(re)[1];
|
|
859
|
+
}
|
|
860
|
+
function parseMessage(id) {
|
|
861
|
+
let value = parsePattern();
|
|
862
|
+
let attributes = parseAttributes();
|
|
863
|
+
if (value === null && Object.keys(attributes).length === 0) {
|
|
864
|
+
throw new SyntaxError("Expected message value or attributes");
|
|
865
|
+
}
|
|
866
|
+
return { id, value, attributes };
|
|
867
|
+
}
|
|
868
|
+
function parseAttributes() {
|
|
869
|
+
let attrs = Object.create(null);
|
|
870
|
+
while (test(RE_ATTRIBUTE_START)) {
|
|
871
|
+
let name = match1(RE_ATTRIBUTE_START);
|
|
872
|
+
let value = parsePattern();
|
|
873
|
+
if (value === null) {
|
|
874
|
+
throw new SyntaxError("Expected attribute value");
|
|
875
|
+
}
|
|
876
|
+
attrs[name] = value;
|
|
877
|
+
}
|
|
878
|
+
return attrs;
|
|
879
|
+
}
|
|
880
|
+
function parsePattern() {
|
|
881
|
+
let first;
|
|
882
|
+
// First try to parse any simple text on the same line as the id.
|
|
883
|
+
if (test(RE_TEXT_RUN)) {
|
|
884
|
+
first = match1(RE_TEXT_RUN);
|
|
885
|
+
}
|
|
886
|
+
// If there's a placeable on the first line, parse a complex pattern.
|
|
887
|
+
if (source[cursor] === "{" || source[cursor] === "}") {
|
|
888
|
+
// Re-use the text parsed above, if possible.
|
|
889
|
+
return parsePatternElements(first ? [first] : [], Infinity);
|
|
890
|
+
}
|
|
891
|
+
// RE_TEXT_VALUE stops at newlines. Only continue parsing the pattern if
|
|
892
|
+
// what comes after the newline is indented.
|
|
893
|
+
let indent = parseIndent();
|
|
894
|
+
if (indent) {
|
|
895
|
+
if (first) {
|
|
896
|
+
// If there's text on the first line, the blank block is part of the
|
|
897
|
+
// translation content in its entirety.
|
|
898
|
+
return parsePatternElements([first, indent], indent.length);
|
|
899
|
+
}
|
|
900
|
+
// Otherwise, we're dealing with a block pattern, i.e. a pattern which
|
|
901
|
+
// starts on a new line. Discrad the leading newlines but keep the
|
|
902
|
+
// inline indent; it will be used by the dedentation logic.
|
|
903
|
+
indent.value = trim(indent.value, RE_LEADING_NEWLINES);
|
|
904
|
+
return parsePatternElements([indent], indent.length);
|
|
905
|
+
}
|
|
906
|
+
if (first) {
|
|
907
|
+
// It was just a simple inline text after all.
|
|
908
|
+
return trim(first, RE_TRAILING_SPACES);
|
|
909
|
+
}
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
// Parse a complex pattern as an array of elements.
|
|
913
|
+
function parsePatternElements(elements = [], commonIndent) {
|
|
914
|
+
while (true) {
|
|
915
|
+
if (test(RE_TEXT_RUN)) {
|
|
916
|
+
elements.push(match1(RE_TEXT_RUN));
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
if (source[cursor] === "{") {
|
|
920
|
+
elements.push(parsePlaceable());
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (source[cursor] === "}") {
|
|
924
|
+
throw new SyntaxError("Unbalanced closing brace");
|
|
925
|
+
}
|
|
926
|
+
let indent = parseIndent();
|
|
927
|
+
if (indent) {
|
|
928
|
+
elements.push(indent);
|
|
929
|
+
commonIndent = Math.min(commonIndent, indent.length);
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
let lastIndex = elements.length - 1;
|
|
935
|
+
let lastElement = elements[lastIndex];
|
|
936
|
+
// Trim the trailing spaces in the last element if it's a TextElement.
|
|
937
|
+
if (typeof lastElement === "string") {
|
|
938
|
+
elements[lastIndex] = trim(lastElement, RE_TRAILING_SPACES);
|
|
939
|
+
}
|
|
940
|
+
let baked = [];
|
|
941
|
+
for (let element of elements) {
|
|
942
|
+
if (element instanceof Indent) {
|
|
943
|
+
// Dedent indented lines by the maximum common indent.
|
|
944
|
+
element = element.value.slice(0, element.value.length - commonIndent);
|
|
945
|
+
}
|
|
946
|
+
if (element) {
|
|
947
|
+
baked.push(element);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
return baked;
|
|
951
|
+
}
|
|
952
|
+
function parsePlaceable() {
|
|
953
|
+
consumeToken(TOKEN_BRACE_OPEN, SyntaxError);
|
|
954
|
+
let selector = parseInlineExpression();
|
|
955
|
+
if (consumeToken(TOKEN_BRACE_CLOSE)) {
|
|
956
|
+
return selector;
|
|
957
|
+
}
|
|
958
|
+
if (consumeToken(TOKEN_ARROW)) {
|
|
959
|
+
let variants = parseVariants();
|
|
960
|
+
consumeToken(TOKEN_BRACE_CLOSE, SyntaxError);
|
|
961
|
+
return {
|
|
962
|
+
type: "select",
|
|
963
|
+
selector,
|
|
964
|
+
...variants,
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
throw new SyntaxError("Unclosed placeable");
|
|
968
|
+
}
|
|
969
|
+
function parseInlineExpression() {
|
|
970
|
+
if (source[cursor] === "{") {
|
|
971
|
+
// It's a nested placeable.
|
|
972
|
+
return parsePlaceable();
|
|
973
|
+
}
|
|
974
|
+
if (test(RE_REFERENCE)) {
|
|
975
|
+
let [, sigil, name, attr = null] = match(RE_REFERENCE);
|
|
976
|
+
if (sigil === "$") {
|
|
977
|
+
return { type: "var", name };
|
|
978
|
+
}
|
|
979
|
+
if (consumeToken(TOKEN_PAREN_OPEN)) {
|
|
980
|
+
let args = parseArguments();
|
|
981
|
+
if (sigil === "-") {
|
|
982
|
+
// A parameterized term: -term(...).
|
|
983
|
+
return { type: "term", name, attr, args };
|
|
984
|
+
}
|
|
985
|
+
if (RE_FUNCTION_NAME.test(name)) {
|
|
986
|
+
return { type: "func", name, args };
|
|
987
|
+
}
|
|
988
|
+
throw new SyntaxError("Function names must be all upper-case");
|
|
989
|
+
}
|
|
990
|
+
if (sigil === "-") {
|
|
991
|
+
// A non-parameterized term: -term.
|
|
992
|
+
return {
|
|
993
|
+
type: "term",
|
|
994
|
+
name,
|
|
995
|
+
attr,
|
|
996
|
+
args: [],
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
return { type: "mesg", name, attr };
|
|
1000
|
+
}
|
|
1001
|
+
return parseLiteral();
|
|
1002
|
+
}
|
|
1003
|
+
function parseArguments() {
|
|
1004
|
+
let args = [];
|
|
1005
|
+
while (true) {
|
|
1006
|
+
switch (source[cursor]) {
|
|
1007
|
+
case ")": // End of the argument list.
|
|
1008
|
+
cursor++;
|
|
1009
|
+
return args;
|
|
1010
|
+
case undefined: // EOF
|
|
1011
|
+
throw new SyntaxError("Unclosed argument list");
|
|
1012
|
+
}
|
|
1013
|
+
args.push(parseArgument());
|
|
1014
|
+
// Commas between arguments are treated as whitespace.
|
|
1015
|
+
consumeToken(TOKEN_COMMA);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
function parseArgument() {
|
|
1019
|
+
let expr = parseInlineExpression();
|
|
1020
|
+
if (expr.type !== "mesg") {
|
|
1021
|
+
return expr;
|
|
1022
|
+
}
|
|
1023
|
+
if (consumeToken(TOKEN_COLON)) {
|
|
1024
|
+
// The reference is the beginning of a named argument.
|
|
1025
|
+
return {
|
|
1026
|
+
type: "narg",
|
|
1027
|
+
name: expr.name,
|
|
1028
|
+
value: parseLiteral(),
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
// It's a regular message reference.
|
|
1032
|
+
return expr;
|
|
1033
|
+
}
|
|
1034
|
+
function parseVariants() {
|
|
1035
|
+
let variants = [];
|
|
1036
|
+
let count = 0;
|
|
1037
|
+
let star;
|
|
1038
|
+
while (test(RE_VARIANT_START)) {
|
|
1039
|
+
if (consumeChar("*")) {
|
|
1040
|
+
star = count;
|
|
1041
|
+
}
|
|
1042
|
+
let key = parseVariantKey();
|
|
1043
|
+
let value = parsePattern();
|
|
1044
|
+
if (value === null) {
|
|
1045
|
+
throw new SyntaxError("Expected variant value");
|
|
1046
|
+
}
|
|
1047
|
+
variants[count++] = { key, value };
|
|
1048
|
+
}
|
|
1049
|
+
if (count === 0) {
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
if (star === undefined) {
|
|
1053
|
+
throw new SyntaxError("Expected default variant");
|
|
1054
|
+
}
|
|
1055
|
+
return { variants, star };
|
|
1056
|
+
}
|
|
1057
|
+
function parseVariantKey() {
|
|
1058
|
+
consumeToken(TOKEN_BRACKET_OPEN, SyntaxError);
|
|
1059
|
+
let key;
|
|
1060
|
+
if (test(RE_NUMBER_LITERAL)) {
|
|
1061
|
+
key = parseNumberLiteral();
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
key = {
|
|
1065
|
+
type: "str",
|
|
1066
|
+
value: match1(RE_IDENTIFIER),
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
consumeToken(TOKEN_BRACKET_CLOSE, SyntaxError);
|
|
1070
|
+
return key;
|
|
1071
|
+
}
|
|
1072
|
+
function parseLiteral() {
|
|
1073
|
+
if (test(RE_NUMBER_LITERAL)) {
|
|
1074
|
+
return parseNumberLiteral();
|
|
1075
|
+
}
|
|
1076
|
+
if (source[cursor] === '"') {
|
|
1077
|
+
return parseStringLiteral();
|
|
1078
|
+
}
|
|
1079
|
+
throw new SyntaxError("Invalid expression");
|
|
1080
|
+
}
|
|
1081
|
+
function parseNumberLiteral() {
|
|
1082
|
+
let [, value, fraction = ""] = match(RE_NUMBER_LITERAL);
|
|
1083
|
+
let precision = fraction.length;
|
|
1084
|
+
return {
|
|
1085
|
+
type: "num",
|
|
1086
|
+
value: parseFloat(value),
|
|
1087
|
+
precision,
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
function parseStringLiteral() {
|
|
1091
|
+
consumeChar('"', SyntaxError);
|
|
1092
|
+
let value = "";
|
|
1093
|
+
while (true) {
|
|
1094
|
+
value += match1(RE_STRING_RUN);
|
|
1095
|
+
if (source[cursor] === "\\") {
|
|
1096
|
+
value += parseEscapeSequence();
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
if (consumeChar('"')) {
|
|
1100
|
+
return { type: "str", value };
|
|
1101
|
+
}
|
|
1102
|
+
// We've reached an EOL of EOF.
|
|
1103
|
+
throw new SyntaxError("Unclosed string literal");
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
// Unescape known escape sequences.
|
|
1107
|
+
function parseEscapeSequence() {
|
|
1108
|
+
if (test(RE_STRING_ESCAPE)) {
|
|
1109
|
+
return match1(RE_STRING_ESCAPE);
|
|
1110
|
+
}
|
|
1111
|
+
if (test(RE_UNICODE_ESCAPE)) {
|
|
1112
|
+
let [, codepoint4, codepoint6] = match(RE_UNICODE_ESCAPE);
|
|
1113
|
+
let codepoint = parseInt(codepoint4 || codepoint6, 16);
|
|
1114
|
+
return codepoint <= 0xd7ff || 0xe000 <= codepoint
|
|
1115
|
+
? // It's a Unicode scalar value.
|
|
1116
|
+
String.fromCodePoint(codepoint)
|
|
1117
|
+
: // Lonely surrogates can cause trouble when the parsing result is
|
|
1118
|
+
// saved using UTF-8. Use U+FFFD REPLACEMENT CHARACTER instead.
|
|
1119
|
+
"�";
|
|
1120
|
+
}
|
|
1121
|
+
throw new SyntaxError("Unknown escape sequence");
|
|
1122
|
+
}
|
|
1123
|
+
// Parse blank space. Return it if it looks like indent before a pattern
|
|
1124
|
+
// line. Skip it othwerwise.
|
|
1125
|
+
function parseIndent() {
|
|
1126
|
+
let start = cursor;
|
|
1127
|
+
consumeToken(TOKEN_BLANK);
|
|
1128
|
+
// Check the first non-blank character after the indent.
|
|
1129
|
+
switch (source[cursor]) {
|
|
1130
|
+
case ".":
|
|
1131
|
+
case "[":
|
|
1132
|
+
case "*":
|
|
1133
|
+
case "}":
|
|
1134
|
+
case undefined: // EOF
|
|
1135
|
+
// A special character. End the Pattern.
|
|
1136
|
+
return false;
|
|
1137
|
+
case "{":
|
|
1138
|
+
// Placeables don't require indentation (in EBNF: block-placeable).
|
|
1139
|
+
// Continue the Pattern.
|
|
1140
|
+
return makeIndent(source.slice(start, cursor));
|
|
1141
|
+
}
|
|
1142
|
+
// If the first character on the line is not one of the special characters
|
|
1143
|
+
// listed above, it's a regular text character. Check if there's at least
|
|
1144
|
+
// one space of indent before it.
|
|
1145
|
+
if (source[cursor - 1] === " ") {
|
|
1146
|
+
// It's an indented text character (in EBNF: indented-char). Continue
|
|
1147
|
+
// the Pattern.
|
|
1148
|
+
return makeIndent(source.slice(start, cursor));
|
|
1149
|
+
}
|
|
1150
|
+
// A not-indented text character is likely the identifier of the next
|
|
1151
|
+
// message. End the Pattern.
|
|
1152
|
+
return false;
|
|
1153
|
+
}
|
|
1154
|
+
// Trim blanks in text according to the given regex.
|
|
1155
|
+
function trim(text, re) {
|
|
1156
|
+
return text.replace(re, "");
|
|
1157
|
+
}
|
|
1158
|
+
// Normalize a blank block and extract the indent details.
|
|
1159
|
+
function makeIndent(blank) {
|
|
1160
|
+
let value = blank.replace(RE_BLANK_LINES, "\n");
|
|
1161
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
1162
|
+
let length = RE_INDENT.exec(blank)[1].length;
|
|
1163
|
+
return new Indent(value, length);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
class Indent {
|
|
1168
|
+
constructor(value, length) {
|
|
1169
|
+
this.value = value;
|
|
1170
|
+
this.length = length;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/* eslint no-console: ["error", {allow: ["warn"]}] */
|
|
1175
|
+
/* global console */
|
|
1176
|
+
// Match the opening angle bracket (<) in HTML tags, and HTML entities like
|
|
1177
|
+
// &, &, &.
|
|
1178
|
+
const reOverlay = /<|&#?\w+;/;
|
|
1179
|
+
/**
|
|
1180
|
+
* Elements allowed in translations even if they are not present in the source
|
|
1181
|
+
* HTML. They are text-level elements as defined by the HTML5 spec:
|
|
1182
|
+
* https://www.w3.org/TR/html5/text-level-semantics.html with the exception of:
|
|
1183
|
+
*
|
|
1184
|
+
* - a - because we don't allow href on it anyways,
|
|
1185
|
+
* - ruby, rt, rp - because we don't allow nested elements to be inserted.
|
|
1186
|
+
*/
|
|
1187
|
+
const TEXT_LEVEL_ELEMENTS = {
|
|
1188
|
+
"http://www.w3.org/1999/xhtml": [
|
|
1189
|
+
"em",
|
|
1190
|
+
"strong",
|
|
1191
|
+
"small",
|
|
1192
|
+
"s",
|
|
1193
|
+
"cite",
|
|
1194
|
+
"q",
|
|
1195
|
+
"dfn",
|
|
1196
|
+
"abbr",
|
|
1197
|
+
"data",
|
|
1198
|
+
"time",
|
|
1199
|
+
"code",
|
|
1200
|
+
"var",
|
|
1201
|
+
"samp",
|
|
1202
|
+
"kbd",
|
|
1203
|
+
"sub",
|
|
1204
|
+
"sup",
|
|
1205
|
+
"i",
|
|
1206
|
+
"b",
|
|
1207
|
+
"u",
|
|
1208
|
+
"mark",
|
|
1209
|
+
"bdi",
|
|
1210
|
+
"bdo",
|
|
1211
|
+
"span",
|
|
1212
|
+
"br",
|
|
1213
|
+
"wbr",
|
|
1214
|
+
],
|
|
1215
|
+
};
|
|
1216
|
+
const LOCALIZABLE_ATTRIBUTES = {
|
|
1217
|
+
"http://www.w3.org/1999/xhtml": {
|
|
1218
|
+
global: ["title", "aria-label", "aria-valuetext"],
|
|
1219
|
+
a: ["download"],
|
|
1220
|
+
area: ["download", "alt"],
|
|
1221
|
+
// value is special-cased in isAttrNameLocalizable
|
|
1222
|
+
input: ["alt", "placeholder"],
|
|
1223
|
+
menuitem: ["label"],
|
|
1224
|
+
menu: ["label"],
|
|
1225
|
+
optgroup: ["label"],
|
|
1226
|
+
option: ["label"],
|
|
1227
|
+
track: ["label"],
|
|
1228
|
+
img: ["alt"],
|
|
1229
|
+
textarea: ["placeholder"],
|
|
1230
|
+
th: ["abbr"],
|
|
1231
|
+
},
|
|
1232
|
+
"http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul": {
|
|
1233
|
+
global: [
|
|
1234
|
+
"accesskey",
|
|
1235
|
+
"aria-label",
|
|
1236
|
+
"aria-valuetext",
|
|
1237
|
+
"label",
|
|
1238
|
+
"title",
|
|
1239
|
+
"tooltiptext",
|
|
1240
|
+
],
|
|
1241
|
+
description: ["value"],
|
|
1242
|
+
key: ["key", "keycode"],
|
|
1243
|
+
label: ["value"],
|
|
1244
|
+
textbox: ["placeholder", "value"],
|
|
1245
|
+
},
|
|
1246
|
+
};
|
|
1247
|
+
/**
|
|
1248
|
+
* Translate an element.
|
|
1249
|
+
*
|
|
1250
|
+
* Translate the element's text content and attributes. Some HTML markup is
|
|
1251
|
+
* allowed in the translation. The element's children with the data-l10n-name
|
|
1252
|
+
* attribute will be treated as arguments to the translation. If the
|
|
1253
|
+
* translation defines the same children, their attributes and text contents
|
|
1254
|
+
* will be used for translating the matching source child.
|
|
1255
|
+
*
|
|
1256
|
+
* @param {Element} element
|
|
1257
|
+
* @param {Object} translation
|
|
1258
|
+
* @private
|
|
1259
|
+
*/
|
|
1260
|
+
function translateElement(element, translation) {
|
|
1261
|
+
const { value } = translation;
|
|
1262
|
+
if (typeof value === "string") {
|
|
1263
|
+
if (element.localName === "title" &&
|
|
1264
|
+
element.namespaceURI === "http://www.w3.org/1999/xhtml") {
|
|
1265
|
+
// A special case for the HTML title element whose content must be text.
|
|
1266
|
+
element.textContent = value;
|
|
1267
|
+
}
|
|
1268
|
+
else if (!reOverlay.test(value)) {
|
|
1269
|
+
// If the translation doesn't contain any markup skip the overlay logic.
|
|
1270
|
+
element.textContent = value;
|
|
1271
|
+
}
|
|
1272
|
+
else {
|
|
1273
|
+
// Else parse the translation's HTML using an inert template element,
|
|
1274
|
+
// sanitize it and replace the element's content.
|
|
1275
|
+
const templateElement = element.ownerDocument.createElementNS("http://www.w3.org/1999/xhtml", "template");
|
|
1276
|
+
templateElement.innerHTML = value;
|
|
1277
|
+
overlayChildNodes(templateElement.content, element);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
// Even if the translation doesn't define any localizable attributes, run
|
|
1281
|
+
// overlayAttributes to remove any localizable attributes set by previous
|
|
1282
|
+
// translations.
|
|
1283
|
+
overlayAttributes(translation, element);
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Replace child nodes of an element with child nodes of another element.
|
|
1287
|
+
*
|
|
1288
|
+
* The contents of the target element will be cleared and fully replaced with
|
|
1289
|
+
* sanitized contents of the source element.
|
|
1290
|
+
*
|
|
1291
|
+
* @param {DocumentFragment} fromFragment - The source of children to overlay.
|
|
1292
|
+
* @param {Element} toElement - The target of the overlay.
|
|
1293
|
+
* @private
|
|
1294
|
+
*/
|
|
1295
|
+
function overlayChildNodes(fromFragment, toElement) {
|
|
1296
|
+
for (const childNode of fromFragment.childNodes) {
|
|
1297
|
+
if (childNode.nodeType === childNode.TEXT_NODE) {
|
|
1298
|
+
// Keep the translated text node.
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
if (childNode.hasAttribute("data-l10n-name")) {
|
|
1302
|
+
const sanitized = getNodeForNamedElement(toElement, childNode);
|
|
1303
|
+
fromFragment.replaceChild(sanitized, childNode);
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
if (isElementAllowed(childNode)) {
|
|
1307
|
+
const sanitized = createSanitizedElement(childNode);
|
|
1308
|
+
fromFragment.replaceChild(sanitized, childNode);
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
console.warn(`An element of forbidden type "${childNode.localName}" was found in ` +
|
|
1312
|
+
"the translation. Only safe text-level elements and elements with " +
|
|
1313
|
+
"data-l10n-name are allowed.");
|
|
1314
|
+
// If all else fails, replace the element with its text content.
|
|
1315
|
+
fromFragment.replaceChild(createTextNodeFromTextContent(childNode), childNode);
|
|
1316
|
+
}
|
|
1317
|
+
toElement.textContent = "";
|
|
1318
|
+
toElement.appendChild(fromFragment);
|
|
1319
|
+
}
|
|
1320
|
+
function hasAttribute(attributes, name) {
|
|
1321
|
+
if (!attributes) {
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
for (let attr of attributes) {
|
|
1325
|
+
if (attr.name === name) {
|
|
1326
|
+
return true;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Transplant localizable attributes of an element to another element.
|
|
1333
|
+
*
|
|
1334
|
+
* Any localizable attributes already set on the target element will be
|
|
1335
|
+
* cleared.
|
|
1336
|
+
*
|
|
1337
|
+
* @param {Element|Object} fromElement - The source of child nodes to overlay.
|
|
1338
|
+
* @param {Element} toElement - The target of the overlay.
|
|
1339
|
+
* @private
|
|
1340
|
+
*/
|
|
1341
|
+
function overlayAttributes(fromElement, toElement) {
|
|
1342
|
+
const explicitlyAllowed = toElement.hasAttribute("data-l10n-attrs")
|
|
1343
|
+
? toElement
|
|
1344
|
+
.getAttribute("data-l10n-attrs")
|
|
1345
|
+
.split(",")
|
|
1346
|
+
.map(i => i.trim())
|
|
1347
|
+
: null;
|
|
1348
|
+
// Remove existing localizable attributes if they
|
|
1349
|
+
// will not be used in the new translation.
|
|
1350
|
+
for (const attr of Array.from(toElement.attributes)) {
|
|
1351
|
+
if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed) &&
|
|
1352
|
+
!hasAttribute(fromElement.attributes, attr.name)) {
|
|
1353
|
+
toElement.removeAttribute(attr.name);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
// fromElement might be a {value, attributes} object as returned by
|
|
1357
|
+
// Localization.messageFromBundle. In which case attributes may be null to
|
|
1358
|
+
// save GC cycles.
|
|
1359
|
+
if (!fromElement.attributes) {
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
// Set localizable attributes.
|
|
1363
|
+
for (const attr of Array.from(fromElement.attributes)) {
|
|
1364
|
+
if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed) &&
|
|
1365
|
+
toElement.getAttribute(attr.name) !== attr.value) {
|
|
1366
|
+
toElement.setAttribute(attr.name, attr.value);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Sanitize a child element created by the translation.
|
|
1372
|
+
*
|
|
1373
|
+
* Try to find a corresponding child in sourceElement and use it as the base
|
|
1374
|
+
* for the sanitization. This will preserve functional attribtues defined on
|
|
1375
|
+
* the child element in the source HTML.
|
|
1376
|
+
*
|
|
1377
|
+
* @param {Element} sourceElement - The source for data-l10n-name lookups.
|
|
1378
|
+
* @param {Element} translatedChild - The translated child to be sanitized.
|
|
1379
|
+
* @returns {Element}
|
|
1380
|
+
* @private
|
|
1381
|
+
*/
|
|
1382
|
+
function getNodeForNamedElement(sourceElement, translatedChild) {
|
|
1383
|
+
const childName = translatedChild.getAttribute("data-l10n-name");
|
|
1384
|
+
const sourceChild = sourceElement.querySelector(`[data-l10n-name="${childName}"]`);
|
|
1385
|
+
if (!sourceChild) {
|
|
1386
|
+
console.warn(`An element named "${childName}" wasn't found in the source.`);
|
|
1387
|
+
return createTextNodeFromTextContent(translatedChild);
|
|
1388
|
+
}
|
|
1389
|
+
if (sourceChild.localName !== translatedChild.localName) {
|
|
1390
|
+
console.warn(`An element named "${childName}" was found in the translation ` +
|
|
1391
|
+
`but its type ${translatedChild.localName} didn't match the ` +
|
|
1392
|
+
`element found in the source (${sourceChild.localName}).`);
|
|
1393
|
+
return createTextNodeFromTextContent(translatedChild);
|
|
1394
|
+
}
|
|
1395
|
+
// Remove it from sourceElement so that the translation cannot use
|
|
1396
|
+
// the same reference name again.
|
|
1397
|
+
sourceElement.removeChild(sourceChild);
|
|
1398
|
+
// We can't currently guarantee that a translation won't remove
|
|
1399
|
+
// sourceChild from the element completely, which could break the app if
|
|
1400
|
+
// it relies on an event handler attached to the sourceChild. Let's make
|
|
1401
|
+
// this limitation explicit for now by breaking the identitiy of the
|
|
1402
|
+
// sourceChild by cloning it. This will destroy all event handlers
|
|
1403
|
+
// attached to sourceChild via addEventListener and via on<name>
|
|
1404
|
+
// properties.
|
|
1405
|
+
const clone = sourceChild.cloneNode(false);
|
|
1406
|
+
return shallowPopulateUsing(translatedChild, clone);
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Sanitize an allowed element.
|
|
1410
|
+
*
|
|
1411
|
+
* Text-level elements allowed in translations may only use safe attributes
|
|
1412
|
+
* and will have any nested markup stripped to text content.
|
|
1413
|
+
*
|
|
1414
|
+
* @param {Element} element - The element to be sanitized.
|
|
1415
|
+
* @returns {Element}
|
|
1416
|
+
* @private
|
|
1417
|
+
*/
|
|
1418
|
+
function createSanitizedElement(element) {
|
|
1419
|
+
// Start with an empty element of the same type to remove nested children
|
|
1420
|
+
// and non-localizable attributes defined by the translation.
|
|
1421
|
+
const clone = element.ownerDocument.createElement(element.localName);
|
|
1422
|
+
return shallowPopulateUsing(element, clone);
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Convert an element to a text node.
|
|
1426
|
+
*
|
|
1427
|
+
* @param {Element} element - The element to be sanitized.
|
|
1428
|
+
* @returns {Node}
|
|
1429
|
+
* @private
|
|
1430
|
+
*/
|
|
1431
|
+
function createTextNodeFromTextContent(element) {
|
|
1432
|
+
return element.ownerDocument.createTextNode(element.textContent);
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Check if element is allowed in the translation.
|
|
1436
|
+
*
|
|
1437
|
+
* This method is used by the sanitizer when the translation markup contains
|
|
1438
|
+
* an element which is not present in the source code.
|
|
1439
|
+
*
|
|
1440
|
+
* @param {Element} element
|
|
1441
|
+
* @returns {boolean}
|
|
1442
|
+
* @private
|
|
1443
|
+
*/
|
|
1444
|
+
function isElementAllowed(element) {
|
|
1445
|
+
const allowed = TEXT_LEVEL_ELEMENTS[element.namespaceURI];
|
|
1446
|
+
return allowed && allowed.includes(element.localName);
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Check if attribute is allowed for the given element.
|
|
1450
|
+
*
|
|
1451
|
+
* This method is used by the sanitizer when the translation markup contains
|
|
1452
|
+
* DOM attributes, or when the translation has traits which map to DOM
|
|
1453
|
+
* attributes.
|
|
1454
|
+
*
|
|
1455
|
+
* `explicitlyAllowed` can be passed as a list of attributes explicitly
|
|
1456
|
+
* allowed on this element.
|
|
1457
|
+
*
|
|
1458
|
+
* @param {string} name
|
|
1459
|
+
* @param {Element} element
|
|
1460
|
+
* @param {Array} explicitlyAllowed
|
|
1461
|
+
* @returns {boolean}
|
|
1462
|
+
* @private
|
|
1463
|
+
*/
|
|
1464
|
+
function isAttrNameLocalizable(name, element, explicitlyAllowed = null) {
|
|
1465
|
+
if (explicitlyAllowed && explicitlyAllowed.includes(name)) {
|
|
1466
|
+
return true;
|
|
1467
|
+
}
|
|
1468
|
+
const allowed = LOCALIZABLE_ATTRIBUTES[element.namespaceURI];
|
|
1469
|
+
if (!allowed) {
|
|
1470
|
+
return false;
|
|
1471
|
+
}
|
|
1472
|
+
const attrName = name.toLowerCase();
|
|
1473
|
+
const elemName = element.localName;
|
|
1474
|
+
// Is it a globally safe attribute?
|
|
1475
|
+
if (allowed.global.includes(attrName)) {
|
|
1476
|
+
return true;
|
|
1477
|
+
}
|
|
1478
|
+
// Are there no allowed attributes for this element?
|
|
1479
|
+
if (!allowed[elemName]) {
|
|
1480
|
+
return false;
|
|
1481
|
+
}
|
|
1482
|
+
// Is it allowed on this element?
|
|
1483
|
+
if (allowed[elemName].includes(attrName)) {
|
|
1484
|
+
return true;
|
|
1485
|
+
}
|
|
1486
|
+
// Special case for value on HTML inputs with type button, reset, submit
|
|
1487
|
+
if (element.namespaceURI === "http://www.w3.org/1999/xhtml" &&
|
|
1488
|
+
elemName === "input" &&
|
|
1489
|
+
attrName === "value") {
|
|
1490
|
+
const type = element.type.toLowerCase();
|
|
1491
|
+
if (type === "submit" || type === "button" || type === "reset") {
|
|
1492
|
+
return true;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
return false;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Helper to set textContent and localizable attributes on an element.
|
|
1499
|
+
*
|
|
1500
|
+
* @param {Element} fromElement
|
|
1501
|
+
* @param {Element} toElement
|
|
1502
|
+
* @returns {Element}
|
|
1503
|
+
* @private
|
|
1504
|
+
*/
|
|
1505
|
+
function shallowPopulateUsing(fromElement, toElement) {
|
|
1506
|
+
toElement.textContent = fromElement.textContent;
|
|
1507
|
+
overlayAttributes(fromElement, toElement);
|
|
1508
|
+
return toElement;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/*
|
|
1512
|
+
* Base CachedIterable class.
|
|
1513
|
+
*/
|
|
1514
|
+
class CachedIterable extends Array {
|
|
1515
|
+
/**
|
|
1516
|
+
* Create a `CachedIterable` instance from an iterable or, if another
|
|
1517
|
+
* instance of `CachedIterable` is passed, return it without any
|
|
1518
|
+
* modifications.
|
|
1519
|
+
*
|
|
1520
|
+
* @param {Iterable} iterable
|
|
1521
|
+
* @returns {CachedIterable}
|
|
1522
|
+
*/
|
|
1523
|
+
static from(iterable) {
|
|
1524
|
+
if (iterable instanceof this) {
|
|
1525
|
+
return iterable;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
return new this(iterable);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/*
|
|
1533
|
+
* CachedAsyncIterable caches the elements yielded by an async iterable.
|
|
1534
|
+
*
|
|
1535
|
+
* It can be used to iterate over an iterable many times without depleting the
|
|
1536
|
+
* iterable.
|
|
1537
|
+
*/
|
|
1538
|
+
class CachedAsyncIterable extends CachedIterable {
|
|
1539
|
+
/**
|
|
1540
|
+
* Create an `CachedAsyncIterable` instance.
|
|
1541
|
+
*
|
|
1542
|
+
* @param {Iterable} iterable
|
|
1543
|
+
* @returns {CachedAsyncIterable}
|
|
1544
|
+
*/
|
|
1545
|
+
constructor(iterable) {
|
|
1546
|
+
super();
|
|
1547
|
+
|
|
1548
|
+
if (Symbol.asyncIterator in Object(iterable)) {
|
|
1549
|
+
this.iterator = iterable[Symbol.asyncIterator]();
|
|
1550
|
+
} else if (Symbol.iterator in Object(iterable)) {
|
|
1551
|
+
this.iterator = iterable[Symbol.iterator]();
|
|
1552
|
+
} else {
|
|
1553
|
+
throw new TypeError("Argument must implement the iteration protocol.");
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Asynchronous iterator caching the yielded elements.
|
|
1559
|
+
*
|
|
1560
|
+
* Elements yielded by the original iterable will be cached and available
|
|
1561
|
+
* synchronously. Returns an async generator object implementing the
|
|
1562
|
+
* iterator protocol over the elements of the original (async or sync)
|
|
1563
|
+
* iterable.
|
|
1564
|
+
*/
|
|
1565
|
+
[Symbol.asyncIterator]() {
|
|
1566
|
+
const cached = this;
|
|
1567
|
+
let cur = 0;
|
|
1568
|
+
|
|
1569
|
+
return {
|
|
1570
|
+
async next() {
|
|
1571
|
+
if (cached.length <= cur) {
|
|
1572
|
+
cached.push(cached.iterator.next());
|
|
1573
|
+
}
|
|
1574
|
+
return cached[cur++];
|
|
1575
|
+
}
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* This method allows user to consume the next element from the iterator
|
|
1581
|
+
* into the cache.
|
|
1582
|
+
*
|
|
1583
|
+
* @param {number} count - number of elements to consume
|
|
1584
|
+
*/
|
|
1585
|
+
async touchNext(count = 1) {
|
|
1586
|
+
let idx = 0;
|
|
1587
|
+
while (idx++ < count) {
|
|
1588
|
+
const last = this[this.length - 1];
|
|
1589
|
+
if (last && (await last).done) {
|
|
1590
|
+
break;
|
|
1591
|
+
}
|
|
1592
|
+
this.push(this.iterator.next());
|
|
1593
|
+
}
|
|
1594
|
+
// Return the last cached {value, done} object to allow the calling
|
|
1595
|
+
// code to decide if it needs to call touchNext again.
|
|
1596
|
+
return this[this.length - 1];
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/* eslint no-console: ["error", { allow: ["warn", "error"] }] */
|
|
1601
|
+
/* global console */
|
|
1602
|
+
/**
|
|
1603
|
+
* The `Localization` class is a central high-level API for vanilla
|
|
1604
|
+
* JavaScript use of Fluent.
|
|
1605
|
+
* It combines language negotiation, FluentBundle and I/O to
|
|
1606
|
+
* provide a scriptable API to format translations.
|
|
1607
|
+
*/
|
|
1608
|
+
class Localization {
|
|
1609
|
+
/**
|
|
1610
|
+
* @param {Array<String>} resourceIds - List of resource IDs
|
|
1611
|
+
* @param {Function} generateBundles - Function that returns a
|
|
1612
|
+
* generator over FluentBundles
|
|
1613
|
+
*
|
|
1614
|
+
* @returns {Localization}
|
|
1615
|
+
*/
|
|
1616
|
+
constructor(resourceIds = [], generateBundles) {
|
|
1617
|
+
this.resourceIds = resourceIds;
|
|
1618
|
+
this.generateBundles = generateBundles;
|
|
1619
|
+
this.onChange(true);
|
|
1620
|
+
}
|
|
1621
|
+
addResourceIds(resourceIds, eager = false) {
|
|
1622
|
+
this.resourceIds.push(...resourceIds);
|
|
1623
|
+
this.onChange(eager);
|
|
1624
|
+
return this.resourceIds.length;
|
|
1625
|
+
}
|
|
1626
|
+
removeResourceIds(resourceIds) {
|
|
1627
|
+
this.resourceIds = this.resourceIds.filter(r => !resourceIds.includes(r));
|
|
1628
|
+
this.onChange();
|
|
1629
|
+
return this.resourceIds.length;
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Format translations and handle fallback if needed.
|
|
1633
|
+
*
|
|
1634
|
+
* Format translations for `keys` from `FluentBundle` instances on this
|
|
1635
|
+
* DOMLocalization. In case of errors, fetch the next context in the
|
|
1636
|
+
* fallback chain.
|
|
1637
|
+
*
|
|
1638
|
+
* @param {Array<Object>} keys - Translation keys to format.
|
|
1639
|
+
* @param {Function} method - Formatting function.
|
|
1640
|
+
* @returns {Promise<Array<string|Object>>}
|
|
1641
|
+
* @private
|
|
1642
|
+
*/
|
|
1643
|
+
async formatWithFallback(keys, method) {
|
|
1644
|
+
const translations = [];
|
|
1645
|
+
let hasAtLeastOneBundle = false;
|
|
1646
|
+
for await (const bundle of this.bundles) {
|
|
1647
|
+
hasAtLeastOneBundle = true;
|
|
1648
|
+
const missingIds = keysFromBundle(method, bundle, keys, translations);
|
|
1649
|
+
if (missingIds.size === 0) {
|
|
1650
|
+
break;
|
|
1651
|
+
}
|
|
1652
|
+
if (typeof console !== "undefined") {
|
|
1653
|
+
const locale = bundle.locales[0];
|
|
1654
|
+
const ids = Array.from(missingIds).join(", ");
|
|
1655
|
+
console.warn(`[fluent] Missing translations in ${locale}: ${ids}`);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
if (!hasAtLeastOneBundle && typeof console !== "undefined") {
|
|
1659
|
+
// eslint-disable-next-line max-len
|
|
1660
|
+
console.warn(`[fluent] Request for keys failed because no resource bundles got generated.
|
|
1661
|
+
keys: ${JSON.stringify(keys)}.
|
|
1662
|
+
resourceIds: ${JSON.stringify(this.resourceIds)}.`);
|
|
1663
|
+
}
|
|
1664
|
+
return translations;
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Format translations into `{value, attributes}` objects.
|
|
1668
|
+
*
|
|
1669
|
+
* The fallback logic is the same as in `formatValues`
|
|
1670
|
+
* but it returns `{value, attributes}` objects
|
|
1671
|
+
* which are suitable for the translation of DOM elements.
|
|
1672
|
+
*
|
|
1673
|
+
* Returns a Promise resolving to an array of the translation strings.
|
|
1674
|
+
*
|
|
1675
|
+
* @example
|
|
1676
|
+
* ```js
|
|
1677
|
+
* docL10n.formatMessages([
|
|
1678
|
+
* {id: 'hello', args: { who: 'Mary' }},
|
|
1679
|
+
* {id: 'welcome'}
|
|
1680
|
+
* ]).then(console.log);
|
|
1681
|
+
*
|
|
1682
|
+
* // [
|
|
1683
|
+
* // { value: 'Hello, Mary!', attributes: null },
|
|
1684
|
+
* // {
|
|
1685
|
+
* // value: 'Welcome!',
|
|
1686
|
+
* // attributes: [ { name: "title", value: 'Hello' } ]
|
|
1687
|
+
* // }
|
|
1688
|
+
* // ]
|
|
1689
|
+
* ```
|
|
1690
|
+
*
|
|
1691
|
+
* @param {Array<Object>} keys
|
|
1692
|
+
* @returns {Promise<Array<{value: string, attributes: Object}>>}
|
|
1693
|
+
* @private
|
|
1694
|
+
*/
|
|
1695
|
+
formatMessages(keys) {
|
|
1696
|
+
return this.formatWithFallback(keys, messageFromBundle);
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Retrieve translations corresponding to the passed keys.
|
|
1700
|
+
*
|
|
1701
|
+
* A generalized version of `DOMLocalization.formatValue`. Keys must
|
|
1702
|
+
* be `{id, args}` objects.
|
|
1703
|
+
*
|
|
1704
|
+
* Returns a Promise resolving to an array of the translation strings.
|
|
1705
|
+
*
|
|
1706
|
+
* @example
|
|
1707
|
+
* ```js
|
|
1708
|
+
* docL10n.formatValues([
|
|
1709
|
+
* {id: 'hello', args: { who: 'Mary' }},
|
|
1710
|
+
* {id: 'hello', args: { who: 'John' }},
|
|
1711
|
+
* {id: 'welcome'}
|
|
1712
|
+
* ]).then(console.log);
|
|
1713
|
+
*
|
|
1714
|
+
* // ['Hello, Mary!', 'Hello, John!', 'Welcome!']
|
|
1715
|
+
* ```
|
|
1716
|
+
*
|
|
1717
|
+
* @param {Array<Object>} keys
|
|
1718
|
+
* @returns {Promise<Array<string>>}
|
|
1719
|
+
*/
|
|
1720
|
+
formatValues(keys) {
|
|
1721
|
+
return this.formatWithFallback(keys, valueFromBundle);
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* Retrieve the translation corresponding to the `id` identifier.
|
|
1725
|
+
*
|
|
1726
|
+
* If passed, `args` is a simple hash object with a list of variables that
|
|
1727
|
+
* will be interpolated in the value of the translation.
|
|
1728
|
+
*
|
|
1729
|
+
* Returns a Promise resolving to the translation string.
|
|
1730
|
+
*
|
|
1731
|
+
* Use this sparingly for one-off messages which don't need to be
|
|
1732
|
+
* retranslated when the user changes their language preferences, e.g. in
|
|
1733
|
+
* notifications.
|
|
1734
|
+
*
|
|
1735
|
+
* @example
|
|
1736
|
+
* ```js
|
|
1737
|
+
* docL10n.formatValue(
|
|
1738
|
+
* 'hello', { who: 'world' }
|
|
1739
|
+
* ).then(console.log);
|
|
1740
|
+
*
|
|
1741
|
+
* // 'Hello, world!'
|
|
1742
|
+
* ```
|
|
1743
|
+
*
|
|
1744
|
+
* @param {string} id - Identifier of the translation to format
|
|
1745
|
+
* @param {Object} [args] - Optional external arguments
|
|
1746
|
+
* @returns {Promise<string>}
|
|
1747
|
+
*/
|
|
1748
|
+
async formatValue(id, args) {
|
|
1749
|
+
const [val] = await this.formatValues([{ id, args }]);
|
|
1750
|
+
return val;
|
|
1751
|
+
}
|
|
1752
|
+
handleEvent() {
|
|
1753
|
+
this.onChange();
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* This method should be called when there's a reason to believe
|
|
1757
|
+
* that language negotiation or available resources changed.
|
|
1758
|
+
*/
|
|
1759
|
+
onChange(eager = false) {
|
|
1760
|
+
this.bundles = CachedAsyncIterable.from(this.generateBundles(this.resourceIds));
|
|
1761
|
+
if (eager) {
|
|
1762
|
+
this.bundles.touchNext(2);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Format the value of a message into a string or `null`.
|
|
1768
|
+
*
|
|
1769
|
+
* This function is passed as a method to `keysFromBundle` and resolve
|
|
1770
|
+
* a value of a single L10n Entity using provided `FluentBundle`.
|
|
1771
|
+
*
|
|
1772
|
+
* If the message doesn't have a value, return `null`.
|
|
1773
|
+
*
|
|
1774
|
+
* @param {FluentBundle} bundle
|
|
1775
|
+
* @param {Array<Error>} errors
|
|
1776
|
+
* @param {Object} message
|
|
1777
|
+
* @param {Object} args
|
|
1778
|
+
* @returns {string|null}
|
|
1779
|
+
* @private
|
|
1780
|
+
*/
|
|
1781
|
+
function valueFromBundle(bundle, errors, message, args) {
|
|
1782
|
+
if (message.value) {
|
|
1783
|
+
return bundle.formatPattern(message.value, args, errors);
|
|
1784
|
+
}
|
|
1785
|
+
return null;
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Format all public values of a message into a {value, attributes} object.
|
|
1789
|
+
*
|
|
1790
|
+
* This function is passed as a method to `keysFromBundle` and resolve
|
|
1791
|
+
* a single L10n Entity using provided `FluentBundle`.
|
|
1792
|
+
*
|
|
1793
|
+
* The function will return an object with a value and attributes of the
|
|
1794
|
+
* entity.
|
|
1795
|
+
*
|
|
1796
|
+
* @param {FluentBundle} bundle
|
|
1797
|
+
* @param {Array<Error>} errors
|
|
1798
|
+
* @param {Object} message
|
|
1799
|
+
* @param {Object} args
|
|
1800
|
+
* @returns {Object}
|
|
1801
|
+
* @private
|
|
1802
|
+
*/
|
|
1803
|
+
function messageFromBundle(bundle, errors, message, args) {
|
|
1804
|
+
const formatted = {
|
|
1805
|
+
value: null,
|
|
1806
|
+
attributes: null,
|
|
1807
|
+
};
|
|
1808
|
+
if (message.value) {
|
|
1809
|
+
formatted.value = bundle.formatPattern(message.value, args, errors);
|
|
1810
|
+
}
|
|
1811
|
+
let attrNames = Object.keys(message.attributes);
|
|
1812
|
+
if (attrNames.length > 0) {
|
|
1813
|
+
formatted.attributes = new Array(attrNames.length);
|
|
1814
|
+
for (let [i, name] of attrNames.entries()) {
|
|
1815
|
+
let value = bundle.formatPattern(message.attributes[name], args, errors);
|
|
1816
|
+
formatted.attributes[i] = { name, value };
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
return formatted;
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* This function is an inner function for `Localization.formatWithFallback`.
|
|
1823
|
+
*
|
|
1824
|
+
* It takes a `FluentBundle`, list of l10n-ids and a method to be used for
|
|
1825
|
+
* key resolution (either `valueFromBundle` or `messageFromBundle`) and
|
|
1826
|
+
* optionally a value returned from `keysFromBundle` executed against
|
|
1827
|
+
* another `FluentBundle`.
|
|
1828
|
+
*
|
|
1829
|
+
* The idea here is that if the previous `FluentBundle` did not resolve
|
|
1830
|
+
* all keys, we're calling this function with the next context to resolve
|
|
1831
|
+
* the remaining ones.
|
|
1832
|
+
*
|
|
1833
|
+
* In the function, we loop over `keys` and check if we have the `prev`
|
|
1834
|
+
* passed and if it has an error entry for the position we're in.
|
|
1835
|
+
*
|
|
1836
|
+
* If it doesn't, it means that we have a good translation for this key and
|
|
1837
|
+
* we return it. If it does, we'll try to resolve the key using the passed
|
|
1838
|
+
* `FluentBundle`.
|
|
1839
|
+
*
|
|
1840
|
+
* In the end, we fill the translations array, and return the Set with
|
|
1841
|
+
* missing ids.
|
|
1842
|
+
*
|
|
1843
|
+
* See `Localization.formatWithFallback` for more info on how this is used.
|
|
1844
|
+
*
|
|
1845
|
+
* @param {Function} method
|
|
1846
|
+
* @param {FluentBundle} bundle
|
|
1847
|
+
* @param {Array<string>} keys
|
|
1848
|
+
* @param {{Array<{value: string, attributes: Object}>}} translations
|
|
1849
|
+
*
|
|
1850
|
+
* @returns {Set<string>}
|
|
1851
|
+
* @private
|
|
1852
|
+
*/
|
|
1853
|
+
function keysFromBundle(method, bundle, keys, translations) {
|
|
1854
|
+
const messageErrors = [];
|
|
1855
|
+
const missingIds = new Set();
|
|
1856
|
+
keys.forEach(({ id, args }, i) => {
|
|
1857
|
+
if (translations[i] !== undefined) {
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
let message = bundle.getMessage(id);
|
|
1861
|
+
if (message) {
|
|
1862
|
+
messageErrors.length = 0;
|
|
1863
|
+
translations[i] = method(bundle, messageErrors, message, args);
|
|
1864
|
+
if (messageErrors.length > 0 && typeof console !== "undefined") {
|
|
1865
|
+
const locale = bundle.locales[0];
|
|
1866
|
+
const errors = messageErrors.join(", ");
|
|
1867
|
+
// eslint-disable-next-line max-len
|
|
1868
|
+
console.warn(`[fluent][resolver] errors in ${locale}/${id}: ${errors}.`);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
else {
|
|
1872
|
+
missingIds.add(id);
|
|
1873
|
+
}
|
|
1874
|
+
});
|
|
1875
|
+
return missingIds;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
const L10NID_ATTR_NAME = "data-l10n-id";
|
|
1879
|
+
const L10NARGS_ATTR_NAME = "data-l10n-args";
|
|
1880
|
+
const L10N_ELEMENT_QUERY = `[${L10NID_ATTR_NAME}]`;
|
|
1881
|
+
/**
|
|
1882
|
+
* The `DOMLocalization` class is responsible for fetching resources and
|
|
1883
|
+
* formatting translations.
|
|
1884
|
+
*
|
|
1885
|
+
* It implements the fallback strategy in case of errors encountered during the
|
|
1886
|
+
* formatting of translations and methods for observing DOM
|
|
1887
|
+
* trees with a `MutationObserver`.
|
|
1888
|
+
*/
|
|
1889
|
+
class DOMLocalization extends Localization {
|
|
1890
|
+
/**
|
|
1891
|
+
* @param {Array<String>} resourceIds - List of resource IDs
|
|
1892
|
+
* @param {Function} generateBundles - Function that returns a
|
|
1893
|
+
* generator over FluentBundles
|
|
1894
|
+
* @returns {DOMLocalization}
|
|
1895
|
+
*/
|
|
1896
|
+
constructor(resourceIds, generateBundles) {
|
|
1897
|
+
super(resourceIds, generateBundles);
|
|
1898
|
+
// A Set of DOM trees observed by the `MutationObserver`.
|
|
1899
|
+
this.roots = new Set();
|
|
1900
|
+
// requestAnimationFrame handler.
|
|
1901
|
+
this.pendingrAF = null;
|
|
1902
|
+
// list of elements pending for translation.
|
|
1903
|
+
this.pendingElements = new Set();
|
|
1904
|
+
this.windowElement = null;
|
|
1905
|
+
this.mutationObserver = null;
|
|
1906
|
+
this.observerConfig = {
|
|
1907
|
+
attributes: true,
|
|
1908
|
+
characterData: false,
|
|
1909
|
+
childList: true,
|
|
1910
|
+
subtree: true,
|
|
1911
|
+
attributeFilter: [L10NID_ATTR_NAME, L10NARGS_ATTR_NAME],
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
onChange(eager = false) {
|
|
1915
|
+
super.onChange(eager);
|
|
1916
|
+
if (this.roots) {
|
|
1917
|
+
this.translateRoots();
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Set the `data-l10n-id` and `data-l10n-args` attributes on DOM elements.
|
|
1922
|
+
* FluentDOM makes use of mutation observers to detect changes
|
|
1923
|
+
* to `data-l10n-*` attributes and translate elements asynchronously.
|
|
1924
|
+
* `setAttributes` is a convenience method which allows to translate
|
|
1925
|
+
* DOM elements declaratively.
|
|
1926
|
+
*
|
|
1927
|
+
* You should always prefer to use `data-l10n-id` on elements (statically in
|
|
1928
|
+
* HTML or dynamically via `setAttributes`) over manually retrieving
|
|
1929
|
+
* translations with `format`. The use of attributes ensures that the
|
|
1930
|
+
* elements can be retranslated when the user changes their language
|
|
1931
|
+
* preferences.
|
|
1932
|
+
*
|
|
1933
|
+
* ```javascript
|
|
1934
|
+
* localization.setAttributes(
|
|
1935
|
+
* document.querySelector('#welcome'), 'hello', { who: 'world' }
|
|
1936
|
+
* );
|
|
1937
|
+
* ```
|
|
1938
|
+
*
|
|
1939
|
+
* This will set the following attributes on the `#welcome` element.
|
|
1940
|
+
* The MutationObserver will pick up this change and will localize the element
|
|
1941
|
+
* asynchronously.
|
|
1942
|
+
*
|
|
1943
|
+
* ```html
|
|
1944
|
+
* <p id='welcome'
|
|
1945
|
+
* data-l10n-id='hello'
|
|
1946
|
+
* data-l10n-args='{"who": "world"}'>
|
|
1947
|
+
* </p>
|
|
1948
|
+
* ```
|
|
1949
|
+
*
|
|
1950
|
+
* @param {Element} element - Element to set attributes on
|
|
1951
|
+
* @param {string} id - l10n-id string
|
|
1952
|
+
* @param {Object<string, string>} args - KVP list of l10n arguments
|
|
1953
|
+
* @returns {Element}
|
|
1954
|
+
*/
|
|
1955
|
+
setAttributes(element, id, args) {
|
|
1956
|
+
element.setAttribute(L10NID_ATTR_NAME, id);
|
|
1957
|
+
if (args) {
|
|
1958
|
+
element.setAttribute(L10NARGS_ATTR_NAME, JSON.stringify(args));
|
|
1959
|
+
}
|
|
1960
|
+
else {
|
|
1961
|
+
element.removeAttribute(L10NARGS_ATTR_NAME);
|
|
1962
|
+
}
|
|
1963
|
+
return element;
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* Get the `data-l10n-*` attributes from DOM elements.
|
|
1967
|
+
*
|
|
1968
|
+
* ```javascript
|
|
1969
|
+
* localization.getAttributes(
|
|
1970
|
+
* document.querySelector('#welcome')
|
|
1971
|
+
* );
|
|
1972
|
+
* // -> { id: 'hello', args: { who: 'world' } }
|
|
1973
|
+
* ```
|
|
1974
|
+
*
|
|
1975
|
+
* @param {Element} element - HTML element
|
|
1976
|
+
* @returns {{id: string, args: Object}}
|
|
1977
|
+
*/
|
|
1978
|
+
getAttributes(element) {
|
|
1979
|
+
return {
|
|
1980
|
+
id: element.getAttribute(L10NID_ATTR_NAME),
|
|
1981
|
+
args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null),
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Add `newRoot` to the list of roots managed by this `DOMLocalization`.
|
|
1986
|
+
*
|
|
1987
|
+
* Additionally, if this `DOMLocalization` has an observer, start observing
|
|
1988
|
+
* `newRoot` in order to translate mutations in it.
|
|
1989
|
+
*
|
|
1990
|
+
* @param {Element} newRoot - Root to observe.
|
|
1991
|
+
*/
|
|
1992
|
+
connectRoot(newRoot) {
|
|
1993
|
+
for (const root of this.roots) {
|
|
1994
|
+
if (root === newRoot ||
|
|
1995
|
+
root.contains(newRoot) ||
|
|
1996
|
+
newRoot.contains(root)) {
|
|
1997
|
+
throw new Error("Cannot add a root that overlaps with existing root.");
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
if (this.windowElement) {
|
|
2001
|
+
if (this.windowElement !== newRoot.ownerDocument.defaultView) {
|
|
2002
|
+
throw new Error(`Cannot connect a root:
|
|
2003
|
+
DOMLocalization already has a root from a different window.`);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
else {
|
|
2007
|
+
this.windowElement = newRoot.ownerDocument.defaultView;
|
|
2008
|
+
this.mutationObserver = new this.windowElement.MutationObserver(mutations => this.translateMutations(mutations));
|
|
2009
|
+
}
|
|
2010
|
+
this.roots.add(newRoot);
|
|
2011
|
+
this.mutationObserver.observe(newRoot, this.observerConfig);
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* Remove `root` from the list of roots managed by this `DOMLocalization`.
|
|
2015
|
+
*
|
|
2016
|
+
* Additionally, if this `DOMLocalization` has an observer, stop observing
|
|
2017
|
+
* `root`.
|
|
2018
|
+
*
|
|
2019
|
+
* Returns `true` if the root was the last one managed by this
|
|
2020
|
+
* `DOMLocalization`.
|
|
2021
|
+
*
|
|
2022
|
+
* @param {Element} root - Root to disconnect.
|
|
2023
|
+
* @returns {boolean}
|
|
2024
|
+
*/
|
|
2025
|
+
disconnectRoot(root) {
|
|
2026
|
+
this.roots.delete(root);
|
|
2027
|
+
// Pause the mutation observer to stop observing `root`.
|
|
2028
|
+
this.pauseObserving();
|
|
2029
|
+
if (this.roots.size === 0) {
|
|
2030
|
+
this.mutationObserver = null;
|
|
2031
|
+
this.windowElement = null;
|
|
2032
|
+
this.pendingrAF = null;
|
|
2033
|
+
this.pendingElements.clear();
|
|
2034
|
+
return true;
|
|
2035
|
+
}
|
|
2036
|
+
// Resume observing all other roots.
|
|
2037
|
+
this.resumeObserving();
|
|
2038
|
+
return false;
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Translate all roots associated with this `DOMLocalization`.
|
|
2042
|
+
*
|
|
2043
|
+
* @returns {Promise}
|
|
2044
|
+
*/
|
|
2045
|
+
translateRoots() {
|
|
2046
|
+
const roots = Array.from(this.roots);
|
|
2047
|
+
return Promise.all(roots.map(root => this.translateFragment(root)));
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Pauses the `MutationObserver`.
|
|
2051
|
+
*
|
|
2052
|
+
* @private
|
|
2053
|
+
*/
|
|
2054
|
+
pauseObserving() {
|
|
2055
|
+
if (!this.mutationObserver) {
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
this.translateMutations(this.mutationObserver.takeRecords());
|
|
2059
|
+
this.mutationObserver.disconnect();
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* Resumes the `MutationObserver`.
|
|
2063
|
+
*
|
|
2064
|
+
* @private
|
|
2065
|
+
*/
|
|
2066
|
+
resumeObserving() {
|
|
2067
|
+
if (!this.mutationObserver) {
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
for (const root of this.roots) {
|
|
2071
|
+
this.mutationObserver.observe(root, this.observerConfig);
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Translate mutations detected by the `MutationObserver`.
|
|
2076
|
+
*
|
|
2077
|
+
* @private
|
|
2078
|
+
*/
|
|
2079
|
+
translateMutations(mutations) {
|
|
2080
|
+
for (const mutation of mutations) {
|
|
2081
|
+
switch (mutation.type) {
|
|
2082
|
+
case "attributes":
|
|
2083
|
+
if (mutation.target.hasAttribute("data-l10n-id")) {
|
|
2084
|
+
this.pendingElements.add(mutation.target);
|
|
2085
|
+
}
|
|
2086
|
+
break;
|
|
2087
|
+
case "childList":
|
|
2088
|
+
for (const addedNode of mutation.addedNodes) {
|
|
2089
|
+
if (addedNode.nodeType === addedNode.ELEMENT_NODE) {
|
|
2090
|
+
if (addedNode.childElementCount) {
|
|
2091
|
+
for (const element of this.getTranslatables(addedNode)) {
|
|
2092
|
+
this.pendingElements.add(element);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
else if (addedNode.hasAttribute(L10NID_ATTR_NAME)) {
|
|
2096
|
+
this.pendingElements.add(addedNode);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
break;
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
// This fragment allows us to coalesce all pending translations
|
|
2104
|
+
// into a single requestAnimationFrame.
|
|
2105
|
+
if (this.pendingElements.size > 0) {
|
|
2106
|
+
if (this.pendingrAF === null) {
|
|
2107
|
+
this.pendingrAF = this.windowElement.requestAnimationFrame(() => {
|
|
2108
|
+
this.translateElements(Array.from(this.pendingElements));
|
|
2109
|
+
this.pendingElements.clear();
|
|
2110
|
+
this.pendingrAF = null;
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Translate a DOM element or fragment asynchronously using this
|
|
2117
|
+
* `DOMLocalization` object.
|
|
2118
|
+
*
|
|
2119
|
+
* Manually trigger the translation (or re-translation) of a DOM fragment.
|
|
2120
|
+
* Use the `data-l10n-id` and `data-l10n-args` attributes to mark up the DOM
|
|
2121
|
+
* with information about which translations to use.
|
|
2122
|
+
*
|
|
2123
|
+
* Returns a `Promise` that gets resolved once the translation is complete.
|
|
2124
|
+
*
|
|
2125
|
+
* @param {DOMFragment} frag - Element or DocumentFragment to be translated
|
|
2126
|
+
* @returns {Promise}
|
|
2127
|
+
*/
|
|
2128
|
+
translateFragment(frag) {
|
|
2129
|
+
return this.translateElements(this.getTranslatables(frag));
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Translate a list of DOM elements asynchronously using this
|
|
2133
|
+
* `DOMLocalization` object.
|
|
2134
|
+
*
|
|
2135
|
+
* Manually trigger the translation (or re-translation) of a list of elements.
|
|
2136
|
+
* Use the `data-l10n-id` and `data-l10n-args` attributes to mark up the DOM
|
|
2137
|
+
* with information about which translations to use.
|
|
2138
|
+
*
|
|
2139
|
+
* Returns a `Promise` that gets resolved once the translation is complete.
|
|
2140
|
+
*
|
|
2141
|
+
* @param {Array<Element>} elements - List of elements to be translated
|
|
2142
|
+
* @returns {Promise}
|
|
2143
|
+
*/
|
|
2144
|
+
async translateElements(elements) {
|
|
2145
|
+
if (!elements.length) {
|
|
2146
|
+
return undefined;
|
|
2147
|
+
}
|
|
2148
|
+
const keys = elements.map(this.getKeysForElement);
|
|
2149
|
+
const translations = await this.formatMessages(keys);
|
|
2150
|
+
return this.applyTranslations(elements, translations);
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Applies translations onto elements.
|
|
2154
|
+
*
|
|
2155
|
+
* @param {Array<Element>} elements
|
|
2156
|
+
* @param {Array<Object>} translations
|
|
2157
|
+
* @private
|
|
2158
|
+
*/
|
|
2159
|
+
applyTranslations(elements, translations) {
|
|
2160
|
+
this.pauseObserving();
|
|
2161
|
+
for (let i = 0; i < elements.length; i++) {
|
|
2162
|
+
if (translations[i] !== undefined) {
|
|
2163
|
+
translateElement(elements[i], translations[i]);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
this.resumeObserving();
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Collects all translatable child elements of the element.
|
|
2170
|
+
*
|
|
2171
|
+
* @param {Element} element
|
|
2172
|
+
* @returns {Array<Element>}
|
|
2173
|
+
* @private
|
|
2174
|
+
*/
|
|
2175
|
+
getTranslatables(element) {
|
|
2176
|
+
const nodes = Array.from(element.querySelectorAll(L10N_ELEMENT_QUERY));
|
|
2177
|
+
if (typeof element.hasAttribute === "function" &&
|
|
2178
|
+
element.hasAttribute(L10NID_ATTR_NAME)) {
|
|
2179
|
+
nodes.push(element);
|
|
2180
|
+
}
|
|
2181
|
+
return nodes;
|
|
2182
|
+
}
|
|
2183
|
+
/**
|
|
2184
|
+
* Get the `data-l10n-*` attributes from DOM elements as a two-element
|
|
2185
|
+
* array.
|
|
2186
|
+
*
|
|
2187
|
+
* @param {Element} element
|
|
2188
|
+
* @returns {Object}
|
|
2189
|
+
* @private
|
|
2190
|
+
*/
|
|
2191
|
+
getKeysForElement(element) {
|
|
2192
|
+
return {
|
|
2193
|
+
id: element.getAttribute(L10NID_ATTR_NAME),
|
|
2194
|
+
args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null),
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
/* Copyright 2023 Mozilla Foundation
|
|
2200
|
+
*
|
|
2201
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
2202
|
+
* you may not use this file except in compliance with the License.
|
|
2203
|
+
* You may obtain a copy of the License at
|
|
2204
|
+
*
|
|
2205
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
2206
|
+
*
|
|
2207
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
2208
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
2209
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
2210
|
+
* See the License for the specific language governing permissions and
|
|
2211
|
+
* limitations under the License.
|
|
2212
|
+
*/
|
|
2213
|
+
|
|
2214
|
+
/** @typedef {import("./interfaces").IL10n} IL10n */
|
|
2215
|
+
|
|
2216
|
+
/**
|
|
2217
|
+
* NOTE: The L10n-implementations should use lowercase language-codes
|
|
2218
|
+
* internally.
|
|
2219
|
+
* @implements {IL10n}
|
|
2220
|
+
*/
|
|
2221
|
+
class L10n {
|
|
2222
|
+
#dir;
|
|
2223
|
+
#lang;
|
|
2224
|
+
#l10n;
|
|
2225
|
+
constructor({
|
|
2226
|
+
lang,
|
|
2227
|
+
isRTL
|
|
2228
|
+
}, l10n = null) {
|
|
2229
|
+
this.#lang = L10n.#fixupLangCode(lang);
|
|
2230
|
+
this.#l10n = l10n;
|
|
2231
|
+
this.#dir = (isRTL !== null && isRTL !== void 0 ? isRTL : L10n.#isRTL(this.#lang)) ? "rtl" : "ltr";
|
|
2232
|
+
}
|
|
2233
|
+
_setL10n(l10n) {
|
|
2234
|
+
this.#l10n = l10n;
|
|
2235
|
+
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
|
|
2236
|
+
document.l10n = l10n;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
/** @inheritdoc */
|
|
2241
|
+
getLanguage() {
|
|
2242
|
+
return this.#lang;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/** @inheritdoc */
|
|
2246
|
+
getDirection() {
|
|
2247
|
+
return this.#dir;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/** @inheritdoc */
|
|
2251
|
+
async get(ids, args = null, fallback) {
|
|
2252
|
+
if (Array.isArray(ids)) {
|
|
2253
|
+
ids = ids.map(id => ({
|
|
2254
|
+
id
|
|
2255
|
+
}));
|
|
2256
|
+
const messages = await this.#l10n.formatMessages(ids);
|
|
2257
|
+
return messages.map(message => message.value);
|
|
2258
|
+
}
|
|
2259
|
+
const messages = await this.#l10n.formatMessages([{
|
|
2260
|
+
id: ids,
|
|
2261
|
+
args
|
|
2262
|
+
}]);
|
|
2263
|
+
return (messages === null || messages === void 0 ? void 0 : messages[0].value) || fallback;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
/** @inheritdoc */
|
|
2267
|
+
async translate(element) {
|
|
2268
|
+
try {
|
|
2269
|
+
this.#l10n.connectRoot(element);
|
|
2270
|
+
await this.#l10n.translateRoots();
|
|
2271
|
+
} catch {
|
|
2272
|
+
// Element is under an existing root, so there is no need to add it again.
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
/** @inheritdoc */
|
|
2277
|
+
pause() {
|
|
2278
|
+
this.#l10n.pauseObserving();
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/** @inheritdoc */
|
|
2282
|
+
resume() {
|
|
2283
|
+
this.#l10n.resumeObserving();
|
|
2284
|
+
}
|
|
2285
|
+
static #fixupLangCode(langCode) {
|
|
2286
|
+
var _langCode;
|
|
2287
|
+
// Use only lowercase language-codes internally, and fallback to English.
|
|
2288
|
+
langCode = ((_langCode = langCode) === null || _langCode === void 0 ? void 0 : _langCode.toLowerCase()) || "en-us";
|
|
2289
|
+
|
|
2290
|
+
// Try to support "incompletely" specified language codes (see issue 13689).
|
|
2291
|
+
const PARTIAL_LANG_CODES = {
|
|
2292
|
+
en: "en-us",
|
|
2293
|
+
es: "es-es",
|
|
2294
|
+
fy: "fy-nl",
|
|
2295
|
+
ga: "ga-ie",
|
|
2296
|
+
gu: "gu-in",
|
|
2297
|
+
hi: "hi-in",
|
|
2298
|
+
hy: "hy-am",
|
|
2299
|
+
nb: "nb-no",
|
|
2300
|
+
ne: "ne-np",
|
|
2301
|
+
nn: "nn-no",
|
|
2302
|
+
pa: "pa-in",
|
|
2303
|
+
pt: "pt-pt",
|
|
2304
|
+
sv: "sv-se",
|
|
2305
|
+
zh: "zh-cn"
|
|
2306
|
+
};
|
|
2307
|
+
return PARTIAL_LANG_CODES[langCode] || langCode;
|
|
2308
|
+
}
|
|
2309
|
+
static #isRTL(lang) {
|
|
2310
|
+
const shortCode = lang.split("-", 1)[0];
|
|
2311
|
+
return ["ar", "he", "fa", "ps", "ur"].includes(shortCode);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/* Copyright 2017 Mozilla Foundation
|
|
2316
|
+
*
|
|
2317
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
2318
|
+
* you may not use this file except in compliance with the License.
|
|
2319
|
+
* You may obtain a copy of the License at
|
|
2320
|
+
*
|
|
2321
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
2322
|
+
*
|
|
2323
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
2324
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
2325
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
2326
|
+
* See the License for the specific language governing permissions and
|
|
2327
|
+
* limitations under the License.
|
|
2328
|
+
*/
|
|
2329
|
+
|
|
2330
|
+
function createBundle(lang, text) {
|
|
2331
|
+
const resource = new FluentResource(text);
|
|
2332
|
+
const bundle = new FluentBundle(lang);
|
|
2333
|
+
const errors = bundle.addResource(resource);
|
|
2334
|
+
if (errors.length) {
|
|
2335
|
+
console.error("L10n errors", errors);
|
|
2336
|
+
}
|
|
2337
|
+
return bundle;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
/**
|
|
2341
|
+
* @implements {IL10n}
|
|
2342
|
+
*/
|
|
2343
|
+
class GenericL10n extends L10n {
|
|
2344
|
+
constructor(lang) {
|
|
2345
|
+
super({
|
|
2346
|
+
lang
|
|
2347
|
+
});
|
|
2348
|
+
const generateBundles = !lang ? GenericL10n.#generateBundlesFallback.bind(GenericL10n, this.getLanguage()) : GenericL10n.#generateBundles.bind(GenericL10n, "en-us", this.getLanguage());
|
|
2349
|
+
this._setL10n(new DOMLocalization([], generateBundles));
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/**
|
|
2353
|
+
* Generate the bundles for Fluent.
|
|
2354
|
+
* @param {String} defaultLang - The fallback language to use for
|
|
2355
|
+
* translations.
|
|
2356
|
+
* @param {String} baseLang - The base language to use for translations.
|
|
2357
|
+
*/
|
|
2358
|
+
static async *#generateBundles(defaultLang, baseLang) {
|
|
2359
|
+
const {
|
|
2360
|
+
baseURL,
|
|
2361
|
+
paths
|
|
2362
|
+
} = await this.#getPaths();
|
|
2363
|
+
const langs = [baseLang];
|
|
2364
|
+
if (defaultLang !== baseLang) {
|
|
2365
|
+
// Also fallback to the short-format of the base language
|
|
2366
|
+
// (see issue 17269).
|
|
2367
|
+
const shortLang = baseLang.split("-", 1)[0];
|
|
2368
|
+
if (shortLang !== baseLang) {
|
|
2369
|
+
langs.push(shortLang);
|
|
2370
|
+
}
|
|
2371
|
+
langs.push(defaultLang);
|
|
2372
|
+
}
|
|
2373
|
+
for (const lang of langs) {
|
|
2374
|
+
const bundle = await this.#createBundle(lang, baseURL, paths);
|
|
2375
|
+
if (bundle) {
|
|
2376
|
+
yield bundle;
|
|
2377
|
+
}
|
|
2378
|
+
if (lang === "en-us") {
|
|
2379
|
+
yield this.#createBundleFallback(lang);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
static async #createBundle(lang, baseURL, paths) {
|
|
2384
|
+
const path = paths[lang];
|
|
2385
|
+
if (!path) {
|
|
2386
|
+
return null;
|
|
2387
|
+
}
|
|
2388
|
+
const url = new URL(path, baseURL);
|
|
2389
|
+
const text = await fetchData(url, /* type = */"text");
|
|
2390
|
+
return createBundle(lang, text);
|
|
2391
|
+
}
|
|
2392
|
+
static async #getPaths() {
|
|
2393
|
+
try {
|
|
2394
|
+
const {
|
|
2395
|
+
href
|
|
2396
|
+
} = document.querySelector(`link[type="application/l10n"]`);
|
|
2397
|
+
const paths = await fetchData(href, /* type = */"json");
|
|
2398
|
+
return {
|
|
2399
|
+
baseURL: href.replace(/[^/]*$/, "") || "./",
|
|
2400
|
+
paths
|
|
2401
|
+
};
|
|
2402
|
+
} catch {}
|
|
2403
|
+
return {
|
|
2404
|
+
baseURL: "./",
|
|
2405
|
+
paths: Object.create(null)
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
static async *#generateBundlesFallback(lang) {
|
|
2409
|
+
yield this.#createBundleFallback(lang);
|
|
2410
|
+
}
|
|
2411
|
+
static async #createBundleFallback(lang) {
|
|
2412
|
+
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
|
|
2413
|
+
throw new Error("Not implemented: #createBundleFallback");
|
|
2414
|
+
}
|
|
2415
|
+
const text = typeof PDFJSDev === "undefined" ? "# This Source Code Form is subject to the terms of the Mozilla Public\r\n# License, v. 2.0. If a copy of the MPL was not distributed with this\r\n# file, You can obtain one at http://mozilla.org/MPL/2.0/.\r\n\r\n## Main toolbar buttons (tooltips and alt text for images)\r\n\r\npdfjs-previous-button =\r\n .title = Previous Page\r\npdfjs-previous-button-label = Previous\r\npdfjs-next-button =\r\n .title = Next Page\r\npdfjs-next-button-label = Next\r\n\r\n# .title: Tooltip for the pageNumber input.\r\npdfjs-page-input =\r\n .title = Page\r\n\r\n# Variables:\r\n# $pagesCount (Number) - the total number of pages in the document\r\n# This string follows an input field with the number of the page currently displayed.\r\npdfjs-of-pages = of { $pagesCount }\r\n\r\n# Variables:\r\n# $pageNumber (Number) - the currently visible page\r\n# $pagesCount (Number) - the total number of pages in the document\r\npdfjs-page-of-pages = ({ $pageNumber } of { $pagesCount })\r\n\r\npdfjs-zoom-out-button =\r\n .title = Zoom Out\r\npdfjs-zoom-out-button-label = Zoom Out\r\npdfjs-zoom-in-button =\r\n .title = Zoom In\r\npdfjs-zoom-in-button-label = Zoom In\r\npdfjs-zoom-select =\r\n .title = Zoom\r\npdfjs-presentation-mode-button =\r\n .title = Switch to Presentation Mode\r\npdfjs-presentation-mode-button-label = Presentation Mode\r\npdfjs-open-file-button =\r\n .title = Open File\r\npdfjs-open-file-button-label = Open\r\npdfjs-print-button =\r\n .title = Print\r\npdfjs-print-button-label = Print\r\npdfjs-save-button =\r\n .title = Save\r\npdfjs-save-button-label = Save\r\n\r\n# Used in Firefox for Android as a tooltip for the download button (“download” is a verb).\r\npdfjs-download-button =\r\n .title = Download\r\n\r\n# Used in Firefox for Android as a label for the download button (“download” is a verb).\r\n# Length of the translation matters since we are in a mobile context, with limited screen estate.\r\npdfjs-download-button-label = Download\r\n\r\npdfjs-bookmark-button =\r\n .title = Current Page (View URL from Current Page)\r\npdfjs-bookmark-button-label = Current Page\r\n\r\n## Secondary toolbar and context menu\r\n\r\npdfjs-tools-button =\r\n .title = Tools\r\n\r\npdfjs-tools-button-label = Tools\r\npdfjs-first-page-button =\r\n .title = Go to First Page\r\npdfjs-first-page-button-label = Go to First Page\r\npdfjs-last-page-button =\r\n .title = Go to Last Page\r\npdfjs-last-page-button-label = Go to Last Page\r\npdfjs-page-rotate-cw-button =\r\n .title = Rotate Clockwise\r\npdfjs-page-rotate-cw-button-label = Rotate Clockwise\r\npdfjs-page-rotate-ccw-button =\r\n .title = Rotate Counterclockwise\r\npdfjs-page-rotate-ccw-button-label = Rotate Counterclockwise\r\npdfjs-cursor-text-select-tool-button =\r\n .title = Enable Text Selection Tool\r\npdfjs-cursor-text-select-tool-button-label = Text Selection Tool\r\npdfjs-cursor-hand-tool-button =\r\n .title = Enable Hand Tool\r\npdfjs-cursor-hand-tool-button-label = Hand Tool\r\npdfjs-scroll-page-button =\r\n .title = Use Page Scrolling\r\npdfjs-scroll-page-button-label = Page Scrolling\r\npdfjs-scroll-vertical-button =\r\n .title = Use Vertical Scrolling\r\npdfjs-scroll-vertical-button-label = Vertical Scrolling\r\npdfjs-scroll-horizontal-button =\r\n .title = Use Horizontal Scrolling\r\npdfjs-scroll-horizontal-button-label = Horizontal Scrolling\r\npdfjs-scroll-wrapped-button =\r\n .title = Use Wrapped Scrolling\r\npdfjs-scroll-wrapped-button-label = Wrapped Scrolling\r\npdfjs-spread-none-button =\r\n .title = Do not join page spreads\r\npdfjs-spread-none-button-label = No Spreads\r\npdfjs-spread-odd-button =\r\n .title = Join page spreads starting with odd-numbered pages\r\npdfjs-spread-odd-button-label = Odd Spreads\r\npdfjs-spread-even-button =\r\n .title = Join page spreads starting with even-numbered pages\r\npdfjs-spread-even-button-label = Even Spreads\r\n\r\n## Document properties dialog\r\n\r\npdfjs-document-properties-button =\r\n .title = Document Properties…\r\npdfjs-document-properties-button-label = Document Properties…\r\npdfjs-document-properties-file-name = File name:\r\npdfjs-document-properties-file-size = File size:\r\n\r\n# Variables:\r\n# $size_kb (Number) - the PDF file size in kilobytes\r\n# $size_b (Number) - the PDF file size in bytes\r\npdfjs-document-properties-kb = { $size_kb } KB ({ $size_b } bytes)\r\n\r\n# Variables:\r\n# $size_mb (Number) - the PDF file size in megabytes\r\n# $size_b (Number) - the PDF file size in bytes\r\npdfjs-document-properties-mb = { $size_mb } MB ({ $size_b } bytes)\r\n\r\npdfjs-document-properties-title = Title:\r\npdfjs-document-properties-author = Author:\r\npdfjs-document-properties-subject = Subject:\r\npdfjs-document-properties-keywords = Keywords:\r\npdfjs-document-properties-creation-date = Creation Date:\r\npdfjs-document-properties-modification-date = Modification Date:\r\n\r\n# Variables:\r\n# $date (Date) - the creation/modification date of the PDF file\r\n# $time (Time) - the creation/modification time of the PDF file\r\npdfjs-document-properties-date-string = { $date }, { $time }\r\n\r\npdfjs-document-properties-creator = Creator:\r\npdfjs-document-properties-producer = PDF Producer:\r\npdfjs-document-properties-version = PDF Version:\r\npdfjs-document-properties-page-count = Page Count:\r\npdfjs-document-properties-page-size = Page Size:\r\npdfjs-document-properties-page-size-unit-inches = in\r\npdfjs-document-properties-page-size-unit-millimeters = mm\r\npdfjs-document-properties-page-size-orientation-portrait = portrait\r\npdfjs-document-properties-page-size-orientation-landscape = landscape\r\npdfjs-document-properties-page-size-name-a-three = A3\r\npdfjs-document-properties-page-size-name-a-four = A4\r\npdfjs-document-properties-page-size-name-letter = Letter\r\npdfjs-document-properties-page-size-name-legal = Legal\r\n\r\n## Variables:\r\n## $width (Number) - the width of the (current) page\r\n## $height (Number) - the height of the (current) page\r\n## $unit (String) - the unit of measurement of the (current) page\r\n## $name (String) - the name of the (current) page\r\n## $orientation (String) - the orientation of the (current) page\r\n\r\npdfjs-document-properties-page-size-dimension-string = { $width } × { $height } { $unit } ({ $orientation })\r\npdfjs-document-properties-page-size-dimension-name-string = { $width } × { $height } { $unit } ({ $name }, { $orientation })\r\n\r\n##\r\n\r\n# The linearization status of the document; usually called \"Fast Web View\" in\r\n# English locales of Adobe software.\r\npdfjs-document-properties-linearized = Fast Web View:\r\npdfjs-document-properties-linearized-yes = Yes\r\npdfjs-document-properties-linearized-no = No\r\npdfjs-document-properties-close-button = Close\r\n\r\n## Print\r\n\r\npdfjs-print-progress-message = Preparing document for printing…\r\n\r\n# Variables:\r\n# $progress (Number) - percent value\r\npdfjs-print-progress-percent = { $progress }%\r\n\r\npdfjs-print-progress-close-button = Cancel\r\npdfjs-printing-not-supported = Warning: Printing is not fully supported by this browser.\r\npdfjs-printing-not-ready = Warning: The PDF is not fully loaded for printing.\r\n\r\n## Tooltips and alt text for side panel toolbar buttons\r\n\r\npdfjs-toggle-sidebar-button =\r\n .title = Toggle Sidebar\r\npdfjs-toggle-sidebar-notification-button =\r\n .title = Toggle Sidebar (document contains outline/attachments/layers)\r\npdfjs-toggle-sidebar-button-label = Toggle Sidebar\r\npdfjs-document-outline-button =\r\n .title = Show Document Outline (double-click to expand/collapse all items)\r\npdfjs-document-outline-button-label = Document Outline\r\npdfjs-attachments-button =\r\n .title = Show Attachments\r\npdfjs-attachments-button-label = Attachments\r\npdfjs-layers-button =\r\n .title = Show Layers (double-click to reset all layers to the default state)\r\npdfjs-layers-button-label = Layers\r\npdfjs-thumbs-button =\r\n .title = Show Thumbnails\r\npdfjs-thumbs-button-label = Thumbnails\r\npdfjs-current-outline-item-button =\r\n .title = Find Current Outline Item\r\npdfjs-current-outline-item-button-label = Current Outline Item\r\npdfjs-findbar-button =\r\n .title = Find in Document\r\npdfjs-findbar-button-label = Find\r\npdfjs-additional-layers = Additional Layers\r\n\r\n## Thumbnails panel item (tooltip and alt text for images)\r\n\r\n# Variables:\r\n# $page (Number) - the page number\r\npdfjs-thumb-page-title =\r\n .title = Page { $page }\r\n\r\n# Variables:\r\n# $page (Number) - the page number\r\npdfjs-thumb-page-canvas =\r\n .aria-label = Thumbnail of Page { $page }\r\n\r\n## Find panel button title and messages\r\n\r\npdfjs-find-input =\r\n .title = Find\r\n .placeholder = Find in document…\r\npdfjs-find-previous-button =\r\n .title = Find the previous occurrence of the phrase\r\npdfjs-find-previous-button-label = Previous\r\npdfjs-find-next-button =\r\n .title = Find the next occurrence of the phrase\r\npdfjs-find-next-button-label = Next\r\npdfjs-find-highlight-checkbox = Highlight All\r\npdfjs-find-match-case-checkbox-label = Match Case\r\npdfjs-find-match-diacritics-checkbox-label = Match Diacritics\r\npdfjs-find-entire-word-checkbox-label = Whole Words\r\npdfjs-find-reached-top = Reached top of document, continued from bottom\r\npdfjs-find-reached-bottom = Reached end of document, continued from top\r\n\r\n# Variables:\r\n# $current (Number) - the index of the currently active find result\r\n# $total (Number) - the total number of matches in the document\r\npdfjs-find-match-count =\r\n { $total ->\r\n [one] { $current } of { $total } match\r\n *[other] { $current } of { $total } matches\r\n }\r\n\r\n# Variables:\r\n# $limit (Number) - the maximum number of matches\r\npdfjs-find-match-count-limit =\r\n { $limit ->\r\n [one] More than { $limit } match\r\n *[other] More than { $limit } matches\r\n }\r\n\r\npdfjs-find-not-found = Phrase not found\r\n\r\n## Predefined zoom values\r\n\r\npdfjs-page-scale-width = Page Width\r\npdfjs-page-scale-fit = Page Fit\r\npdfjs-page-scale-auto = Automatic Zoom\r\npdfjs-page-scale-actual = Actual Size\r\n\r\n# Variables:\r\n# $scale (Number) - percent value for page scale\r\npdfjs-page-scale-percent = { $scale }%\r\n\r\n## PDF page\r\n\r\n# Variables:\r\n# $page (Number) - the page number\r\npdfjs-page-landmark =\r\n .aria-label = Page { $page }\r\n\r\n## Loading indicator messages\r\n\r\npdfjs-loading-error = An error occurred while loading the PDF.\r\npdfjs-invalid-file-error = Invalid or corrupted PDF file.\r\npdfjs-missing-file-error = Missing PDF file.\r\npdfjs-unexpected-response-error = Unexpected server response.\r\npdfjs-rendering-error = An error occurred while rendering the page.\r\n\r\n## Annotations\r\n\r\n# Variables:\r\n# $date (Date) - the modification date of the annotation\r\n# $time (Time) - the modification time of the annotation\r\npdfjs-annotation-date-string = { $date }, { $time }\r\n\r\n# .alt: This is used as a tooltip.\r\n# Variables:\r\n# $type (String) - an annotation type from a list defined in the PDF spec\r\n# (32000-1:2008 Table 169 – Annotation types).\r\n# Some common types are e.g.: \"Check\", \"Text\", \"Comment\", \"Note\"\r\npdfjs-text-annotation-type =\r\n .alt = [{ $type } Annotation]\r\n\r\n## Password\r\n\r\npdfjs-password-label = Enter the password to open this PDF file.\r\npdfjs-password-invalid = Invalid password. Please try again.\r\npdfjs-password-ok-button = OK\r\npdfjs-password-cancel-button = Cancel\r\npdfjs-web-fonts-disabled = Web fonts are disabled: unable to use embedded PDF fonts.\r\n\r\n## Editing\r\n\r\npdfjs-editor-free-text-button =\r\n .title = Text\r\npdfjs-editor-free-text-button-label = Text\r\npdfjs-editor-ink-button =\r\n .title = Draw\r\npdfjs-editor-ink-button-label = Draw\r\npdfjs-editor-stamp-button =\r\n .title = Add or edit images\r\npdfjs-editor-stamp-button-label = Add or edit images\r\npdfjs-editor-highlight-button =\r\n .title = Highlight\r\npdfjs-editor-highlight-button-label = Highlight\r\npdfjs-highlight-floating-button1 =\r\n .title = Highlight\r\n .aria-label = Highlight\r\npdfjs-highlight-floating-button-label = Highlight\r\n\r\n## Remove button for the various kind of editor.\r\n\r\npdfjs-editor-remove-ink-button =\r\n .title = Remove drawing\r\npdfjs-editor-remove-freetext-button =\r\n .title = Remove text\r\npdfjs-editor-remove-stamp-button =\r\n .title = Remove image\r\npdfjs-editor-remove-highlight-button =\r\n .title = Remove highlight\r\n\r\n##\r\n\r\n# Editor Parameters\r\npdfjs-editor-free-text-color-input = Color\r\npdfjs-editor-free-text-size-input = Size\r\npdfjs-editor-ink-color-input = Color\r\npdfjs-editor-ink-thickness-input = Thickness\r\npdfjs-editor-ink-opacity-input = Opacity\r\npdfjs-editor-stamp-add-image-button =\r\n .title = Add image\r\npdfjs-editor-stamp-add-image-button-label = Add image\r\n# This refers to the thickness of the line used for free highlighting (not bound to text)\r\npdfjs-editor-free-highlight-thickness-input = Thickness\r\npdfjs-editor-free-highlight-thickness-title =\r\n .title = Change thickness when highlighting items other than text\r\n\r\npdfjs-free-text =\r\n .aria-label = Text Editor\r\npdfjs-free-text-default-content = Start typing…\r\npdfjs-ink =\r\n .aria-label = Draw Editor\r\npdfjs-ink-canvas =\r\n .aria-label = User-created image\r\n\r\n## Alt-text dialog\r\n\r\n# Alternative text (alt text) helps when people can't see the image.\r\npdfjs-editor-alt-text-button-label = Alt text\r\n\r\npdfjs-editor-alt-text-edit-button-label = Edit alt text\r\npdfjs-editor-alt-text-dialog-label = Choose an option\r\npdfjs-editor-alt-text-dialog-description = Alt text (alternative text) helps when people can’t see the image or when it doesn’t load.\r\npdfjs-editor-alt-text-add-description-label = Add a description\r\npdfjs-editor-alt-text-add-description-description = Aim for 1-2 sentences that describe the subject, setting, or actions.\r\npdfjs-editor-alt-text-mark-decorative-label = Mark as decorative\r\npdfjs-editor-alt-text-mark-decorative-description = This is used for ornamental images, like borders or watermarks.\r\npdfjs-editor-alt-text-cancel-button = Cancel\r\npdfjs-editor-alt-text-save-button = Save\r\npdfjs-editor-alt-text-decorative-tooltip = Marked as decorative\r\n\r\n# .placeholder: This is a placeholder for the alt text input area\r\npdfjs-editor-alt-text-textarea =\r\n .placeholder = For example, “A young man sits down at a table to eat a meal”\r\n\r\n## Editor resizers\r\n## This is used in an aria label to help to understand the role of the resizer.\r\n\r\npdfjs-editor-resizer-label-top-left = Top left corner — resize\r\npdfjs-editor-resizer-label-top-middle = Top middle — resize\r\npdfjs-editor-resizer-label-top-right = Top right corner — resize\r\npdfjs-editor-resizer-label-middle-right = Middle right — resize\r\npdfjs-editor-resizer-label-bottom-right = Bottom right corner — resize\r\npdfjs-editor-resizer-label-bottom-middle = Bottom middle — resize\r\npdfjs-editor-resizer-label-bottom-left = Bottom left corner — resize\r\npdfjs-editor-resizer-label-middle-left = Middle left — resize\r\n\r\n## Color picker\r\n\r\n# This means \"Color used to highlight text\"\r\npdfjs-editor-highlight-colorpicker-label = Highlight color\r\n\r\npdfjs-editor-colorpicker-button =\r\n .title = Change color\r\npdfjs-editor-colorpicker-dropdown =\r\n .aria-label = Color choices\r\npdfjs-editor-colorpicker-yellow =\r\n .title = Yellow\r\npdfjs-editor-colorpicker-green =\r\n .title = Green\r\npdfjs-editor-colorpicker-blue =\r\n .title = Blue\r\npdfjs-editor-colorpicker-pink =\r\n .title = Pink\r\npdfjs-editor-colorpicker-red =\r\n .title = Red\r\n\r\n## Show all highlights\r\n## This is a toggle button to show/hide all the highlights.\r\n\r\npdfjs-editor-highlight-show-all-button-label = Show all\r\npdfjs-editor-highlight-show-all-button =\r\n .title = Show all\r\n" : PDFJSDev.eval("DEFAULT_FTL");
|
|
2416
|
+
return createBundle(lang, text);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
export { GenericL10n };
|