mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
refactor(i18n): load server catalogs from weblate json (#3378)
This commit is contained in:
1 parent
d564ca5691
commit
1198b61648
114 files changed
+171
-27595
No files matched your search
@@ -8,9 +8,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"generate:types": "tsx scripts/GenerateI18nTypes.ts",
|
||||
"static:compile": "tsx scripts/SyncStaticI18nCatalogs.ts --compile",
|
||||
"static:extract": "tsx scripts/SyncStaticI18nCatalogs.ts --extract",
|
||||
"static:sync": "tsx scripts/SyncStaticI18nCatalogs.ts --extract --compile",
|
||||
"static:sync": "tsx scripts/SyncStaticI18nCatalogs.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -8,8 +8,6 @@ import {parseArgs} from 'node:util';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../..');
|
||||
const GENERATED_HEADER = '// SPDX-License-Identifier: AGPL-3.0-or-later\n\n';
|
||||
const USAGE = 'usage: SyncStaticI18nCatalogs.ts --extract [--compile] [--catalog <name>]...';
|
||||
|
||||
const STATIC_I18N_LOCALES = [
|
||||
'ar',
|
||||
@@ -65,10 +63,6 @@ interface StaticCatalogConfig {
|
||||
sourceModulePath: string;
|
||||
sourceExportName: string;
|
||||
weblateDir: string;
|
||||
localeTsDir: string;
|
||||
defineImportPath: string;
|
||||
defineFunctionName: string;
|
||||
exportPrefix: string;
|
||||
}
|
||||
|
||||
const CATALOGS: Array<StaticCatalogConfig> = [
|
||||
@@ -78,10 +72,6 @@ const CATALOGS: Array<StaticCatalogConfig> = [
|
||||
sourceModulePath: path.join(REPO_ROOT, 'packages/errors/src/i18n/ErrorI18nMessages.ts'),
|
||||
sourceExportName: 'ERROR_I18N_MESSAGES',
|
||||
weblateDir: path.join(REPO_ROOT, 'packages/errors/src/i18n/weblate'),
|
||||
localeTsDir: path.join(REPO_ROOT, 'packages/errors/src/i18n/locales'),
|
||||
defineImportPath: '@fluxer/errors/src/i18n/ErrorI18nMessages',
|
||||
defineFunctionName: 'defineErrorI18nLocaleMessages',
|
||||
exportPrefix: 'ERROR_I18N',
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
@@ -89,10 +79,6 @@ const CATALOGS: Array<StaticCatalogConfig> = [
|
||||
sourceModulePath: path.join(REPO_ROOT, 'fluxer_api/pkgs/email/src/email_i18n/EmailI18nMessages.ts'),
|
||||
sourceExportName: 'EMAIL_I18N_MESSAGES',
|
||||
weblateDir: path.join(REPO_ROOT, 'fluxer_api/pkgs/email/src/email_i18n/weblate'),
|
||||
localeTsDir: path.join(REPO_ROOT, 'fluxer_api/pkgs/email/src/email_i18n/locales'),
|
||||
defineImportPath: '@pkgs/email/src/email_i18n/EmailI18nMessages',
|
||||
defineFunctionName: 'defineEmailI18nLocaleMessages',
|
||||
exportPrefix: 'EMAIL_I18N',
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
@@ -100,10 +86,6 @@ const CATALOGS: Array<StaticCatalogConfig> = [
|
||||
sourceModulePath: path.join(REPO_ROOT, 'fluxer_api/src/api/content_i18n/ContentI18nMessages.ts'),
|
||||
sourceExportName: 'CONTENT_I18N_MESSAGES',
|
||||
weblateDir: path.join(REPO_ROOT, 'fluxer_api/src/api/content_i18n/weblate'),
|
||||
localeTsDir: path.join(REPO_ROOT, 'fluxer_api/src/api/content_i18n/locales'),
|
||||
defineImportPath: '../ContentI18nMessages',
|
||||
defineFunctionName: 'defineContentI18nLocaleMessages',
|
||||
exportPrefix: 'CONTENT_I18N',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -127,17 +109,6 @@ async function importExport<T>(modulePath: string, exportName: string): Promise<
|
||||
return value as T;
|
||||
}
|
||||
|
||||
async function importDefault<T>(modulePath: string): Promise<T | null> {
|
||||
if (!fs.existsSync(modulePath)) {
|
||||
return null;
|
||||
}
|
||||
const module = await import(pathToFileURL(modulePath).href);
|
||||
if (!module.default || typeof module.default !== 'object' || Array.isArray(module.default)) {
|
||||
return null;
|
||||
}
|
||||
return module.default as T;
|
||||
}
|
||||
|
||||
function readJson<T>(filePath: string): T | null {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
@@ -158,15 +129,6 @@ function writeJson(filePath: string, value: unknown): void {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, '\t')}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function localeTsPath(config: StaticCatalogConfig, locale: StaticLocale): string {
|
||||
return path.join(config.localeTsDir, `${locale}.ts`);
|
||||
}
|
||||
|
||||
function exportName(config: StaticCatalogConfig, locale: StaticLocale): string {
|
||||
const normalizedLocale = locale.replace(/[^a-zA-Z0-9]+/g, '_').toUpperCase();
|
||||
return `${config.exportPrefix}_${normalizedLocale}_MESSAGES`;
|
||||
}
|
||||
|
||||
function normalizeFlatCatalog(source: FlatCatalog, existing: unknown): FlatCatalog {
|
||||
const existingRecord = isRecord(existing) ? existing : {};
|
||||
const next: FlatCatalog = {};
|
||||
@@ -236,42 +198,13 @@ async function extractCatalog(config: StaticCatalogConfig): Promise<void> {
|
||||
writeJson(jsonPath(config), sortKeys<Catalog[string]>(source));
|
||||
|
||||
for (const locale of STATIC_I18N_LOCALES) {
|
||||
const existingJson = readJson<unknown>(jsonPath(config, locale));
|
||||
const existingTs = existingJson === null ? await importDefault<Catalog>(localeTsPath(config, locale)) : null;
|
||||
const next = normalizeCatalog(config, source, existingJson ?? existingTs);
|
||||
const next = normalizeCatalog(config, source, readJson<unknown>(jsonPath(config, locale)));
|
||||
writeJson(jsonPath(config, locale), sortKeys<Catalog[string]>(next));
|
||||
}
|
||||
console.log(`extracted static ${config.name} catalogs`);
|
||||
}
|
||||
|
||||
function renderTs(config: StaticCatalogConfig, locale: StaticLocale, catalog: Catalog): string {
|
||||
const name = exportName(config, locale);
|
||||
return `${GENERATED_HEADER}import {${config.defineFunctionName}} from '${config.defineImportPath}';
|
||||
|
||||
const ${name} = ${config.defineFunctionName}(${JSON.stringify(catalog, null, '\t')});
|
||||
|
||||
export default ${name};
|
||||
`;
|
||||
}
|
||||
|
||||
async function compileCatalog(config: StaticCatalogConfig): Promise<void> {
|
||||
const source = validateCatalog(
|
||||
config.kind,
|
||||
await importExport(config.sourceModulePath, config.sourceExportName),
|
||||
`${config.name} source catalog`,
|
||||
);
|
||||
for (const locale of STATIC_I18N_LOCALES) {
|
||||
const existing = readJson<unknown>(jsonPath(config, locale));
|
||||
const catalog = normalizeCatalog(config, source, existing);
|
||||
ensureDir(config.localeTsDir);
|
||||
fs.writeFileSync(localeTsPath(config, locale), renderTs(config, locale, catalog), 'utf8');
|
||||
}
|
||||
console.log(`compiled static ${config.name} locale modules`);
|
||||
}
|
||||
|
||||
interface SyncOptions {
|
||||
extract: boolean;
|
||||
compile: boolean;
|
||||
catalogs: Array<StaticCatalogConfig>;
|
||||
}
|
||||
|
||||
@@ -279,24 +212,17 @@ function parseSyncOptions(argv: Array<string>): SyncOptions {
|
||||
const {values} = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
extract: {type: 'boolean', default: false},
|
||||
compile: {type: 'boolean', default: false},
|
||||
catalog: {type: 'string', multiple: true, default: []},
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
if (!values.extract && !values.compile) {
|
||||
throw new Error(USAGE);
|
||||
}
|
||||
const known = CATALOGS.map((config) => config.name);
|
||||
const unknown = values.catalog.filter((name) => !known.includes(name));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`unknown catalog ${unknown.join(', ')}, expected one of ${known.join(', ')}`);
|
||||
}
|
||||
return {
|
||||
extract: values.extract,
|
||||
compile: values.compile,
|
||||
catalogs:
|
||||
values.catalog.length === 0 ? CATALOGS : CATALOGS.filter((config) => values.catalog.includes(config.name)),
|
||||
};
|
||||
@@ -305,12 +231,7 @@ function parseSyncOptions(argv: Array<string>): SyncOptions {
|
||||
async function main(): Promise<void> {
|
||||
const options = parseSyncOptions(process.argv.slice(2));
|
||||
for (const config of options.catalogs) {
|
||||
if (options.extract) {
|
||||
await extractCatalog(config);
|
||||
}
|
||||
if (options.compile) {
|
||||
await compileCatalog(config);
|
||||
}
|
||||
await extractCatalog(config);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,27 +47,6 @@ export type MessageVariablesForTemplate<T extends string> = [TemplatePlaceholder
|
||||
export type MessageArgsForTemplate<T extends string> = [MessageVariablesForTemplate<T>] extends [never]
|
||||
? [variables?: undefined]
|
||||
: [variables: MessageVariablesForTemplate<T>];
|
||||
type LocaleMessageForTemplate<Source extends string, Translation extends string> = [
|
||||
Exclude<TemplatePlaceholderNames<Source>, TemplatePlaceholderNames<Translation>>,
|
||||
] extends [never]
|
||||
? Translation
|
||||
: never;
|
||||
type StaticLocaleMessagesForCatalog<
|
||||
TCatalog extends Record<string, string>,
|
||||
TMessages extends Partial<Record<keyof TCatalog, string>>,
|
||||
> = {
|
||||
readonly [Key in keyof TMessages]: Key extends keyof TCatalog
|
||||
? TMessages[Key] extends string
|
||||
? LocaleMessageForTemplate<TCatalog[Key], TMessages[Key]>
|
||||
: never
|
||||
: never;
|
||||
};
|
||||
|
||||
export function defineStaticLocaleMessages<TCatalog extends Record<string, string>>() {
|
||||
return <const TMessages extends Partial<Record<keyof TCatalog, string>>>(
|
||||
messages: TMessages & StaticLocaleMessagesForCatalog<TCatalog, TMessages>,
|
||||
): TMessages => messages;
|
||||
}
|
||||
|
||||
function collectMessageTemplateVariables(tokens: ReadonlyArray<Token>, variables: Set<string>): void {
|
||||
for (const token of tokens) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type {MessageArgsForTemplate, MessageVariablesForTemplate} from '@fluxer/i18n/src/runtime/MessageCatalogTypes';
|
||||
import {
|
||||
defineStaticLocaleMessages,
|
||||
extractMessageTemplatePlaceholders,
|
||||
extractMessageTemplateVariables,
|
||||
validateMessageTemplateVariables,
|
||||
@@ -44,15 +43,4 @@ describe('message catalog type helpers', () => {
|
||||
expectTypeOf<Args>().toEqualTypeOf<[variables: {userName: string | number | boolean | Date}]>();
|
||||
expectTypeOf<NoArgs>().toEqualTypeOf<[variables?: undefined]>();
|
||||
});
|
||||
it('defines locale messages with source placeholders preserved', () => {
|
||||
const defineMessages = defineStaticLocaleMessages<{
|
||||
greeting: 'Hello {userName}.';
|
||||
count: '{count, plural, one {# item} other {# items}}';
|
||||
}>();
|
||||
const messages = defineMessages({
|
||||
greeting: 'Salut {userName}.',
|
||||
count: '{count, plural, one {# element} other {# elements}}',
|
||||
});
|
||||
expect(messages.greeting).toBe('Salut {userName}.');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user