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 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 contains this value.
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 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` |
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 { stat, writeFile } from "node:fs/promises";
6
- import { Command, Option } from "commander";
7
- import { compileFiles } 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(
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?: 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";
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 !== undefined && prior !== label) {
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] ?? __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 types[data.type](data);
172
+ return compileType(data);
163
173
  });
164
174
  const named = data.arguments?.named.reduce((obj, data) => {
165
- const entry = compileType(data);
166
- const [key, value] = entry.split(": ");
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
- 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;
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
- 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) {
285
301
  exports.push(`${assignment}`);
286
302
  } else {
287
- exports.push(`'${metadata[assignment].id}': ${assignment}`);
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
- 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("`", "\\`");
312
330
  },
313
331
  Placeable: (data, parent) => {
314
- return `${options.useIsolating ? "\u2068" : ""}\${${compileType(
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 decimal = Number.parseFloat(data.value);
329
- const number = Number.isInteger(decimal)
330
- ? Number.parseInt(data.value, 10)
331
- : decimal;
332
- 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;
333
352
  },
334
353
  VariableReference: (data, parent) => {
335
354
  functions.__formatVariable = true;
336
355
  metadata[variable].params = true;
337
- 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}`;
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, parent) => {
405
- // Inconsistent: `Variant` uses `key` instead of `id` for Identifier
406
- 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);
407
438
  const value = compileType(data.value, data.type);
408
439
  return ` '${key}': ${value}`;
409
440
  },
410
441
  FunctionReference: (data) => {
411
- 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));
412
449
  },
413
- // Functions
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
- if (/\t/.test(src)) {
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 (typeof value === 'string') value = new Date(value)
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 (typeof value === 'string') value = new Date(value)
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, options) => {
531
- const k = JSON.stringify(options) ?? ''
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.5.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 detect --source . --redact --no-banner",
46
- "test:sast:license": "license-check-and-add check -f license.json",
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": "14.0.3"
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
@@ -1,2 +0,0 @@
1
- Copyright 2026 will Farrell, and fluent-transpiler contributors.
2
- SPDX-License-Identifier: MIT