#!/usr/bin/env node // Converts locales/en-US/main.ftl into web/locales/en-US.json: a flat // id -> string dictionary the browser can fetch with no parser of its own. // // Deliberately hand-rolled rather than a dependency on @fluent/syntax: the // source file only ever uses Fluent's simplest shape (comments, flat // `id = value` messages, `{ $var }` references, and `{"literal"}` string // inserts for embedded newlines) — confirmed by grep before writing this, // not assumed. If the file ever gains a select expression, a term, or a // multiline value, this script will need to grow with it; until then a // real parser is more dependency than the input justifies. import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const root = dirname(dirname(fileURLToPath(import.meta.url))); const srcPath = join(root, "locales/en-US/main.ftl"); const outPath = join(root, "web/locales/en-US.json"); const MESSAGE = /^([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*(.*)$/; // A Fluent string literal, e.g. {"\u000A"} — only used in this file to // embed a literal newline inside an otherwise single-line value. const LITERAL = /\{"((?:[^"\\]|\\.)*)"\}/g; function unescapeLiteral(text) { return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) .replace(/\\(.)/g, "$1"); } function parseFtl(text) { const messages = {}; for (const line of text.split("\n")) { if (!line || line.startsWith("#")) continue; // blank line or comment const match = MESSAGE.exec(line); if (!match) continue; const [, id, rawValue] = match; messages[id] = rawValue.replace(LITERAL, (_, inner) => unescapeLiteral(inner)); } return messages; } const messages = parseFtl(readFileSync(srcPath, "utf8")); mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, JSON.stringify(messages, null, 2) + "\n"); console.log(`wrote ${Object.keys(messages).length} messages to ${outPath}`);