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 CHANGED
@@ -35,19 +35,21 @@ npm i -D fluent-transpiler
35
35
  ## CLI
36
36
 
37
37
  ```bash
38
- Usage: ftl [options] <input>
38
+ Usage: ftl [options] <inputs...>
39
39
 
40
40
  Compile Fluent (.ftl) files to JavaScript (.js or .mjs)
41
41
 
42
42
  Arguments:
43
- input Path to the Fluent file to compile
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 contains this value.
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 contains this value. Default: `undefined` |
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 { readFile, stat, writeFile } from "node:fs/promises";
6
- import { Command, Option } from "commander";
7
- import compile from "./index.js";
8
-
9
- const fileExists = async (filepath) => {
10
- const stats = await stat(filepath);
11
- if (!stats.isFile()) {
12
- throw new Error(`${filepath} is not a file`);
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?: string | string[];
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 contains this value. */
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(src: string, opts?: CompileOptions): string;
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] ?? __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 types[data.type](data);
172
+ return compileType(data);
125
173
  });
126
174
  const named = data.arguments?.named.reduce((obj, data) => {
127
- const entry = compileType(data);
128
- const [key, value] = entry.split(": ");
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
- return compileType(data, parent);
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
- if (assignment === metadata[assignment].id) {
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(`'${metadata[assignment].id}': ${assignment}`);
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
- return data.value.replaceAll("`", "\\`"); // escape string literal
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 `${options.useIsolating ? "\u2068" : ""}\${${compileType(
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 decimal = Number.parseFloat(data.value);
291
- const number = Number.isInteger(decimal)
292
- ? Number.parseInt(data.value, 10)
293
- : decimal;
294
- return Intl.NumberFormat(options.locale).format(number);
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
- const value = `${options.params}?.${data.id.name}`;
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, parent) => {
367
- // Inconsistent: `Variant` uses `key` instead of `id` for Identifier
368
- const key = compileType(data.key);
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
- return `${types[data.id.name](compileFunctionArguments(data))}`;
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
- // Functions
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
- if (/\t/.test(src)) {
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 (typeof value === 'string') value = new Date(value)
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 (typeof value === 'string') value = new Date(value)
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, options) => {
493
- const k = JSON.stringify(options) ?? ''
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.4.1",
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:license": "license-check-and-add check -f license.json",
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
- "release:license:add": "license-check-and-add add -f license.json",
52
- "release:license:remove": "license-check-and-add remove -f license.json"
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": "14.0.3"
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": "^20.0.0",
84
- "@commitlint/config-conventional": "^20.0.0",
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": "^6.0.0"
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
@@ -1,2 +0,0 @@
1
- Copyright 2026 will Farrell, and fluent-transpiler contributors.
2
- SPDX-License-Identifier: MIT