import prettier from "prettier"; import type { LanguageInfo, TranslationSchema } from "./types"; import { unique } from "./utils"; export interface Translations { [fileName: string]: TranslationSchema; } // Assumes BCP 47 locale format (e.g. 'en-GB', 'zh-Hans', 'zh-Hans-CN'). // The ISO-639-1 key is always the first hyphen-delimited segment. function getIso1Key(code: string): string { return code.includes("-") ? code.split("-")[0] : code; } /** * Builds the body of the `iso1ToLocale` map (ISO-639-1 prefix -> full locale). * * Several locales can share the same ISO-639-1 prefix (e.g. en-GB/en-US, * zh-Hans/zh-Hant, pt-PT/pt-BR). Emitting one entry per locale produced a * literal with duplicate object keys, where the last declaration silently * overwrote the previous ones. To avoid that: * - the default locale is processed first, so a shared prefix maps to it; * - remaining prefix collisions resolve to the first listed locale. * The result always contains exactly one entry per prefix. */ function buildIso1ToLocale( languages: LanguageInfo[], defaultLocale: string, ): string { const ordered: LanguageInfo[] = []; for (const lang of languages) { if (lang.code === defaultLocale) ordered.push(lang); } for (const lang of languages) { if (lang.code !== defaultLocale) ordered.push(lang); } const seen = new Map(); for (const { code } of ordered) { const key = getIso1Key(code); if (!seen.has(key)) { seen.set(key, code); } } return Array.from(seen, ([key, code]) => `${key}: '${code}'`).join(", "); } function extractTranslationParameters(translation: string): string[] { const match = translation.match(/{{\w+}}/g) || []; return match.map((x) => x.replace(/[{}]/g, "")); } function transformKeys>( obj: T, transform: (key: string) => string, ): T { const transformed = Object.fromEntries( Object.entries(obj).map(([key, value]) => [transform(key), value]), ); return transformed as T; } function getFileKeys( translations: TranslationSchema, ): Record { return Object.entries(translations).reduce( (acc, [key, value]) => { return typeof value === "string" ? { ...acc, [key]: extractTranslationParameters(value) } : { ...acc, ...transformKeys( getFileKeys(value), (nestedKey) => `${key}.${nestedKey}`, ), }; }, {} as Record, ); } function getKeys( namespace: string, file: TranslationSchema, ): Record { return transformKeys(getFileKeys(file), (key) => `${namespace}:${key}`); } const removeJsonExtension = (file: string): string => file.replace(/\.json$/, ""); export async function generateKeys({ languages, translations, prettierConfig, defaultLocale, }: { languages: LanguageInfo[]; translations: Translations; defaultLocale: string; prettierConfig: prettier.Options; }): Promise { const namespaces = Object.keys(translations).map(removeJsonExtension); // Handle empty translations case if (namespaces.length === 0) { const content = await prettier.format( `// This file was autogenerated on ${new Date().toISOString()}. // DO NOT EDIT THIS FILE. export const locales = [${languages .map((lng) => `'${lng.code}'`) .join(", ")}] as const; export type Locale = typeof locales[number]; export const defaultLocale: Locale = '${defaultLocale}'; export const iso1ToLocale: { [key: string]: Locale } = {${buildIso1ToLocale( languages, defaultLocale, )}} export const languages: Array<{ code: Locale; englishName: string; localName: string }> = ${JSON.stringify( languages, )}; export type Namespace = never; export const namespaces: Namespace[] = []; export type TranslationKeyWithoutOptions = never; export type TranslationWithOptions = {}; type TranslationKeyWithOptions = keyof TranslationWithOptions; export type TranslationKey = | TranslationKeyWithoutOptions | TranslationKeyWithOptions; export type Translator = { (key: TranslationKeyWithoutOptions, options?: { count: number }): string; ( key: T, options: TranslationWithOptions[T] & { count?: number } ): string; }; `, { ...prettierConfig, parser: "typescript" }, ); return content; } const allKeys = Object.entries(translations) .map(([namespace, file]) => getKeys(removeJsonExtension(namespace), file)) .reduce((acc, cur) => { return { ...acc, ...cur, }; }, {}); const { parameterized, simple } = Object.entries(allKeys).reduce( (acc, [key, parameters]) => { if (parameters.length > 0) { return { ...acc, parameterized: { ...acc.parameterized, [key]: unique(parameters) }, }; } return { ...acc, simple: [...acc.simple, key] }; }, { parameterized: {}, simple: [] } as { parameterized: Record; simple: string[]; }, ); const content = await prettier.format( `// This file was autogenerated on ${new Date().toISOString()}. // DO NOT EDIT THIS FILE. export const locales = [${languages .map((lng) => `'${lng.code}'`) .join(", ")}] as const; export type Locale = typeof locales[number]; export const defaultLocale: Locale = '${defaultLocale}'; export const iso1ToLocale: { [key: string]: Locale } = {${buildIso1ToLocale( languages, defaultLocale, )}} export const languages: Array<{ code: Locale; englishName: string; localName: string }> = ${JSON.stringify( languages, )}; export type Namespace = ${namespaces .map((namespace) => `'${namespace}'`) .join(" | ")}; export const namespaces: Namespace[] = [${namespaces .map((namespace) => `'${namespace}'`) .join(" , ")}]; export type TranslationKeyWithoutOptions = ${ simple.length > 0 ? simple.map((key) => `'${key}'`).join(" | ") : "never" }; export type TranslationWithOptions = { ${Object.entries(parameterized).map( ([key, parameters]) => `'${key}': { ${parameters .map( (parameter) => `'${parameter}': ${parameter === "count" ? "number" : "string"}`, ) .join(",")} }`, )} }; type TranslationKeyWithOptions = keyof TranslationWithOptions; export type TranslationKey = | TranslationKeyWithoutOptions | TranslationKeyWithOptions; export type Translator = { (key: TranslationKeyWithoutOptions, options?: { count: number }): string; ( key: T, options: TranslationWithOptions[T] & { count?: number } ): string; }; `, { ...prettierConfig, parser: "typescript" }, ); return content; }