#!/usr/bin/env node /** * This file contains a script that can be used to update the following files: * * - `src/locale/.ts` * - `src/locales//index.ts` * - `src/locales///index.ts` * - `src/docs/guide/localization.md` * * If you wish to edit all/specific locale data files you can do so using the * `updateLocaleFileHook()` method. * Please remember to not commit your temporary update code. * * Run this script using `pnpm run generate:locales` */ import { constants } from 'node:fs'; import { access, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import type { LocaleDefinition, MetadataDefinition } from '../src/definitions'; import { keys } from '../src/internal/keys'; import { formatMarkdown, formatTypescript } from './apidocs/utils/format'; // Constants const pathRoot = resolve(import.meta.dirname, '..'); const pathLocale = resolve(pathRoot, 'src', 'locale'); const pathLocales = resolve(pathRoot, 'src', 'locales'); const pathLocaleIndex = resolve(pathLocale, 'index.ts'); const pathLocalesIndex = resolve(pathLocales, 'index.ts'); const pathDocsGuideLocalization = resolve( pathRoot, 'docs', 'guide', 'localization.md' ); // Workaround for nameOf type PascalCase = TName extends `${infer Prefix}_${infer Remainder}` ? `${Capitalize}${PascalCase}` : Capitalize; type DefinitionType = { [key in keyof LocaleDefinition]-?: PascalCase<`${key}Definition`>; }; /** * The types of the definitions. */ const definitionsTypes: DefinitionType = { airline: 'AirlineDefinition', animal: 'AnimalDefinition', book: 'BookDefinition', color: 'ColorDefinition', commerce: 'CommerceDefinition', company: 'CompanyDefinition', database: 'DatabaseDefinition', date: 'DateDefinition', finance: 'FinanceDefinition', food: 'FoodDefinition', hacker: 'HackerDefinition', internet: 'InternetDefinition', location: 'LocationDefinition', lorem: 'LoremDefinition', metadata: 'MetadataDefinition', music: 'MusicDefinition', person: 'PersonDefinition', phone_number: 'PhoneNumberDefinition', science: 'ScienceDefinition', system: 'SystemDefinition', vehicle: 'VehicleDefinition', word: 'WordDefinition', }; const scriptCommand = 'pnpm run generate:locales'; const autoGeneratedCommentHeader = `/* * This file is automatically generated. * Run '${scriptCommand}' to update. */`; // Helper functions function removeIndexTs(files: string[]): string[] { const index = files.indexOf('index.ts'); if (index !== -1) { files.splice(index, 1); } return files; } function removeTsSuffix(files: string[]): string[] { return files.map((file) => file.replace('.ts', '')); } function escapeImport(parent: string, module: string): string { if (['name', 'type', 'switch', parent].includes(module)) { return `${module}_`; } return module; } function escapeField(parent: string, module: string): string { if (['name', 'type', 'switch', parent].includes(module)) { return `${module}: ${module}_`; } return module; } async function loadMetadata(locale: string): Promise { const imported = await import( `file:${resolve(pathLocales, locale, 'metadata.ts')}` ); return imported.default as MetadataDefinition; } async function tryLoadMetadata(locale: string): Promise { try { return await loadMetadata(locale); } catch { return {}; } } async function generateLocaleFile(locale: string): Promise { const parts = locale.split('_'); const locales = [locale]; for (let i = parts.length - 1; i > 0; i--) { const fallback = parts.slice(0, i).join('_'); try { await access(resolve(pathLocales, fallback), constants.R_OK); locales.push(fallback); } catch { // file is missing } } // TODO @Shinigami92 2023-03-07: Remove 'en' fallback in a separate PR if (locales.at(-1) !== 'en' && locale !== 'base') { locales.push('en'); } if (locales.at(-1) !== 'base') { locales.push('base'); } const metadata = await tryLoadMetadata(locale); const imports = `import { Faker } from '../faker'; ${locales.map((imp) => `import ${imp} from '../locales/${imp}';`).join('\n')}`; const jsdoc = locale === 'base' ? `The faker instance for the \`base\` locale. The base locale contains data that is shared across all locales such as ISO codes, time zones, and more.` : `The faker instance for the \`${locale}\` locale. - Language: ${metadata.title} - Endonym: ${metadata.endonym} This instance uses the following locales internally (in descending precedence): ${locales.map((locale) => `- \`${locale}\``).join('\n')}`; const content = ` ${autoGeneratedCommentHeader} ${imports} /** * ${jsdoc.replaceAll('\n', '\n * ')} */ export const faker = new Faker({ locale: ${ locales.length === 1 ? locales[0] : `[${locales.join(', ')}]` }, }); `; return writeFile( resolve(pathLocale, `${locale}.ts`), await formatTypescript(content) ); } async function generateLocalesIndexFile( path: string, name: string, type: string, depth: number ): Promise { let modules = await readdir(path); modules = modules.filter((file) => !file.startsWith('.')); modules = removeIndexTs(modules); modules = removeTsSuffix(modules); modules.sort(); const metadata = depth === 1 ? await tryLoadMetadata(name) : {}; const jsdoc = depth === 1 ? name === 'base' ? `/** * The locale data for the \`base\` locale. * * The base locale contains data that is shared across all locales such as ISO codes, time zones, and more. */` : `/** * The locale data for the \`${name}\` locale. * * - Language: ${metadata.title} * - Endonym: ${metadata.endonym} */` : ''; const content = [autoGeneratedCommentHeader]; let fieldType = ''; if (type !== 'any') { fieldType = `: ${type}`; content.push( `import type { ${type.replace(/\[.*/, '')} } from '..${'/..'.repeat( depth )}';` ); } content.push( ...modules.map( (module) => `import ${escapeImport(name, module)} from './${module}';` ), '', jsdoc, `const ${name}${fieldType} = { ${modules.map((module) => `${escapeField(name, module)},`).join('\n')} };`, '', `export default ${name};` ); return writeFile( resolve(path, 'index.ts'), await formatTypescript(content.join('\n')) ); } async function generateRecursiveModuleIndexes( path: string, name: string, definition: string, depth: number ): Promise { await generateLocalesIndexFile(path, name, definition, depth); const promises: Array> = []; let submodules = await readdir(path); submodules = removeIndexTs(submodules); for (const submodule of submodules) { const pathModule = resolve(path, submodule); await updateLocaleFile(pathModule); // Only process sub folders recursively const moduleStat = await stat(pathModule); if (moduleStat.isDirectory()) { let moduleDefinition = definition === 'any' ? 'any' : `${definition}['${submodule}']`; // Overwrite types of src/locales///index.ts for known definition types if (depth === 1) { moduleDefinition = definitionsTypes[submodule] ?? 'any'; } // Recursive promises.push( generateRecursiveModuleIndexes( pathModule, submodule, moduleDefinition, depth + 1 ) ); } } return Promise.all(promises); } /** * Intermediate helper function to allow selectively updating locale data files. * Use the `updateLocaleFileHook()` method to temporarily add your custom per file processing/update logic. * * @param filePath The full file path to the file. */ async function updateLocaleFile(filePath: string): Promise { const fileStat = await stat(filePath); if (fileStat.isFile()) { const [locale, moduleKey, entryKey] = filePath .substring(pathLocales.length + 1, filePath.length - 3) .split(/[\\/]/); return updateLocaleFileHook(filePath, locale, moduleKey, entryKey); } } /** * Use this hook method to selectively update locale data files (not for index.ts files). * This method is intended to be temporarily overwritten for one-time updates. * * @param filePath The full file path to the file. * @param locale The locale for that file. * @param definitionKey The definition key of the current file (ex. 'location'). * @param entryName The entry key of the current file (ex. 'state'). Is `undefined` if `definitionKey` is `'metadata'`. */ async function updateLocaleFileHook( filePath: string, locale: string, definitionKey: string, entryName: string | undefined ): Promise { // this needs to stay so all arguments are "used" if (filePath === 'never') { console.log(`${filePath} <-> ${locale} @ ${definitionKey} -> ${entryName}`); } return normalizeLocaleFile(filePath, definitionKey); } /** * Normalizes the data of a locale file based on a set of rules. * Those include: * - filter the entry list for duplicates * - limiting the maximum entries of a file to 1000 * - sorting the entries alphabetically * * This function mutates the file by reading and writing to it! * * @param filePath The full file path to the file. * @param definitionKey The definition key of the current file (ex. 'location'). */ async function normalizeLocaleFile(filePath: string, definitionKey: string) { function normalizeDataRecursive(localeData: T): T { if (typeof localeData !== 'object' || localeData === null) { // we can only traverse object-like structs return localeData; } if (Array.isArray(localeData)) { return ( [...new Set(localeData)] // limit entries to 1k .slice(0, 1000) // sort entries alphabetically // We cannot sort the entries locale aware as the sort order is not stable within node versions #2905 .sort() as T ); } const result = {} as T; for (const key of keys(localeData)) { result[key] = normalizeDataRecursive(localeData[key]); } return result; } const legacyDefinitions = ['app', 'cell_phone', 'team']; const definitionsToSkip = [ 'location', 'lorem', 'metadata', 'person', 'phone_number', 'system', 'word', ...legacyDefinitions, ]; if (definitionsToSkip.includes(definitionKey)) { return; } console.log(`Running data normalization for:`, filePath); const fileContent = await readFile(filePath, { encoding: 'utf8' }); const searchString = 'export default '; const compareIndex = fileContent.indexOf(searchString) + searchString.length; const compareString = fileContent.substring(compareIndex); const isNonApplicable = compareString.startsWith('null'); if (isNonApplicable) { return; } const validEntryListStartCharacters = ['[', '{']; const staticFileOpenSyntax = validEntryListStartCharacters.find( (validStart) => compareString.startsWith(validStart) ); if (staticFileOpenSyntax === undefined) { console.log('Found an unhandled dynamic file:', filePath); return; } const fileContentPreData = fileContent.substring(0, compareIndex); const fileImport = await import(`file:${filePath}`); const oldData = fileImport.default; const localeData = normalizeDataRecursive(oldData); // We reattach the content before the actual data implementation to keep stuff like comments. // In the long term we should probably define a whether we want those in the files at all. const newDataJson = JSON.stringify(localeData); const newContent = fileContentPreData + newDataJson; // Exit early if unchanged for performance reasons if (JSON.stringify(oldData) === newDataJson) { return; } return writeFile(filePath, await formatTypescript(newContent)); } // Start of actual logic const locales = await readdir(pathLocales); removeIndexTs(locales); // src/locale/index.ts (Faker Imports and Exports) let localeIndexImports = ''; let localeIndexExportsIndividual = ''; let localeIndexExportsGrouped = ''; // src/locales/index.ts (Locale Data Imports and Exports) let localesIndexImports = ''; let localesIndexExportsIndividual = ''; let localesIndexExportsGrouped = ''; let localizationLocales = '| Locale | Name | Faker |\n| :--- | :--- | :--- |\n'; const promises: Array> = []; for (const locale of locales) { const pathModules = resolve(pathLocales, locale); const pathMetadata = resolve(pathModules, 'metadata.ts'); let localeTitle = 'No title found'; try { const metadata: MetadataDefinition = await loadMetadata(locale); const { title } = metadata; if (!title) { throw new Error(`No title property found on ${JSON.stringify(metadata)}`); } localeTitle = title; } catch (error) { console.error( `Failed to load ${pathMetadata}. Please make sure the file exists and exports a MetadataDefinition.` ); console.error(error); } const localizedFaker = `faker${locale.replace(/^([a-z]+)/, (part) => part.toUpperCase() )}`; localeIndexImports += `import { faker as ${localizedFaker} } from './${locale}';\n`; localeIndexExportsIndividual += `export { faker as ${localizedFaker} } from './${locale}';\n`; localeIndexExportsGrouped += ` ${locale}: ${localizedFaker},\n`; localesIndexImports += `import ${locale} from './${locale}';\n`; localesIndexExportsIndividual += `export { default as ${locale} } from './${locale}';\n`; localesIndexExportsGrouped += ` ${locale},\n`; localizationLocales += `| \`${locale}\` | ${localeTitle} | \`${localizedFaker}\` |\n`; promises.push( // src/locale/.ts // eslint-disable-next-line unicorn/prefer-top-level-await -- Disabled for performance generateLocaleFile(locale), // src/locales/**/index.ts // eslint-disable-next-line unicorn/prefer-top-level-await -- Disabled for performance generateRecursiveModuleIndexes(pathModules, locale, 'LocaleDefinition', 1) ); } await Promise.all(promises); // src/locale/index.ts let localeIndexContent = ` ${autoGeneratedCommentHeader} ${localeIndexImports} ${localeIndexExportsIndividual} export const allFakers = { ${localeIndexExportsGrouped} } as const; `; localeIndexContent = await formatTypescript(localeIndexContent); await writeFile(pathLocaleIndex, localeIndexContent); // src/locales/index.ts let localesIndexContent = ` ${autoGeneratedCommentHeader} ${localesIndexImports} ${localesIndexExportsIndividual} export const allLocales = { ${localesIndexExportsGrouped} } as const; `; localesIndexContent = await formatTypescript(localesIndexContent); await writeFile(pathLocalesIndex, localesIndexContent); // docs/guide/localization.md localizationLocales = await formatMarkdown(localizationLocales); let localizationContent = await readFile(pathDocsGuideLocalization, 'utf8'); localizationContent = localizationContent.replaceAll( /(^$).*(^$)/gms, `$1\n\n\n\n${localizationLocales}\n$2` ); await writeFile(pathDocsGuideLocalization, localizationContent);