fluent-transpiler 0.5.0 → 0.6.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/README.md +8 -4
- package/cli.js +8 -88
- package/index.d.ts +2 -2
- package/index.js +101 -64
- package/package.json +17 -9
- package/program.js +89 -0
- package/license.json +0 -28
- package/license.template +0 -2
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ Options:
|
|
|
49
49
|
--comments Include comments in output file.
|
|
50
50
|
--include-key <keys...> Allowed messages to be included. Default to include all.
|
|
51
51
|
--exclude-key <keys...> Ignored messages to be excluded. Default to exclude none.
|
|
52
|
-
--exclude-value <value> Set message to an empty string when it
|
|
52
|
+
--exclude-value <value> Set message to an empty string when it equals this value.
|
|
53
53
|
--variable-notation <variableNotation> What variable notation to use with exports (choices: "camelCase", "pascalCase", "constantCase",
|
|
54
54
|
"snakeCase", default: "camelCase")
|
|
55
55
|
--disable-minify If disabled, all exported messages will have the same interface `(params) => ({value, attributes})`.
|
|
@@ -64,15 +64,19 @@ Options:
|
|
|
64
64
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
65
65
|
| locale | What locale(s) to be used. Multiple can be set to allow for fallback. i.e. `en-CA` |
|
|
66
66
|
| comments | Include comments in output file. Default: `true` |
|
|
67
|
-
| includeKey | Array of message keys to include. Default: `[]` (include all) |
|
|
68
|
-
| excludeKey | Array of message keys to exclude. Default: `[]` (exclude none) |
|
|
69
|
-
| excludeValue | Set message to an empty string when it
|
|
67
|
+
| includeKey | Array of message keys to include; matches the exported name (`msgOne`) or the original FTL id (`msg-one`). Non-included messages remain private consts so references keep working. Default: `[]` (include all) |
|
|
68
|
+
| excludeKey | Array of message keys to exclude; matches the exported name (`msgOne`) or the original FTL id (`msg-one`). Excluded messages remain private consts so references keep working. Default: `[]` (exclude none) |
|
|
69
|
+
| excludeValue | Set message to an empty string when it equals this value. Default: `undefined` |
|
|
70
70
|
| disableMinify | If disabled, all exported messages will have the same interface `(params) => ({value, attributes})`. Default: each exported message could be a different type based on what is needed to generate the message (`string`, `object`, `() => ''`, `() => ({})`) |
|
|
71
71
|
| errorOnJunk | Throw error when `Junk` is parsed. Default: `true` |
|
|
72
72
|
| variableNotation | What variable notation to use with exports. Choices: `camelCase`, `pascalCase`, `snakeCase`, `constantCase`. Default: `camelCase` |
|
|
73
73
|
| useIsolating | Wrap placeable with \u2068 and \u2069. Default: `false` |
|
|
74
|
+
| params | Parameter name used in generated message functions. Default: `params` |
|
|
74
75
|
| exportDefault | Allows the overwriting of the `export default` to allow for custom uses. Default: See code |
|
|
75
76
|
|
|
77
|
+
Messages and terms must be defined before they are referenced; referencing a
|
|
78
|
+
later definition is a compile error.
|
|
79
|
+
|
|
76
80
|
```javascript
|
|
77
81
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
78
82
|
import fluentTranspiler from 'fluent-transpiler'
|
package/cli.js
CHANGED
|
@@ -2,91 +2,11 @@
|
|
|
2
2
|
// Copyright 2026 will Farrell, and fluent-transpiler contributors.
|
|
3
3
|
// SPDX-License-Identifier: MIT
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
new Command()
|
|
17
|
-
.name("ftl")
|
|
18
|
-
.description("Compile Fluent (.ftl) files to JavaScript (.js or .mjs)")
|
|
19
|
-
.argument(
|
|
20
|
-
"<inputs...>",
|
|
21
|
-
"Paths to the Fluent file(s) to compile. Multiple files are joined in order; ids must be unique across the set.",
|
|
22
|
-
)
|
|
23
|
-
.requiredOption(
|
|
24
|
-
"--locale <locale...>",
|
|
25
|
-
"What locale(s) to be used. Multiple can be set to allow for fallback. i.e. en-CA",
|
|
26
|
-
)
|
|
27
|
-
.addOption(
|
|
28
|
-
new Option("--comments", "Include comments in output file.").preset(true),
|
|
29
|
-
)
|
|
30
|
-
.addOption(
|
|
31
|
-
new Option(
|
|
32
|
-
"--include-key <includeMessageKey...>",
|
|
33
|
-
"Allowed messages to be included. Default to include all.",
|
|
34
|
-
),
|
|
35
|
-
)
|
|
36
|
-
.addOption(
|
|
37
|
-
new Option(
|
|
38
|
-
"--exclude-key <excludeMessageKey...>",
|
|
39
|
-
"Ignored messages to be excluded. Default to exclude none.",
|
|
40
|
-
),
|
|
41
|
-
)
|
|
42
|
-
.addOption(
|
|
43
|
-
new Option(
|
|
44
|
-
"--exclude-value <excludeMessageValue>",
|
|
45
|
-
"Set message to an empty string when it contains this value. Default to not allowing empty strings.",
|
|
46
|
-
),
|
|
47
|
-
)
|
|
48
|
-
.addOption(
|
|
49
|
-
new Option(
|
|
50
|
-
"--variable-notation <variableNotation>",
|
|
51
|
-
"What variable notation to use with exports",
|
|
52
|
-
)
|
|
53
|
-
.choices(["camelCase", "pascalCase", "constantCase", "snakeCase"])
|
|
54
|
-
.default("camelCase"),
|
|
55
|
-
)
|
|
56
|
-
.addOption(
|
|
57
|
-
new Option(
|
|
58
|
-
"--disable-minify",
|
|
59
|
-
"If disabled, all exported messages will have the same interface `(params) => ({value, attributes})`.",
|
|
60
|
-
).preset(true),
|
|
61
|
-
)
|
|
62
|
-
.addOption(
|
|
63
|
-
new Option(
|
|
64
|
-
"--use-isolating",
|
|
65
|
-
"Wrap placeable with \\u2068 and \\u2069.",
|
|
66
|
-
).preset(true),
|
|
67
|
-
)
|
|
68
|
-
.addOption(
|
|
69
|
-
new Option(
|
|
70
|
-
"-o, --output <output>",
|
|
71
|
-
"Path to store the resulting JavaScript file. Will be in ESM.",
|
|
72
|
-
),
|
|
73
|
-
)
|
|
74
|
-
.action(async (inputs, options) => {
|
|
75
|
-
options.comments = options.comments ?? false;
|
|
76
|
-
try {
|
|
77
|
-
for (const input of inputs) {
|
|
78
|
-
await fileExists(input);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const js = await compileFiles(inputs, options);
|
|
82
|
-
if (options.output) {
|
|
83
|
-
await writeFile(options.output, js, "utf8");
|
|
84
|
-
} else {
|
|
85
|
-
console.log(js);
|
|
86
|
-
}
|
|
87
|
-
} catch (e) {
|
|
88
|
-
console.error(`Error: ${e.message}`);
|
|
89
|
-
process.exit(1);
|
|
90
|
-
}
|
|
91
|
-
})
|
|
92
|
-
.parse();
|
|
5
|
+
import { createProgram } from "./program.js";
|
|
6
|
+
|
|
7
|
+
createProgram()
|
|
8
|
+
.parseAsync()
|
|
9
|
+
.catch((e) => {
|
|
10
|
+
console.error(`Error: ${e.message}`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
});
|
package/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
export interface CompileOptions {
|
|
5
5
|
/** What locale(s) to be used. Multiple can be set to allow for fallback. */
|
|
6
|
-
locale
|
|
6
|
+
locale: string | string[];
|
|
7
7
|
/** Include comments in output file. Default: `true` */
|
|
8
8
|
comments?: boolean;
|
|
9
9
|
/** Throw error when `Junk` is parsed. Default: `true` */
|
|
@@ -12,7 +12,7 @@ export interface CompileOptions {
|
|
|
12
12
|
includeKey?: string | string[];
|
|
13
13
|
/** Array of message keys to exclude. Default: `[]` (exclude none) */
|
|
14
14
|
excludeKey?: string | string[];
|
|
15
|
-
/** Set message to an empty string when it
|
|
15
|
+
/** Set message to an empty string when it equals this value. */
|
|
16
16
|
excludeValue?: string;
|
|
17
17
|
/** What variable notation to use with exports. Default: `"camelCase"` */
|
|
18
18
|
variableNotation?: "camelCase" | "pascalCase" | "snakeCase" | "constantCase";
|
package/index.js
CHANGED
|
@@ -22,10 +22,10 @@ const checkDuplicates = (sources) => {
|
|
|
22
22
|
for (const { label, src } of sources) {
|
|
23
23
|
for (const id of collectTopLevelIds(src)) {
|
|
24
24
|
const prior = seen.get(id);
|
|
25
|
-
if (prior
|
|
26
|
-
duplicates.push({ id, a: prior, b: label });
|
|
27
|
-
} else if (prior === undefined) {
|
|
25
|
+
if (prior === undefined) {
|
|
28
26
|
seen.set(id, label);
|
|
27
|
+
} else if (prior !== label) {
|
|
28
|
+
duplicates.push({ id, a: prior, b: label });
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -107,7 +107,7 @@ const reservedWords = new Set([
|
|
|
107
107
|
]);
|
|
108
108
|
|
|
109
109
|
const exportDefault = `(id, params) => {
|
|
110
|
-
const source = __exports[id]
|
|
110
|
+
const source = __exports[id]
|
|
111
111
|
if (typeof source === 'undefined') return '*** '+id+' ***'
|
|
112
112
|
if (typeof source === 'function') return source(params)
|
|
113
113
|
return source
|
|
@@ -123,6 +123,9 @@ export const compile = (src, opts) => {
|
|
|
123
123
|
comments: true,
|
|
124
124
|
errorOnJunk: true,
|
|
125
125
|
includeKey: [],
|
|
126
|
+
// Stryker disable next-line ArrayDeclaration: equivalent mutant — a bogus
|
|
127
|
+
// exclude key can never match a generated message id, so seeding the
|
|
128
|
+
// default with an entry produces identical output.
|
|
126
129
|
excludeKey: [],
|
|
127
130
|
excludeValue: undefined,
|
|
128
131
|
variableNotation: "camelCase",
|
|
@@ -149,9 +152,16 @@ export const compile = (src, opts) => {
|
|
|
149
152
|
|
|
150
153
|
const compileAssignment = (data) => {
|
|
151
154
|
variable = compileType(data);
|
|
155
|
+
if (metadata[variable] !== undefined) {
|
|
156
|
+
// Two declarations of one name (repeated id, term/message overlap, or
|
|
157
|
+
// ids that merge under the notation transform) would emit duplicate
|
|
158
|
+
// `const` declarations — an unloadable module.
|
|
159
|
+
throw new Error(`Duplicate identifier "${variable}"`, {
|
|
160
|
+
cause: { id: data.name },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
152
163
|
metadata[variable] = {
|
|
153
164
|
id: data.name,
|
|
154
|
-
term: false,
|
|
155
165
|
params: false,
|
|
156
166
|
};
|
|
157
167
|
return variable;
|
|
@@ -159,12 +169,11 @@ export const compile = (src, opts) => {
|
|
|
159
169
|
|
|
160
170
|
const compileFunctionArguments = (data) => {
|
|
161
171
|
const positional = data.arguments?.positional.map((data) => {
|
|
162
|
-
return
|
|
172
|
+
return compileType(data);
|
|
163
173
|
});
|
|
164
174
|
const named = data.arguments?.named.reduce((obj, data) => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
obj[key] = value;
|
|
175
|
+
// `NamedArgument` uses `name` instead of `id`; never transform it
|
|
176
|
+
obj[data.name.name] = compileType(data.value, data.type);
|
|
168
177
|
return obj;
|
|
169
178
|
}, {});
|
|
170
179
|
return { positional, named };
|
|
@@ -200,11 +209,18 @@ export const compile = (src, opts) => {
|
|
|
200
209
|
return ` ${key}: ${value}`;
|
|
201
210
|
},
|
|
202
211
|
Pattern: (data, parent) => {
|
|
212
|
+
// Parity with @fluent/bundle: placeables are only isolated when the
|
|
213
|
+
// pattern mixes them with other elements.
|
|
214
|
+
const isolate = options.useIsolating && data.elements.length > 1;
|
|
203
215
|
return (
|
|
204
216
|
"`" +
|
|
205
217
|
data.elements
|
|
206
218
|
.map((data) => {
|
|
207
|
-
|
|
219
|
+
const value = compileType(data, parent);
|
|
220
|
+
if (isolate && data.type === "Placeable") {
|
|
221
|
+
return `\u2068${value}\u2069`;
|
|
222
|
+
}
|
|
223
|
+
return value;
|
|
208
224
|
})
|
|
209
225
|
.join("") +
|
|
210
226
|
"`"
|
|
@@ -213,8 +229,7 @@ export const compile = (src, opts) => {
|
|
|
213
229
|
// resources
|
|
214
230
|
Term: (data) => {
|
|
215
231
|
const assignment = compileAssignment(data.id);
|
|
216
|
-
const templateStringLiteral = compileType(data.value);
|
|
217
|
-
metadata[assignment].term = true;
|
|
232
|
+
const templateStringLiteral = compileType(data.value, data.type);
|
|
218
233
|
if (metadata[assignment].params) {
|
|
219
234
|
return `const ${assignment} = (${options.params}) => ${templateStringLiteral}\n`;
|
|
220
235
|
}
|
|
@@ -223,20 +238,6 @@ export const compile = (src, opts) => {
|
|
|
223
238
|
Message: (data) => {
|
|
224
239
|
const assignment = compileAssignment(data.id);
|
|
225
240
|
|
|
226
|
-
if (
|
|
227
|
-
options.includeKey.length &&
|
|
228
|
-
!options.includeKey.includes(assignment)
|
|
229
|
-
) {
|
|
230
|
-
return "";
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
if (
|
|
234
|
-
options.excludeKey.length &&
|
|
235
|
-
options.excludeKey.includes(assignment)
|
|
236
|
-
) {
|
|
237
|
-
return "";
|
|
238
|
-
}
|
|
239
|
-
|
|
240
241
|
let templateStringLiteral =
|
|
241
242
|
data.value && compileType(data.value, data.type);
|
|
242
243
|
|
|
@@ -245,7 +246,7 @@ export const compile = (src, opts) => {
|
|
|
245
246
|
}
|
|
246
247
|
|
|
247
248
|
metadata[assignment].attributes = data.attributes.length;
|
|
248
|
-
let attributes = {};
|
|
249
|
+
let attributes = "{}";
|
|
249
250
|
if (metadata[assignment].attributes) {
|
|
250
251
|
attributes = `{\n${data.attributes
|
|
251
252
|
.map((data) => {
|
|
@@ -254,7 +255,7 @@ export const compile = (src, opts) => {
|
|
|
254
255
|
.join(",\n")}\n }`;
|
|
255
256
|
}
|
|
256
257
|
|
|
257
|
-
let message
|
|
258
|
+
let message;
|
|
258
259
|
if (!options.disableMinify) {
|
|
259
260
|
if (metadata[assignment].attributes) {
|
|
260
261
|
if (metadata[assignment].params) {
|
|
@@ -281,10 +282,25 @@ export const compile = (src, opts) => {
|
|
|
281
282
|
})\n`;
|
|
282
283
|
}
|
|
283
284
|
|
|
284
|
-
|
|
285
|
+
// Filters match the exported name or the original FTL id.
|
|
286
|
+
const id = metadata[assignment].id;
|
|
287
|
+
if (
|
|
288
|
+
(options.includeKey.length &&
|
|
289
|
+
!options.includeKey.includes(assignment) &&
|
|
290
|
+
!options.includeKey.includes(id)) ||
|
|
291
|
+
options.excludeKey.includes(assignment) ||
|
|
292
|
+
options.excludeKey.includes(id)
|
|
293
|
+
) {
|
|
294
|
+
// Filtered messages stay as private consts: other messages may
|
|
295
|
+
// reference them, so dropping the declaration entirely would
|
|
296
|
+
// break the generated module.
|
|
297
|
+
return `const ${assignment} = ${message}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (assignment === id) {
|
|
285
301
|
exports.push(`${assignment}`);
|
|
286
302
|
} else {
|
|
287
|
-
exports.push(`'${
|
|
303
|
+
exports.push(`'${id}': ${assignment}`);
|
|
288
304
|
}
|
|
289
305
|
return `export const ${assignment} = ${message}`;
|
|
290
306
|
},
|
|
@@ -308,13 +324,12 @@ export const compile = (src, opts) => {
|
|
|
308
324
|
},
|
|
309
325
|
// Element
|
|
310
326
|
TextElement: (data) => {
|
|
311
|
-
|
|
327
|
+
// escape for template literal; backslashes first so the escapes
|
|
328
|
+
// added for backticks are not themselves escaped
|
|
329
|
+
return data.value.replaceAll("\\", "\\\\").replaceAll("`", "\\`");
|
|
312
330
|
},
|
|
313
331
|
Placeable: (data, parent) => {
|
|
314
|
-
return
|
|
315
|
-
data.expression,
|
|
316
|
-
parent,
|
|
317
|
-
)}}${options.useIsolating ? "\u2069" : ""}`;
|
|
332
|
+
return `\${${compileType(data.expression, parent)}}`;
|
|
318
333
|
},
|
|
319
334
|
// Expression
|
|
320
335
|
StringLiteral: (data, parent) => {
|
|
@@ -324,24 +339,36 @@ export const compile = (src, opts) => {
|
|
|
324
339
|
}
|
|
325
340
|
return `"${data.value}"`;
|
|
326
341
|
},
|
|
327
|
-
NumberLiteral: (data) => {
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
342
|
+
NumberLiteral: (data, parent) => {
|
|
343
|
+
const number = Number.parseFloat(data.value);
|
|
344
|
+
// Pattern text positions display the locale-formatted number (as a
|
|
345
|
+
// string literal: bare `1,000` inside `${}` is a comma expression).
|
|
346
|
+
// Argument, selector, and variant-key positions need the raw value
|
|
347
|
+
// for Intl options and variant matching.
|
|
348
|
+
if (["Message", "Term", "Variant", "Attribute"].includes(parent)) {
|
|
349
|
+
return JSON.stringify(Intl.NumberFormat(options.locale).format(number));
|
|
350
|
+
}
|
|
351
|
+
return number;
|
|
333
352
|
},
|
|
334
353
|
VariableReference: (data, parent) => {
|
|
335
354
|
functions.__formatVariable = true;
|
|
336
355
|
metadata[variable].params = true;
|
|
337
|
-
|
|
356
|
+
// Fluent identifiers allow dashes; those need bracket access in JS
|
|
357
|
+
const value = data.id.name.includes("-")
|
|
358
|
+
? `${options.params}?.[${JSON.stringify(data.id.name)}]`
|
|
359
|
+
: `${options.params}?.${data.id.name}`;
|
|
338
360
|
if (["Message", "Variant", "Attribute"].includes(parent)) {
|
|
339
|
-
return `__formatVariable(${value})`;
|
|
361
|
+
return `__formatVariable(${value}, ${JSON.stringify(data.id.name)})`;
|
|
340
362
|
}
|
|
341
363
|
return value;
|
|
342
364
|
},
|
|
343
365
|
MessageReference: (data) => {
|
|
344
366
|
const messageName = compileType(data.id);
|
|
367
|
+
if (metadata[messageName] === undefined) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
`Unknown reference "${data.id.name}" (messages and terms must be defined before they are referenced)`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
345
372
|
metadata[variable].params ||= metadata[messageName].params;
|
|
346
373
|
if (!options.disableMinify) {
|
|
347
374
|
if (metadata[messageName].params) {
|
|
@@ -355,6 +382,11 @@ export const compile = (src, opts) => {
|
|
|
355
382
|
},
|
|
356
383
|
TermReference: (data) => {
|
|
357
384
|
const termName = compileType(data.id);
|
|
385
|
+
if (metadata[termName] === undefined) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`Unknown reference "${data.id.name}" (messages and terms must be defined before they are referenced)`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
358
390
|
metadata[variable].params ||= metadata[termName].params;
|
|
359
391
|
|
|
360
392
|
let params;
|
|
@@ -378,12 +410,6 @@ export const compile = (src, opts) => {
|
|
|
378
410
|
}
|
|
379
411
|
return `${termName}(${params ? params : ""})`;
|
|
380
412
|
},
|
|
381
|
-
NamedArgument: (data) => {
|
|
382
|
-
// Inconsistent: `NamedArgument` uses `name` instead of `id` for Identifier
|
|
383
|
-
const key = data.name.name; // Don't transform value
|
|
384
|
-
const value = compileType(data.value, data.type);
|
|
385
|
-
return `${key}: ${value}`;
|
|
386
|
-
},
|
|
387
413
|
SelectExpression: (data) => {
|
|
388
414
|
functions.__select = true;
|
|
389
415
|
metadata[variable].params = true;
|
|
@@ -401,16 +427,29 @@ export const compile = (src, opts) => {
|
|
|
401
427
|
})
|
|
402
428
|
.join(",\n")}\n },\n ${fallback}\n )`;
|
|
403
429
|
},
|
|
404
|
-
Variant: (data
|
|
405
|
-
//
|
|
406
|
-
|
|
430
|
+
Variant: (data) => {
|
|
431
|
+
// Variant keys are runtime match keys (plural categories, selector
|
|
432
|
+
// values), never identifiers — emit them verbatim. Numeric keys use
|
|
433
|
+
// the raw value so `cases[1000]` can match (`'1,000'` never would).
|
|
434
|
+
const key =
|
|
435
|
+
data.key.type === "Identifier"
|
|
436
|
+
? data.key.name
|
|
437
|
+
: Number.parseFloat(data.key.value);
|
|
407
438
|
const value = compileType(data.value, data.type);
|
|
408
439
|
return ` '${key}': ${value}`;
|
|
409
440
|
},
|
|
410
441
|
FunctionReference: (data) => {
|
|
411
|
-
|
|
442
|
+
const fn = functionTypes[data.id.name];
|
|
443
|
+
if (fn === undefined) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
`Unknown function "${data.id.name}" (supported: DATETIME, NUMBER, RELATIVETIME)`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return fn(compileFunctionArguments(data));
|
|
412
449
|
},
|
|
413
|
-
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const functionTypes = {
|
|
414
453
|
DATETIME: (data) => {
|
|
415
454
|
functions.__formatDateTime = true;
|
|
416
455
|
const { positional, named } = data;
|
|
@@ -431,9 +470,7 @@ export const compile = (src, opts) => {
|
|
|
431
470
|
},
|
|
432
471
|
};
|
|
433
472
|
|
|
434
|
-
|
|
435
|
-
src = src.replace(/\t/g, " ");
|
|
436
|
-
}
|
|
473
|
+
src = src.replace(/\t/g, " ");
|
|
437
474
|
|
|
438
475
|
const { body } = parse(src);
|
|
439
476
|
let translations = ``;
|
|
@@ -441,7 +478,7 @@ export const compile = (src, opts) => {
|
|
|
441
478
|
translations += compileType(data);
|
|
442
479
|
}
|
|
443
480
|
|
|
444
|
-
let output =
|
|
481
|
+
let output = `// Generated by fluent-transpiler. Do not edit.\n`;
|
|
445
482
|
if (
|
|
446
483
|
functions.__formatVariable ||
|
|
447
484
|
functions.__formatDateTime ||
|
|
@@ -483,7 +520,7 @@ const __relativeTimeDiff = (d) => {
|
|
|
483
520
|
return [Math.round(elapsed / msPerYear), 'year']
|
|
484
521
|
}
|
|
485
522
|
const __formatRelativeTime = (value, options) => {
|
|
486
|
-
if (
|
|
523
|
+
if (!(value instanceof Date)) value = new Date(value)
|
|
487
524
|
if (isNaN(value.getTime())) return value
|
|
488
525
|
try {
|
|
489
526
|
const [duration, unit] = __relativeTimeDiff(value)
|
|
@@ -500,7 +537,7 @@ const __formatRelativeTime = (value, options) => {
|
|
|
500
537
|
if (functions.__formatDateTime) {
|
|
501
538
|
output += `
|
|
502
539
|
const __formatDateTime = (value, options) => {
|
|
503
|
-
if (
|
|
540
|
+
if (!(value instanceof Date)) value = new Date(value)
|
|
504
541
|
if (isNaN(value.getTime())) return value
|
|
505
542
|
const k = JSON.stringify(options) ?? ''
|
|
506
543
|
return (__intlCache['D'+k] ??= new Intl.DateTimeFormat(__locales, options)).format(value)
|
|
@@ -517,8 +554,9 @@ const __formatNumber = (value, options) => {
|
|
|
517
554
|
}
|
|
518
555
|
if (functions.__formatVariable) {
|
|
519
556
|
output += `
|
|
520
|
-
const __formatVariable = (value) => {
|
|
557
|
+
const __formatVariable = (value, name) => {
|
|
521
558
|
if (typeof value === 'string') return value
|
|
559
|
+
if (value === undefined || value === null) return '{$'+name+'}'
|
|
522
560
|
const decimal = Number.parseFloat(value)
|
|
523
561
|
const number = Number.isInteger(decimal) ? Number.parseInt(value, 10) : decimal
|
|
524
562
|
return __formatNumber(number)
|
|
@@ -527,9 +565,8 @@ const __formatVariable = (value) => {
|
|
|
527
565
|
}
|
|
528
566
|
if (functions.__select) {
|
|
529
567
|
output += `
|
|
530
|
-
const __select = (value, cases, fallback
|
|
531
|
-
const
|
|
532
|
-
const rule = (__intlCache['P'+k] ??= new Intl.PluralRules(__locales, options)).select(value)
|
|
568
|
+
const __select = (value, cases, fallback) => {
|
|
569
|
+
const rule = (__intlCache.P ??= new Intl.PluralRules(__locales)).select(value)
|
|
533
570
|
return cases[value] ?? cases[rule] ?? fallback
|
|
534
571
|
}
|
|
535
572
|
`;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
".github"
|
|
4
4
|
],
|
|
5
5
|
"name": "fluent-transpiler",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.6.0",
|
|
7
7
|
"description": "Transpile Fluent (ftl) files into optimized, tree-shakable, JavaScript EcmaScript Modules (esm).",
|
|
8
8
|
"main": "index.js",
|
|
9
9
|
"types": "index.d.ts",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"cli.js",
|
|
27
|
+
"program.js",
|
|
27
28
|
"index.js",
|
|
28
29
|
"index.d.ts"
|
|
29
30
|
],
|
|
@@ -35,18 +36,21 @@
|
|
|
35
36
|
"git:unit-staged": "node --test",
|
|
36
37
|
"git:test-staged": "npm run git:unit-staged",
|
|
37
38
|
"lint": "biome check --write --no-errors-on-unmatched",
|
|
38
|
-
"test": "npm run test:lint && npm run test:unit && npm run test:types && npm run test:sast && npm run test:perf && npm run test:dast",
|
|
39
|
+
"test": "npm run test:lint && npm run test:unit && npm run test:mutation && npm run test:types && npm run test:sast && npm run test:perf && npm run test:dast",
|
|
39
40
|
"test:lint": "biome check --staged --no-errors-on-unmatched",
|
|
40
41
|
"test:unit": "node --test --test-force-exit --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 ./**/*.test.js",
|
|
42
|
+
"test:mutation": "stryker run",
|
|
41
43
|
"test:types": "tstyche",
|
|
42
44
|
"test:perf": "node --test --test-concurrency=1 ./**/*.perf.js",
|
|
43
45
|
"test:sast": "npm run test:sast:license && npm run test:sast:lockfile && npm run test:sast:semgrep && npm run test:sast:trufflehog && npm run test:sast:gitleaks && npm run test:sast:actionlint && npm run test:sast:zizmor && npm run test:sast:trivy",
|
|
44
46
|
"test:sast:actionlint": "actionlint",
|
|
45
|
-
"test:sast:gitleaks": "gitleaks
|
|
46
|
-
"test:sast:
|
|
47
|
+
"test:sast:gitleaks": "npm run test:sast:gitleaks:dir && npm run test:sast:gitleaks:git",
|
|
48
|
+
"test:sast:gitleaks:dir": "gitleaks dir . --redact --no-banner",
|
|
49
|
+
"test:sast:gitleaks:git": "gitleaks git . --redact --no-banner",
|
|
50
|
+
"test:sast:license": "license-check-and-add check -f .license.config.json",
|
|
47
51
|
"test:sast:lockfile": "lockfile-lint --path package-lock.json --type npm --allowed-schemes \"https:\" --allowed-hosts npm --validate-integrity --validate-package-names",
|
|
48
|
-
"test:sast:semgrep": "semgrep scan --config auto",
|
|
49
|
-
"test:sast:trivy": "trivy fs --scanners vuln,license --include-dev-deps --ignored-licenses 0BSD,Apache-2.0,BSD-1-Clause,BSD-2-Clause,BSD-3-Clause,CC0-1.0,CC-BY-4.0,ISC,MIT,Python-2.0 --exit-code 1 --disable-telemetry .",
|
|
52
|
+
"test:sast:semgrep": "semgrep scan --config auto --error",
|
|
53
|
+
"test:sast:trivy": "trivy fs --scanners vuln,license --include-dev-deps --ignored-licenses 0BSD,Apache-2.0,BSD-1-Clause,BSD-2-Clause,BSD-3-Clause,CC0-1.0,CC-BY-4.0,ISC,MIT,Python-2.0,LGPL-3.0-or-later,MPL-2.0,BlueOak-1.0.0,Unlicense --exit-code 1 --skip-files '**/bun.lock' --disable-telemetry .",
|
|
50
54
|
"test:sast:trufflehog": "trufflehog filesystem --only-verified --log-level=-1 ./",
|
|
51
55
|
"test:sast:zizmor": "zizmor .github/workflows/",
|
|
52
56
|
"test:dast": "npm run test:dast:fuzz",
|
|
@@ -55,8 +59,8 @@
|
|
|
55
59
|
"rm:macos": "find . -name '.DS_Store' -type f -delete",
|
|
56
60
|
"rm:lock": "find . -name 'package-lock.json' -type f -delete",
|
|
57
61
|
"rm:node_modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
|
|
58
|
-
"release:license:add": "license-check-and-add add -f license.json",
|
|
59
|
-
"release:license:remove": "license-check-and-add remove -f license.json"
|
|
62
|
+
"release:license:add": "license-check-and-add add -f .license.config.json",
|
|
63
|
+
"release:license:remove": "license-check-and-add remove -f .license.config.json"
|
|
60
64
|
},
|
|
61
65
|
"repository": {
|
|
62
66
|
"type": "git",
|
|
@@ -83,13 +87,17 @@
|
|
|
83
87
|
"dependencies": {
|
|
84
88
|
"@fluent/syntax": "0.19.0",
|
|
85
89
|
"change-case": "5.4.4",
|
|
86
|
-
"commander": "
|
|
90
|
+
"commander": "15.0.0"
|
|
91
|
+
},
|
|
92
|
+
"overrides": {
|
|
93
|
+
"qs": "^6.15.2"
|
|
87
94
|
},
|
|
88
95
|
"devDependencies": {
|
|
89
96
|
"@biomejs/biome": "^2.0.0",
|
|
90
97
|
"@commitlint/cli": "^21.0.0",
|
|
91
98
|
"@commitlint/config-conventional": "^21.0.0",
|
|
92
99
|
"@fluent/bundle": "^0.19.0",
|
|
100
|
+
"@stryker-mutator/core": "^9.0.0",
|
|
93
101
|
"fast-check": "^4.0.0",
|
|
94
102
|
"husky": "^9.0.0",
|
|
95
103
|
"license-check-and-add": "4.0.5",
|
package/program.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Copyright 2026 will Farrell, and fluent-transpiler contributors.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { Command, Option } from "commander";
|
|
6
|
+
import { compileFiles } from "./index.js";
|
|
7
|
+
|
|
8
|
+
const fileExists = async (filepath) => {
|
|
9
|
+
const stats = await stat(filepath);
|
|
10
|
+
if (!stats.isFile()) {
|
|
11
|
+
throw new Error(`${filepath} is not a file`);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const createProgram = () =>
|
|
16
|
+
new Command()
|
|
17
|
+
.name("ftl")
|
|
18
|
+
.description("Compile Fluent (.ftl) files to JavaScript (.js or .mjs)")
|
|
19
|
+
.argument(
|
|
20
|
+
"<inputs...>",
|
|
21
|
+
"Paths to the Fluent file(s) to compile. Multiple files are joined in order; ids must be unique across the set.",
|
|
22
|
+
)
|
|
23
|
+
.requiredOption(
|
|
24
|
+
"--locale <locale...>",
|
|
25
|
+
"What locale(s) to be used. Multiple can be set to allow for fallback. i.e. en-CA",
|
|
26
|
+
)
|
|
27
|
+
.addOption(
|
|
28
|
+
new Option("--comments", "Include comments in output file.").preset(true),
|
|
29
|
+
)
|
|
30
|
+
.addOption(
|
|
31
|
+
new Option(
|
|
32
|
+
"--include-key <includeMessageKey...>",
|
|
33
|
+
"Allowed messages to be included. Default to include all.",
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
.addOption(
|
|
37
|
+
new Option(
|
|
38
|
+
"--exclude-key <excludeMessageKey...>",
|
|
39
|
+
"Ignored messages to be excluded. Default to exclude none.",
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
.addOption(
|
|
43
|
+
new Option(
|
|
44
|
+
"--exclude-value <excludeMessageValue>",
|
|
45
|
+
"Set message to an empty string when it equals this value. Default to not allowing empty strings.",
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
.addOption(
|
|
49
|
+
new Option(
|
|
50
|
+
"--variable-notation <variableNotation>",
|
|
51
|
+
"What variable notation to use with exports",
|
|
52
|
+
)
|
|
53
|
+
.choices(["camelCase", "pascalCase", "constantCase", "snakeCase"])
|
|
54
|
+
.default("camelCase"),
|
|
55
|
+
)
|
|
56
|
+
.addOption(
|
|
57
|
+
new Option(
|
|
58
|
+
"--disable-minify",
|
|
59
|
+
"If disabled, all exported messages will have the same interface `(params) => ({value, attributes})`.",
|
|
60
|
+
).preset(true),
|
|
61
|
+
)
|
|
62
|
+
.addOption(
|
|
63
|
+
new Option(
|
|
64
|
+
"--use-isolating",
|
|
65
|
+
"Wrap placeable with \\u2068 and \\u2069.",
|
|
66
|
+
).preset(true),
|
|
67
|
+
)
|
|
68
|
+
.addOption(
|
|
69
|
+
new Option(
|
|
70
|
+
"-o, --output <output>",
|
|
71
|
+
"Path to store the resulting JavaScript file. Will be in ESM.",
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
.action(async (inputs, options) => {
|
|
75
|
+
options.comments = options.comments ?? false;
|
|
76
|
+
|
|
77
|
+
for (const input of inputs) {
|
|
78
|
+
await fileExists(input);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const js = await compileFiles(inputs, options);
|
|
82
|
+
if (options.output) {
|
|
83
|
+
await writeFile(options.output, js);
|
|
84
|
+
} else {
|
|
85
|
+
console.log(js);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export default createProgram;
|
package/license.json
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"license": "license.template",
|
|
3
|
-
"licenseFormats": {
|
|
4
|
-
"js|ts": {
|
|
5
|
-
"eachLine": {
|
|
6
|
-
"prepend": "// "
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
},
|
|
10
|
-
"ignoreFile": ".gitignore",
|
|
11
|
-
"ignore": [
|
|
12
|
-
".github/**/*",
|
|
13
|
-
".husky/**/*",
|
|
14
|
-
"fixtures/*",
|
|
15
|
-
"commitlint.config.cjs",
|
|
16
|
-
"LICENSE",
|
|
17
|
-
"license.template",
|
|
18
|
-
"**/.gitignore",
|
|
19
|
-
"**/*.fuzz.js",
|
|
20
|
-
"**/*.perf.js",
|
|
21
|
-
"**/*.test.js",
|
|
22
|
-
"**/*.md",
|
|
23
|
-
"**/*.yml",
|
|
24
|
-
"**/.DS_Store",
|
|
25
|
-
"**/*.ftl",
|
|
26
|
-
"**/*.mjs"
|
|
27
|
-
]
|
|
28
|
-
}
|
package/license.template
DELETED