fluent-transpiler 0.4.1 → 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 +29 -6
- package/cli.js +8 -85
- package/index.d.ts +18 -3
- package/index.js +147 -61
- package/package.json +27 -12
- package/program.js +89 -0
- package/license.json +0 -28
- package/license.template +0 -2
package/README.md
CHANGED
|
@@ -35,19 +35,21 @@ npm i -D fluent-transpiler
|
|
|
35
35
|
## CLI
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
Usage: ftl [options] <
|
|
38
|
+
Usage: ftl [options] <inputs...>
|
|
39
39
|
|
|
40
40
|
Compile Fluent (.ftl) files to JavaScript (.js or .mjs)
|
|
41
41
|
|
|
42
42
|
Arguments:
|
|
43
|
-
|
|
43
|
+
inputs Paths to the Fluent file(s) to compile.
|
|
44
|
+
Multiple files are joined in order;
|
|
45
|
+
ids must be unique across the set.
|
|
44
46
|
|
|
45
47
|
Options:
|
|
46
48
|
--locale <locale...> What locale(s) to be used. Multiple can be set to allow for fallback. i.e. en-CA
|
|
47
49
|
--comments Include comments in output file.
|
|
48
50
|
--include-key <keys...> Allowed messages to be included. Default to include all.
|
|
49
51
|
--exclude-key <keys...> Ignored messages to be excluded. Default to exclude none.
|
|
50
|
-
--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.
|
|
51
53
|
--variable-notation <variableNotation> What variable notation to use with exports (choices: "camelCase", "pascalCase", "constantCase",
|
|
52
54
|
"snakeCase", default: "camelCase")
|
|
53
55
|
--disable-minify If disabled, all exported messages will have the same interface `(params) => ({value, attributes})`.
|
|
@@ -62,15 +64,19 @@ Options:
|
|
|
62
64
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
63
65
|
| locale | What locale(s) to be used. Multiple can be set to allow for fallback. i.e. `en-CA` |
|
|
64
66
|
| comments | Include comments in output file. Default: `true` |
|
|
65
|
-
| includeKey | Array of message keys to include. Default: `[]` (include all) |
|
|
66
|
-
| excludeKey | Array of message keys to exclude. Default: `[]` (exclude none) |
|
|
67
|
-
| 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` |
|
|
68
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`, `() => ''`, `() => ({})`) |
|
|
69
71
|
| errorOnJunk | Throw error when `Junk` is parsed. Default: `true` |
|
|
70
72
|
| variableNotation | What variable notation to use with exports. Choices: `camelCase`, `pascalCase`, `snakeCase`, `constantCase`. Default: `camelCase` |
|
|
71
73
|
| useIsolating | Wrap placeable with \u2068 and \u2069. Default: `false` |
|
|
74
|
+
| params | Parameter name used in generated message functions. Default: `params` |
|
|
72
75
|
| exportDefault | Allows the overwriting of the `export default` to allow for custom uses. Default: See code |
|
|
73
76
|
|
|
77
|
+
Messages and terms must be defined before they are referenced; referencing a
|
|
78
|
+
later definition is a compile error.
|
|
79
|
+
|
|
74
80
|
```javascript
|
|
75
81
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
76
82
|
import fluentTranspiler from 'fluent-transpiler'
|
|
@@ -79,3 +85,20 @@ const ftl = await readFile('./path/to/en.ftl', { encoding: 'utf8' })
|
|
|
79
85
|
const js = fluentTranspiler(ftl, { locale: 'en-CA' })
|
|
80
86
|
await writeFile('./path/to/en.mjs', js, 'utf8')
|
|
81
87
|
```
|
|
88
|
+
|
|
89
|
+
### Joining multiple files
|
|
90
|
+
|
|
91
|
+
`compile` also accepts an array of source strings, and `compileFiles` reads and
|
|
92
|
+
joins files from disk. Sources are concatenated in the order supplied; top-level
|
|
93
|
+
message and term ids must be unique across the set.
|
|
94
|
+
|
|
95
|
+
```javascript
|
|
96
|
+
import { writeFile } from 'node:fs/promises'
|
|
97
|
+
import { compileFiles } from 'fluent-transpiler'
|
|
98
|
+
|
|
99
|
+
const js = await compileFiles(
|
|
100
|
+
['./common.ftl', './brand.ftl', './app.ftl'],
|
|
101
|
+
{ locale: 'en-CA' },
|
|
102
|
+
)
|
|
103
|
+
await writeFile('./en.mjs', js, 'utf8')
|
|
104
|
+
```
|
package/cli.js
CHANGED
|
@@ -2,88 +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("<input>", "Path to the Fluent file to compile")
|
|
20
|
-
.requiredOption(
|
|
21
|
-
"--locale <locale...>",
|
|
22
|
-
"What locale(s) to be used. Multiple can be set to allow for fallback. i.e. en-CA",
|
|
23
|
-
)
|
|
24
|
-
.addOption(
|
|
25
|
-
new Option("--comments", "Include comments in output file.").preset(true),
|
|
26
|
-
)
|
|
27
|
-
.addOption(
|
|
28
|
-
new Option(
|
|
29
|
-
"--include-key <includeMessageKey...>",
|
|
30
|
-
"Allowed messages to be included. Default to include all.",
|
|
31
|
-
),
|
|
32
|
-
)
|
|
33
|
-
.addOption(
|
|
34
|
-
new Option(
|
|
35
|
-
"--exclude-key <excludeMessageKey...>",
|
|
36
|
-
"Ignored messages to be excluded. Default to exclude none.",
|
|
37
|
-
),
|
|
38
|
-
)
|
|
39
|
-
.addOption(
|
|
40
|
-
new Option(
|
|
41
|
-
"--exclude-value <excludeMessageValue>",
|
|
42
|
-
"Set message to an empty string when it contains this value. Default to not allowing empty strings.",
|
|
43
|
-
),
|
|
44
|
-
)
|
|
45
|
-
.addOption(
|
|
46
|
-
new Option(
|
|
47
|
-
"--variable-notation <variableNotation>",
|
|
48
|
-
"What variable notation to use with exports",
|
|
49
|
-
)
|
|
50
|
-
.choices(["camelCase", "pascalCase", "constantCase", "snakeCase"])
|
|
51
|
-
.default("camelCase"),
|
|
52
|
-
)
|
|
53
|
-
.addOption(
|
|
54
|
-
new Option(
|
|
55
|
-
"--disable-minify",
|
|
56
|
-
"If disabled, all exported messages will have the same interface `(params) => ({value, attributes})`.",
|
|
57
|
-
).preset(true),
|
|
58
|
-
)
|
|
59
|
-
.addOption(
|
|
60
|
-
new Option(
|
|
61
|
-
"--use-isolating",
|
|
62
|
-
"Wrap placeable with \\u2068 and \\u2069.",
|
|
63
|
-
).preset(true),
|
|
64
|
-
)
|
|
65
|
-
.addOption(
|
|
66
|
-
new Option(
|
|
67
|
-
"-o, --output <output>",
|
|
68
|
-
"Path to store the resulting JavaScript file. Will be in ESM.",
|
|
69
|
-
),
|
|
70
|
-
)
|
|
71
|
-
.action(async (input, options) => {
|
|
72
|
-
options.comments = options.comments ?? false;
|
|
73
|
-
try {
|
|
74
|
-
await fileExists(input);
|
|
75
|
-
|
|
76
|
-
const ftl = await readFile(input, { encoding: "utf8" });
|
|
77
|
-
|
|
78
|
-
const js = compile(ftl, options);
|
|
79
|
-
if (options.output) {
|
|
80
|
-
await writeFile(options.output, js, "utf8");
|
|
81
|
-
} else {
|
|
82
|
-
console.log(js);
|
|
83
|
-
}
|
|
84
|
-
} catch (e) {
|
|
85
|
-
console.error(`Error: ${e.message}`);
|
|
86
|
-
process.exit(1);
|
|
87
|
-
}
|
|
88
|
-
})
|
|
89
|
-
.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";
|
|
@@ -28,7 +28,22 @@ export interface CompileOptions {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Compile Fluent (.ftl) source into a JavaScript ESM string.
|
|
31
|
+
* Pass an array of source strings to join multiple files into one module;
|
|
32
|
+
* top-level message/term ids must be unique across the set.
|
|
31
33
|
*/
|
|
32
|
-
export declare function compile(
|
|
34
|
+
export declare function compile(
|
|
35
|
+
src: string | string[],
|
|
36
|
+
opts?: CompileOptions,
|
|
37
|
+
): string;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read and compile one or more Fluent (.ftl) files into a single JavaScript
|
|
41
|
+
* ESM string. Files are joined in the order supplied. Top-level message/term
|
|
42
|
+
* ids must be unique across the set.
|
|
43
|
+
*/
|
|
44
|
+
export declare function compileFiles(
|
|
45
|
+
paths: string[],
|
|
46
|
+
opts?: CompileOptions,
|
|
47
|
+
): Promise<string>;
|
|
33
48
|
|
|
34
49
|
export default compile;
|
package/index.js
CHANGED
|
@@ -1,9 +1,42 @@
|
|
|
1
1
|
// Copyright 2026 will Farrell, and fluent-transpiler contributors.
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
4
5
|
import { parse } from "@fluent/syntax";
|
|
5
6
|
import { camelCase, constantCase, pascalCase, snakeCase } from "change-case";
|
|
6
7
|
|
|
8
|
+
const collectTopLevelIds = (src) => {
|
|
9
|
+
const { body } = parse(src);
|
|
10
|
+
const ids = [];
|
|
11
|
+
for (const node of body) {
|
|
12
|
+
if (node.type === "Message" || node.type === "Term") {
|
|
13
|
+
ids.push(node.id.name);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return ids;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const checkDuplicates = (sources) => {
|
|
20
|
+
const seen = new Map();
|
|
21
|
+
const duplicates = [];
|
|
22
|
+
for (const { label, src } of sources) {
|
|
23
|
+
for (const id of collectTopLevelIds(src)) {
|
|
24
|
+
const prior = seen.get(id);
|
|
25
|
+
if (prior === undefined) {
|
|
26
|
+
seen.set(id, label);
|
|
27
|
+
} else if (prior !== label) {
|
|
28
|
+
duplicates.push({ id, a: prior, b: label });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (duplicates.length) {
|
|
33
|
+
const lines = duplicates.map(
|
|
34
|
+
(d) => ` - "${d.id}" defined in ${d.a} and ${d.b}`,
|
|
35
|
+
);
|
|
36
|
+
throw new Error(`Duplicate id(s) found:\n${lines.join("\n")}`);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
7
40
|
const reservedWords = new Set([
|
|
8
41
|
"abstract",
|
|
9
42
|
"arguments",
|
|
@@ -74,17 +107,25 @@ const reservedWords = new Set([
|
|
|
74
107
|
]);
|
|
75
108
|
|
|
76
109
|
const exportDefault = `(id, params) => {
|
|
77
|
-
const source = __exports[id]
|
|
110
|
+
const source = __exports[id]
|
|
78
111
|
if (typeof source === 'undefined') return '*** '+id+' ***'
|
|
79
112
|
if (typeof source === 'function') return source(params)
|
|
80
113
|
return source
|
|
81
114
|
}
|
|
82
115
|
`;
|
|
83
116
|
export const compile = (src, opts) => {
|
|
117
|
+
if (Array.isArray(src)) {
|
|
118
|
+
const sources = src.map((s, i) => ({ label: `source[${i}]`, src: s }));
|
|
119
|
+
checkDuplicates(sources);
|
|
120
|
+
src = src.join("\n\n");
|
|
121
|
+
}
|
|
84
122
|
const options = {
|
|
85
123
|
comments: true,
|
|
86
124
|
errorOnJunk: true,
|
|
87
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.
|
|
88
129
|
excludeKey: [],
|
|
89
130
|
excludeValue: undefined,
|
|
90
131
|
variableNotation: "camelCase",
|
|
@@ -111,9 +152,16 @@ export const compile = (src, opts) => {
|
|
|
111
152
|
|
|
112
153
|
const compileAssignment = (data) => {
|
|
113
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
|
+
}
|
|
114
163
|
metadata[variable] = {
|
|
115
164
|
id: data.name,
|
|
116
|
-
term: false,
|
|
117
165
|
params: false,
|
|
118
166
|
};
|
|
119
167
|
return variable;
|
|
@@ -121,12 +169,11 @@ export const compile = (src, opts) => {
|
|
|
121
169
|
|
|
122
170
|
const compileFunctionArguments = (data) => {
|
|
123
171
|
const positional = data.arguments?.positional.map((data) => {
|
|
124
|
-
return
|
|
172
|
+
return compileType(data);
|
|
125
173
|
});
|
|
126
174
|
const named = data.arguments?.named.reduce((obj, data) => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
obj[key] = value;
|
|
175
|
+
// `NamedArgument` uses `name` instead of `id`; never transform it
|
|
176
|
+
obj[data.name.name] = compileType(data.value, data.type);
|
|
130
177
|
return obj;
|
|
131
178
|
}, {});
|
|
132
179
|
return { positional, named };
|
|
@@ -162,11 +209,18 @@ export const compile = (src, opts) => {
|
|
|
162
209
|
return ` ${key}: ${value}`;
|
|
163
210
|
},
|
|
164
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;
|
|
165
215
|
return (
|
|
166
216
|
"`" +
|
|
167
217
|
data.elements
|
|
168
218
|
.map((data) => {
|
|
169
|
-
|
|
219
|
+
const value = compileType(data, parent);
|
|
220
|
+
if (isolate && data.type === "Placeable") {
|
|
221
|
+
return `\u2068${value}\u2069`;
|
|
222
|
+
}
|
|
223
|
+
return value;
|
|
170
224
|
})
|
|
171
225
|
.join("") +
|
|
172
226
|
"`"
|
|
@@ -175,8 +229,7 @@ export const compile = (src, opts) => {
|
|
|
175
229
|
// resources
|
|
176
230
|
Term: (data) => {
|
|
177
231
|
const assignment = compileAssignment(data.id);
|
|
178
|
-
const templateStringLiteral = compileType(data.value);
|
|
179
|
-
metadata[assignment].term = true;
|
|
232
|
+
const templateStringLiteral = compileType(data.value, data.type);
|
|
180
233
|
if (metadata[assignment].params) {
|
|
181
234
|
return `const ${assignment} = (${options.params}) => ${templateStringLiteral}\n`;
|
|
182
235
|
}
|
|
@@ -185,20 +238,6 @@ export const compile = (src, opts) => {
|
|
|
185
238
|
Message: (data) => {
|
|
186
239
|
const assignment = compileAssignment(data.id);
|
|
187
240
|
|
|
188
|
-
if (
|
|
189
|
-
options.includeKey.length &&
|
|
190
|
-
!options.includeKey.includes(assignment)
|
|
191
|
-
) {
|
|
192
|
-
return "";
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
options.excludeKey.length &&
|
|
197
|
-
options.excludeKey.includes(assignment)
|
|
198
|
-
) {
|
|
199
|
-
return "";
|
|
200
|
-
}
|
|
201
|
-
|
|
202
241
|
let templateStringLiteral =
|
|
203
242
|
data.value && compileType(data.value, data.type);
|
|
204
243
|
|
|
@@ -207,7 +246,7 @@ export const compile = (src, opts) => {
|
|
|
207
246
|
}
|
|
208
247
|
|
|
209
248
|
metadata[assignment].attributes = data.attributes.length;
|
|
210
|
-
let attributes = {};
|
|
249
|
+
let attributes = "{}";
|
|
211
250
|
if (metadata[assignment].attributes) {
|
|
212
251
|
attributes = `{\n${data.attributes
|
|
213
252
|
.map((data) => {
|
|
@@ -216,7 +255,7 @@ export const compile = (src, opts) => {
|
|
|
216
255
|
.join(",\n")}\n }`;
|
|
217
256
|
}
|
|
218
257
|
|
|
219
|
-
let message
|
|
258
|
+
let message;
|
|
220
259
|
if (!options.disableMinify) {
|
|
221
260
|
if (metadata[assignment].attributes) {
|
|
222
261
|
if (metadata[assignment].params) {
|
|
@@ -243,10 +282,25 @@ export const compile = (src, opts) => {
|
|
|
243
282
|
})\n`;
|
|
244
283
|
}
|
|
245
284
|
|
|
246
|
-
|
|
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) {
|
|
247
301
|
exports.push(`${assignment}`);
|
|
248
302
|
} else {
|
|
249
|
-
exports.push(`'${
|
|
303
|
+
exports.push(`'${id}': ${assignment}`);
|
|
250
304
|
}
|
|
251
305
|
return `export const ${assignment} = ${message}`;
|
|
252
306
|
},
|
|
@@ -270,13 +324,12 @@ export const compile = (src, opts) => {
|
|
|
270
324
|
},
|
|
271
325
|
// Element
|
|
272
326
|
TextElement: (data) => {
|
|
273
|
-
|
|
327
|
+
// escape for template literal; backslashes first so the escapes
|
|
328
|
+
// added for backticks are not themselves escaped
|
|
329
|
+
return data.value.replaceAll("\\", "\\\\").replaceAll("`", "\\`");
|
|
274
330
|
},
|
|
275
331
|
Placeable: (data, parent) => {
|
|
276
|
-
return
|
|
277
|
-
data.expression,
|
|
278
|
-
parent,
|
|
279
|
-
)}}${options.useIsolating ? "\u2069" : ""}`;
|
|
332
|
+
return `\${${compileType(data.expression, parent)}}`;
|
|
280
333
|
},
|
|
281
334
|
// Expression
|
|
282
335
|
StringLiteral: (data, parent) => {
|
|
@@ -286,24 +339,36 @@ export const compile = (src, opts) => {
|
|
|
286
339
|
}
|
|
287
340
|
return `"${data.value}"`;
|
|
288
341
|
},
|
|
289
|
-
NumberLiteral: (data) => {
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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;
|
|
295
352
|
},
|
|
296
353
|
VariableReference: (data, parent) => {
|
|
297
354
|
functions.__formatVariable = true;
|
|
298
355
|
metadata[variable].params = true;
|
|
299
|
-
|
|
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}`;
|
|
300
360
|
if (["Message", "Variant", "Attribute"].includes(parent)) {
|
|
301
|
-
return `__formatVariable(${value})`;
|
|
361
|
+
return `__formatVariable(${value}, ${JSON.stringify(data.id.name)})`;
|
|
302
362
|
}
|
|
303
363
|
return value;
|
|
304
364
|
},
|
|
305
365
|
MessageReference: (data) => {
|
|
306
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
|
+
}
|
|
307
372
|
metadata[variable].params ||= metadata[messageName].params;
|
|
308
373
|
if (!options.disableMinify) {
|
|
309
374
|
if (metadata[messageName].params) {
|
|
@@ -317,6 +382,11 @@ export const compile = (src, opts) => {
|
|
|
317
382
|
},
|
|
318
383
|
TermReference: (data) => {
|
|
319
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
|
+
}
|
|
320
390
|
metadata[variable].params ||= metadata[termName].params;
|
|
321
391
|
|
|
322
392
|
let params;
|
|
@@ -340,12 +410,6 @@ export const compile = (src, opts) => {
|
|
|
340
410
|
}
|
|
341
411
|
return `${termName}(${params ? params : ""})`;
|
|
342
412
|
},
|
|
343
|
-
NamedArgument: (data) => {
|
|
344
|
-
// Inconsistent: `NamedArgument` uses `name` instead of `id` for Identifier
|
|
345
|
-
const key = data.name.name; // Don't transform value
|
|
346
|
-
const value = compileType(data.value, data.type);
|
|
347
|
-
return `${key}: ${value}`;
|
|
348
|
-
},
|
|
349
413
|
SelectExpression: (data) => {
|
|
350
414
|
functions.__select = true;
|
|
351
415
|
metadata[variable].params = true;
|
|
@@ -363,16 +427,29 @@ export const compile = (src, opts) => {
|
|
|
363
427
|
})
|
|
364
428
|
.join(",\n")}\n },\n ${fallback}\n )`;
|
|
365
429
|
},
|
|
366
|
-
Variant: (data
|
|
367
|
-
//
|
|
368
|
-
|
|
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);
|
|
369
438
|
const value = compileType(data.value, data.type);
|
|
370
439
|
return ` '${key}': ${value}`;
|
|
371
440
|
},
|
|
372
441
|
FunctionReference: (data) => {
|
|
373
|
-
|
|
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));
|
|
374
449
|
},
|
|
375
|
-
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const functionTypes = {
|
|
376
453
|
DATETIME: (data) => {
|
|
377
454
|
functions.__formatDateTime = true;
|
|
378
455
|
const { positional, named } = data;
|
|
@@ -393,9 +470,7 @@ export const compile = (src, opts) => {
|
|
|
393
470
|
},
|
|
394
471
|
};
|
|
395
472
|
|
|
396
|
-
|
|
397
|
-
src = src.replace(/\t/g, " ");
|
|
398
|
-
}
|
|
473
|
+
src = src.replace(/\t/g, " ");
|
|
399
474
|
|
|
400
475
|
const { body } = parse(src);
|
|
401
476
|
let translations = ``;
|
|
@@ -403,7 +478,7 @@ export const compile = (src, opts) => {
|
|
|
403
478
|
translations += compileType(data);
|
|
404
479
|
}
|
|
405
480
|
|
|
406
|
-
let output =
|
|
481
|
+
let output = `// Generated by fluent-transpiler. Do not edit.\n`;
|
|
407
482
|
if (
|
|
408
483
|
functions.__formatVariable ||
|
|
409
484
|
functions.__formatDateTime ||
|
|
@@ -445,7 +520,7 @@ const __relativeTimeDiff = (d) => {
|
|
|
445
520
|
return [Math.round(elapsed / msPerYear), 'year']
|
|
446
521
|
}
|
|
447
522
|
const __formatRelativeTime = (value, options) => {
|
|
448
|
-
if (
|
|
523
|
+
if (!(value instanceof Date)) value = new Date(value)
|
|
449
524
|
if (isNaN(value.getTime())) return value
|
|
450
525
|
try {
|
|
451
526
|
const [duration, unit] = __relativeTimeDiff(value)
|
|
@@ -462,7 +537,7 @@ const __formatRelativeTime = (value, options) => {
|
|
|
462
537
|
if (functions.__formatDateTime) {
|
|
463
538
|
output += `
|
|
464
539
|
const __formatDateTime = (value, options) => {
|
|
465
|
-
if (
|
|
540
|
+
if (!(value instanceof Date)) value = new Date(value)
|
|
466
541
|
if (isNaN(value.getTime())) return value
|
|
467
542
|
const k = JSON.stringify(options) ?? ''
|
|
468
543
|
return (__intlCache['D'+k] ??= new Intl.DateTimeFormat(__locales, options)).format(value)
|
|
@@ -479,8 +554,9 @@ const __formatNumber = (value, options) => {
|
|
|
479
554
|
}
|
|
480
555
|
if (functions.__formatVariable) {
|
|
481
556
|
output += `
|
|
482
|
-
const __formatVariable = (value) => {
|
|
557
|
+
const __formatVariable = (value, name) => {
|
|
483
558
|
if (typeof value === 'string') return value
|
|
559
|
+
if (value === undefined || value === null) return '{$'+name+'}'
|
|
484
560
|
const decimal = Number.parseFloat(value)
|
|
485
561
|
const number = Number.isInteger(decimal) ? Number.parseInt(value, 10) : decimal
|
|
486
562
|
return __formatNumber(number)
|
|
@@ -489,9 +565,8 @@ const __formatVariable = (value) => {
|
|
|
489
565
|
}
|
|
490
566
|
if (functions.__select) {
|
|
491
567
|
output += `
|
|
492
|
-
const __select = (value, cases, fallback
|
|
493
|
-
const
|
|
494
|
-
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)
|
|
495
570
|
return cases[value] ?? cases[rule] ?? fallback
|
|
496
571
|
}
|
|
497
572
|
`;
|
|
@@ -510,4 +585,15 @@ const variableNotation = {
|
|
|
510
585
|
constantCase,
|
|
511
586
|
};
|
|
512
587
|
|
|
588
|
+
export const compileFiles = async (paths, opts) => {
|
|
589
|
+
const sources = await Promise.all(
|
|
590
|
+
paths.map(async (path) => ({
|
|
591
|
+
label: path,
|
|
592
|
+
src: await readFile(path, { encoding: "utf8" }),
|
|
593
|
+
})),
|
|
594
|
+
);
|
|
595
|
+
checkDuplicates(sources);
|
|
596
|
+
return compile(sources.map((s) => s.src).join("\n\n"), opts);
|
|
597
|
+
};
|
|
598
|
+
|
|
513
599
|
export default compile;
|
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,21 +36,31 @@
|
|
|
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
|
-
"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:trivy",
|
|
44
|
-
"test:sast:
|
|
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",
|
|
46
|
+
"test:sast:actionlint": "actionlint",
|
|
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",
|
|
45
51
|
"test:sast:lockfile": "lockfile-lint --path package-lock.json --type npm --allowed-schemes \"https:\" --allowed-hosts npm --validate-integrity --validate-package-names",
|
|
46
|
-
"test:sast:semgrep": "semgrep scan --config auto",
|
|
47
|
-
"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 .",
|
|
48
54
|
"test:sast:trufflehog": "trufflehog filesystem --only-verified --log-level=-1 ./",
|
|
55
|
+
"test:sast:zizmor": "zizmor .github/workflows/",
|
|
49
56
|
"test:dast": "npm run test:dast:fuzz",
|
|
50
57
|
"test:dast:fuzz": "node --test ./**/*.fuzz.js",
|
|
51
|
-
"
|
|
52
|
-
"
|
|
58
|
+
"rm": "npm run rm:macos && npm run rm:node_modules && npm run rm:lock",
|
|
59
|
+
"rm:macos": "find . -name '.DS_Store' -type f -delete",
|
|
60
|
+
"rm:lock": "find . -name 'package-lock.json' -type f -delete",
|
|
61
|
+
"rm:node_modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
|
|
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"
|
|
53
64
|
},
|
|
54
65
|
"repository": {
|
|
55
66
|
"type": "git",
|
|
@@ -76,18 +87,22 @@
|
|
|
76
87
|
"dependencies": {
|
|
77
88
|
"@fluent/syntax": "0.19.0",
|
|
78
89
|
"change-case": "5.4.4",
|
|
79
|
-
"commander": "
|
|
90
|
+
"commander": "15.0.0"
|
|
91
|
+
},
|
|
92
|
+
"overrides": {
|
|
93
|
+
"qs": "^6.15.2"
|
|
80
94
|
},
|
|
81
95
|
"devDependencies": {
|
|
82
96
|
"@biomejs/biome": "^2.0.0",
|
|
83
|
-
"@commitlint/cli": "^
|
|
84
|
-
"@commitlint/config-conventional": "^
|
|
97
|
+
"@commitlint/cli": "^21.0.0",
|
|
98
|
+
"@commitlint/config-conventional": "^21.0.0",
|
|
85
99
|
"@fluent/bundle": "^0.19.0",
|
|
100
|
+
"@stryker-mutator/core": "^9.0.0",
|
|
86
101
|
"fast-check": "^4.0.0",
|
|
87
102
|
"husky": "^9.0.0",
|
|
88
103
|
"license-check-and-add": "4.0.5",
|
|
89
104
|
"tinybench": "^6.0.0",
|
|
90
|
-
"tstyche": "^
|
|
105
|
+
"tstyche": "^7.0.0"
|
|
91
106
|
},
|
|
92
107
|
"funding": {
|
|
93
108
|
"type": "github",
|
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