Files
v10/packages/core/scripts/generate-i18n-locales.ts
T
2026-08-03 09:27:26 +10:00

213 lines
7.8 KiB
TypeScript

import { execFileSync } from 'node:child_process';
import { mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import en from '../src/core/i18n/locales/en.ts';
import { LOCALES, localeAliases } from '../src/core/i18n/locales.ts';
import { flattenEntries } from './i18n-utils.ts';
const GENERATED_HEADER = '/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */\n';
const coreRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = resolve(coreRoot, '../..');
const coreLocalesDir = resolve(coreRoot, 'src/core/i18n/locales');
const htmlLocalesDir = resolve(coreRoot, '../html/src/i18n/locales');
const reactLocalesDir = resolve(coreRoot, '../react/src/i18n/locales');
const textDir = resolve(coreRoot, 'src/core/i18n/text');
const generatedFiles = new Set<string>();
const localeTags = [...LOCALES, ...localeAliases(LOCALES)] as const;
const PLATFORM_LOCALE_TAGS = ['en', ...localeTags] as const;
function importBinding(tag: string): string {
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(tag) ? tag : tag.replace(/-/g, '_');
}
function objectEntry(tag: string): string {
const binding = importBinding(tag);
return tag === binding ? ` ${tag},` : ` '${tag}': ${binding},`;
}
function propertyKey(key: string): string {
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `'${key}'`;
}
function generateLoadLocaleTs(): string {
const entries = localeTags
.map((tag) => ` ${propertyKey(tag.trim().replaceAll('_', '-').toLowerCase())}: () => import('./locales/${tag}'),`)
.join('\n');
return `${GENERATED_HEADER}import { flattenTranslations } from './utils';
import { findLocaleKeys, getCanonicalLocaleKey, hasRegisteredLocale } from './registry';
import type { FlatTranslations, Translations } from './params';
const loaders = {
${entries}
} as const satisfies Record<string, () => Promise<{ default: Translations }>>;
/** Lazy-import a shipped locale pack when the tag is not already in the registry. */
export async function loadLocale(tag: string): Promise<Partial<FlatTranslations> | undefined> {
if (hasRegisteredLocale(tag)) return undefined;
for (const chainTag of findLocaleKeys(tag)) {
if (hasRegisteredLocale(chainTag)) return undefined;
const load = loaders[getCanonicalLocaleKey(chainTag) as keyof typeof loaders];
if (load) return flattenTranslations((await load()).default);
}
return undefined;
}
`;
}
function generateCoreAllTs(): string {
const importTags = ['en', ...localeTags].sort();
const imports = importTags.map((tag) => `import ${importBinding(tag)} from './${tag}';`).join('\n');
const objectLines = [' en,', ...localeTags.map(objectEntry)].join('\n');
return `${GENERATED_HEADER}import type { Translations } from '../params';
${imports}
/** Every built-in locale pack keyed by BCP 47 tag. */
export const all = {
${objectLines}
} as const satisfies Record<string, Partial<Translations>>;
export type LocaleTag = keyof typeof all;
/** BCP 47 tags for every pack in {@link all}. */
export const localeTags = Object.keys(all) as LocaleTag[];
`;
}
function generatePlatformDefaultReExport(tag: string): string {
return `${GENERATED_HEADER}export { default } from '@videojs/core/i18n/locales/${tag}';
`;
}
function generatePlatformAllReExport(): string {
return `${GENERATED_HEADER}export { all, type LocaleTag, localeTags } from '@videojs/core/i18n/locales/all';
`;
}
function exportName(key: string): string {
const name = key.slice(key.indexOf('.') + 1);
return name === 'default' ? 'defaultText' : `${name}Text`;
}
function stringLiteral(value: string): string {
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'`;
}
function generateTextModule(entries: [string, string][]): string {
const namespace = entries[0][0].slice(0, entries[0][0].indexOf('.'));
const prefix = `${namespace}.`;
const exports = entries
.map(
([key, text]) =>
`export const ${exportName(key)} = {\n key: \`\${prefix}${key.slice(prefix.length)}\`,\n text: ${stringLiteral(text)},\n} as const satisfies Text;`
)
.join('\n\n');
return `${GENERATED_HEADER}import type { Text } from '../text';\n\nconst prefix = '${prefix}';\n\n${exports}\n`;
}
async function validateLocaleCompleteness(): Promise<void> {
const englishKeys = new Set(flattenEntries(en).map(([key]) => key));
const errors: string[] = [];
for (const tag of localeTags) {
const { default: locale } = await import(pathToFileURL(resolve(coreLocalesDir, `${tag}.ts`)).href);
const localeKeys = new Set(flattenEntries(locale ?? {}).map(([key]) => key));
const missing = [...englishKeys].filter((key) => !localeKeys.has(key));
if (missing.length) errors.push(`${tag}: ${missing.join(', ')}`);
}
if (errors.length) {
throw new Error(`Locale packs are missing English keys:\n${errors.join('\n')}`);
}
}
function generateTextModules(): void {
rmSync(textDir, { recursive: true, force: true });
mkdirSync(textDir, { recursive: true });
const namespaces = new Map<string, [string, string][]>();
for (const [key, text] of flattenEntries(en)) {
const namespace = key.slice(0, key.indexOf('.'));
const entries = namespaces.get(namespace) ?? [];
entries.push([key, text]);
namespaces.set(namespace, entries);
}
for (const [namespace, entries] of namespaces) {
writeGenerated(resolve(textDir, `${namespace}.ts`), generateTextModule(entries));
}
}
function generatePlatformRegisterTs(tag: string): string {
return `${GENERATED_HEADER}import { registerI18n } from '@videojs/core/i18n';
import translations from '@videojs/core/i18n/locales/${tag}';
registerI18n('${tag}', translations);
`;
}
function generatePlatformRegisterAllTs(): string {
return `${GENERATED_HEADER}import { registerI18n } from '@videojs/core/i18n';
import { all } from '@videojs/core/i18n/locales/all';
for (const [tag, translations] of Object.entries(all)) {
registerI18n(tag, translations);
}
`;
}
function writeGenerated(path: string, content: string): void {
writeFileSync(path, content.endsWith('\n') ? content : `${content}\n`);
generatedFiles.add(path);
}
function syncPlatformLocaleDir(dir: string): void {
const expected = new Set<string>([...PLATFORM_LOCALE_TAGS, 'all'].map((tag) => `${tag}.ts`));
for (const tag of PLATFORM_LOCALE_TAGS) {
writeGenerated(resolve(dir, `${tag}.ts`), generatePlatformDefaultReExport(tag));
const registerDir = resolve(dir, tag);
mkdirSync(registerDir, { recursive: true });
writeGenerated(resolve(registerDir, 'register.ts'), generatePlatformRegisterTs(tag));
}
writeGenerated(resolve(dir, 'all.ts'), generatePlatformAllReExport());
const allRegisterDir = resolve(dir, 'all');
mkdirSync(allRegisterDir, { recursive: true });
writeGenerated(resolve(allRegisterDir, 'register.ts'), generatePlatformRegisterAllTs());
for (const file of readdirSync(dir)) {
if (!file.endsWith('.ts') || expected.has(file)) {
continue;
}
unlinkSync(resolve(dir, file));
}
const expectedDirs = new Set([...PLATFORM_LOCALE_TAGS, 'all']);
for (const file of readdirSync(dir, { withFileTypes: true })) {
if (!file.isDirectory() || expectedDirs.has(file.name)) {
continue;
}
rmSync(resolve(dir, file.name), { recursive: true, force: true });
}
}
await validateLocaleCompleteness();
writeGenerated(resolve(coreLocalesDir, 'all.ts'), generateCoreAllTs());
writeGenerated(resolve(coreRoot, 'src/core/i18n/load-locale.ts'), generateLoadLocaleTs());
generateTextModules();
syncPlatformLocaleDir(htmlLocalesDir);
syncPlatformLocaleDir(reactLocalesDir);
execFileSync('pnpm', ['exec', 'biome', 'check', '--write', ...generatedFiles], {
cwd: repoRoot,
stdio: 'inherit',
});
console.log('[generate-i18n-locales] Updated locale packs, text descriptors, and platform re-exports');