UNPKG

fluent-transpiler

Version:

Transpile Fluent (ftl) files into optimized, tree-shakable, JavaScript EcmaScript Modules (esm).

563 lines (535 loc) 14.5 kB
// Copyright 2026 will Farrell, and fluent-transpiler contributors. // SPDX-License-Identifier: MIT import { readFile } from "node:fs/promises"; import { parse } from "@fluent/syntax"; import { camelCase, constantCase, pascalCase, snakeCase } from "change-case"; const collectTopLevelIds = (src) => { const { body } = parse(src); const ids = []; for (const node of body) { if (node.type === "Message" || node.type === "Term") { ids.push(node.id.name); } } return ids; }; const checkDuplicates = (sources) => { const seen = new Map(); const duplicates = []; for (const { label, src } of sources) { for (const id of collectTopLevelIds(src)) { const prior = seen.get(id); if (prior !== undefined && prior !== label) { duplicates.push({ id, a: prior, b: label }); } else if (prior === undefined) { seen.set(id, label); } } } if (duplicates.length) { const lines = duplicates.map( (d) => ` - "${d.id}" defined in ${d.a} and ${d.b}`, ); throw new Error(`Duplicate id(s) found:\n${lines.join("\n")}`); } }; const reservedWords = new Set([ "abstract", "arguments", "await", "boolean", "break", "byte", "case", "catch", "char", "class", "const", "continue", "debugger", "default", "delete", "do", "double", "else", "enum", "eval", "export", "extends", "false", "final", "finally", "float", "for", "function", "goto", "if", "implements", "import", "in", "instanceof", "int", "interface", "let", "long", "native", "new", "null", "of", "package", "private", "protected", "public", "return", "short", "static", "super", "switch", "synchronized", "this", "throw", "throws", "transient", "true", "try", "typeof", "undefined", "var", "void", "volatile", "while", "with", "yield", ]); const exportDefault = `(id, params) => { const source = __exports[id] ?? __exports['_'+id] if (typeof source === 'undefined') return '*** '+id+' ***' if (typeof source === 'function') return source(params) return source } `; export const compile = (src, opts) => { if (Array.isArray(src)) { const sources = src.map((s, i) => ({ label: `source[${i}]`, src: s })); checkDuplicates(sources); src = src.join("\n\n"); } const options = { comments: true, errorOnJunk: true, includeKey: [], excludeKey: [], excludeValue: undefined, variableNotation: "camelCase", disableMinify: false, useIsolating: false, params: "params", exportDefault, ...opts, }; if (!Array.isArray(options.locale)) options.locale = [options.locale]; if (!Array.isArray(options.includeKey)) options.includeKey = [options.includeKey]; if (!Array.isArray(options.excludeKey)) options.excludeKey = [options.excludeKey]; if (options.excludeValue) { // cast to template literal options.excludeValue = `\`${options.excludeValue}\``; } const metadata = {}; const exports = []; const functions = {}; // global functions let variable; const compileAssignment = (data) => { variable = compileType(data); metadata[variable] = { id: data.name, term: false, params: false, }; return variable; }; const compileFunctionArguments = (data) => { const positional = data.arguments?.positional.map((data) => { return types[data.type](data); }); const named = data.arguments?.named.reduce((obj, data) => { const entry = compileType(data); const [key, value] = entry.split(": "); obj[key] = value; return obj; }, {}); return { positional, named }; }; const compileType = (data, parent) => { try { return types[data.type](data, parent); } catch (e) { throw new Error(e.message, { cause: { error: e, data } }); } }; const types = { Identifier: (data, parent) => { const value = parent === "Attribute" ? data.name : variableNotation[options.variableNotation](data.name); if (value.includes("-")) { return `'${value}'`; } // Check for reserved words if (reservedWords.has(value)) { return `_${value}`; } return value; }, Attribute: (data) => { const key = compileType(data.id, data.type); const value = compileType(data.value, data.type); return ` ${key}: ${value}`; }, Pattern: (data, parent) => { return ( "`" + data.elements .map((data) => { return compileType(data, parent); }) .join("") + "`" ); }, // resources Term: (data) => { const assignment = compileAssignment(data.id); const templateStringLiteral = compileType(data.value); metadata[assignment].term = true; if (metadata[assignment].params) { return `const ${assignment} = (${options.params}) => ${templateStringLiteral}\n`; } return `const ${assignment} = ${templateStringLiteral}\n`; }, Message: (data) => { const assignment = compileAssignment(data.id); if ( options.includeKey.length && !options.includeKey.includes(assignment) ) { return ""; } if ( options.excludeKey.length && options.excludeKey.includes(assignment) ) { return ""; } let templateStringLiteral = data.value && compileType(data.value, data.type); if (options.excludeValue === templateStringLiteral) { templateStringLiteral = "``"; } metadata[assignment].attributes = data.attributes.length; let attributes = {}; if (metadata[assignment].attributes) { attributes = `{\n${data.attributes .map((data) => { return ` ${compileType(data)}`; }) .join(",\n")}\n }`; } let message = ""; if (!options.disableMinify) { if (metadata[assignment].attributes) { if (metadata[assignment].params) { message = `(${options.params}) => ({ value:${templateStringLiteral}, attributes:${attributes} })\n`; } else { message = `{ value: ${templateStringLiteral}, attributes: ${attributes} }\n`; } } else if (metadata[assignment].params) { message = `(${options.params}) => ${templateStringLiteral}\n`; } else { message = `${templateStringLiteral}\n`; } } else { // consistent API message = `(${metadata[assignment].params ? options.params : ""}) => ({ value:${templateStringLiteral}, attributes:${attributes} })\n`; } if (assignment === metadata[assignment].id) { exports.push(`${assignment}`); } else { exports.push(`'${metadata[assignment].id}': ${assignment}`); } return `export const ${assignment} = ${message}`; }, Comment: (data) => { if (options.comments) return `// # ${data.content}\n`; return ""; }, GroupComment: (data) => { if (options.comments) return `// ## ${data.content}\n`; return ""; }, ResourceComment: (data) => { if (options.comments) return `// ### ${data.content}\n`; return ""; }, Junk: (data) => { if (options.errorOnJunk) { throw new Error("Junk found", { cause: data }); } return ""; }, // Element TextElement: (data) => { return data.value.replaceAll("`", "\\`"); // escape string literal }, Placeable: (data, parent) => { return `${options.useIsolating ? "\u2068" : ""}\${${compileType( data.expression, parent, )}}${options.useIsolating ? "\u2069" : ""}`; }, // Expression StringLiteral: (data, parent) => { // JSON.stringify at parent level if (["NamedArgument"].includes(parent)) { return `${data.value}`; } return `"${data.value}"`; }, NumberLiteral: (data) => { const decimal = Number.parseFloat(data.value); const number = Number.isInteger(decimal) ? Number.parseInt(data.value, 10) : decimal; return Intl.NumberFormat(options.locale).format(number); }, VariableReference: (data, parent) => { functions.__formatVariable = true; metadata[variable].params = true; const value = `${options.params}?.${data.id.name}`; if (["Message", "Variant", "Attribute"].includes(parent)) { return `__formatVariable(${value})`; } return value; }, MessageReference: (data) => { const messageName = compileType(data.id); metadata[variable].params ||= metadata[messageName].params; if (!options.disableMinify) { if (metadata[messageName].params) { return `${messageName}(${options.params})`; } return `${messageName}`; } return `${messageName}(${ metadata[messageName].params ? options.params : "" })`; }, TermReference: (data) => { const termName = compileType(data.id); metadata[variable].params ||= metadata[termName].params; let params; if (metadata[termName].params) { let { named } = compileFunctionArguments(data); named = JSON.stringify(named); if (named) { params = `{ ...${options.params}, ${named.substring( 1, named.length - 1, )} }`; } else { params = options.params; } } if (!options.disableMinify) { if (metadata[termName].params) { return `${termName}(${params})`; } return `${termName}`; } return `${termName}(${params ? params : ""})`; }, NamedArgument: (data) => { // Inconsistent: `NamedArgument` uses `name` instead of `id` for Identifier const key = data.name.name; // Don't transform value const value = compileType(data.value, data.type); return `${key}: ${value}`; }, SelectExpression: (data) => { functions.__select = true; metadata[variable].params = true; const value = compileType(data.selector); let fallback; return `__select(\n ${value},\n {\n${data.variants .filter((data) => { if (data.default) { fallback = compileType(data.value, data.type); } return !data.default; }) .map((data) => { return ` ${compileType(data)}`; }) .join(",\n")}\n },\n ${fallback}\n )`; }, Variant: (data, parent) => { // Inconsistent: `Variant` uses `key` instead of `id` for Identifier const key = compileType(data.key); const value = compileType(data.value, data.type); return ` '${key}': ${value}`; }, FunctionReference: (data) => { return `${types[data.id.name](compileFunctionArguments(data))}`; }, // Functions DATETIME: (data) => { functions.__formatDateTime = true; const { positional, named } = data; const value = positional[0]; return `__formatDateTime(${value}, ${JSON.stringify(named)})`; }, RELATIVETIME: (data) => { functions.__formatRelativeTime = true; const { positional, named } = data; const value = positional[0]; return `__formatRelativeTime(${value}, ${JSON.stringify(named)})`; }, NUMBER: (data) => { functions.__formatNumber = true; const { positional, named } = data; const value = positional[0]; return `__formatNumber(${value}, ${JSON.stringify(named)})`; }, }; if (/\t/.test(src)) { src = src.replace(/\t/g, " "); } const { body } = parse(src); let translations = ``; for (const data of body) { translations += compileType(data); } let output = ``; if ( functions.__formatVariable || functions.__formatDateTime || functions.__formatNumber || functions.__formatRelativeTime || functions.__select ) { output += `const __locales = ${JSON.stringify(options.locale)}\nconst __intlCache = {}\n`; } if (functions.__formatRelativeTime) { output += ` const __relativeTimeDiff = (d) => { const msPerMinute = 60 * 1000 const msPerHour = msPerMinute * 60 const msPerDay = msPerHour * 24 const msPerWeek = msPerDay * 7 const msPerMonth = msPerDay * 30 const msPerYear = msPerDay * 365.25 const elapsed = d - new Date() if (Math.abs(elapsed) < msPerMinute) { return [Math.round(elapsed / 1000), 'second'] } if (Math.abs(elapsed) < msPerHour) { return [Math.round(elapsed / msPerMinute), 'minute'] } if (Math.abs(elapsed) < msPerDay) { return [Math.round(elapsed / msPerHour), 'hour'] } if (Math.abs(elapsed) < msPerWeek * 2) { return [Math.round(elapsed / msPerDay), 'day'] } if (Math.abs(elapsed) < msPerMonth) { return [Math.round(elapsed / msPerWeek), 'week'] } if (Math.abs(elapsed) < msPerYear) { return [Math.round(elapsed / msPerMonth), 'month'] } return [Math.round(elapsed / msPerYear), 'year'] } const __formatRelativeTime = (value, options) => { if (typeof value === 'string') value = new Date(value) if (isNaN(value.getTime())) return value try { const [duration, unit] = __relativeTimeDiff(value) const k = JSON.stringify(options) ?? '' return (__intlCache['R'+k] ??= new Intl.RelativeTimeFormat(__locales, options)).format(duration, unit) } catch (e) { // RelativeTimeFormat unsupported or invalid options, fall back to DateTimeFormat } const k = JSON.stringify(options) ?? '' return (__intlCache['D'+k] ??= new Intl.DateTimeFormat(__locales, options)).format(value) } `; } if (functions.__formatDateTime) { output += ` const __formatDateTime = (value, options) => { if (typeof value === 'string') value = new Date(value) if (isNaN(value.getTime())) return value const k = JSON.stringify(options) ?? '' return (__intlCache['D'+k] ??= new Intl.DateTimeFormat(__locales, options)).format(value) } `; } if (functions.__formatVariable || functions.__formatNumber) { output += ` const __formatNumber = (value, options) => { const k = JSON.stringify(options) ?? '' return (__intlCache['N'+k] ??= new Intl.NumberFormat(__locales, options)).format(value) } `; } if (functions.__formatVariable) { output += ` const __formatVariable = (value) => { if (typeof value === 'string') return value const decimal = Number.parseFloat(value) const number = Number.isInteger(decimal) ? Number.parseInt(value, 10) : decimal return __formatNumber(number) } `; } if (functions.__select) { output += ` const __select = (value, cases, fallback, options) => { const k = JSON.stringify(options) ?? '' const rule = (__intlCache['P'+k] ??= new Intl.PluralRules(__locales, options)).select(value) return cases[value] ?? cases[rule] ?? fallback } `; } output += `\n${translations}`; output += `const __exports = {\n ${exports.join(",\n ")}\n}`; output += `\nexport default ${options.exportDefault}`; return output; }; const variableNotation = { camelCase, pascalCase, snakeCase, constantCase, }; export const compileFiles = async (paths, opts) => { const sources = await Promise.all( paths.map(async (path) => ({ label: path, src: await readFile(path, { encoding: "utf8" }), })), ); checkDuplicates(sources); return compile(sources.map((s) => s.src).join("\n\n"), opts); }; export default compile;