refactor progress
This commit is contained in:
28
packages/i18n/package.json
Normal file
28
packages/i18n/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@fluxer/i18n",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"generate:types": "tsx scripts/GenerateI18nTypes.ts",
|
||||
"prune:apply": "tsx scripts/PruneUnusedI18nKeys.ts --apply --force",
|
||||
"prune:dry": "tsx scripts/PruneUnusedI18nKeys.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@messageformat/core": "catalog:",
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
164
packages/i18n/scripts/GenerateI18nTypes.ts
Normal file
164
packages/i18n/scripts/GenerateI18nTypes.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {parse as parseYaml} from 'yaml';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ROOT_DIR = path.resolve(__dirname, '../..');
|
||||
|
||||
interface PackageConfig {
|
||||
name: string;
|
||||
localesPath: string;
|
||||
outputFile: string;
|
||||
isEmail?: boolean;
|
||||
}
|
||||
|
||||
const PACKAGES: Array<PackageConfig> = [
|
||||
{
|
||||
name: '@fluxer/errors',
|
||||
localesPath: path.join(ROOT_DIR, 'errors/src/i18n/locales'),
|
||||
outputFile: path.join(ROOT_DIR, 'errors/src/i18n/ErrorI18nTypes.generated.tsx'),
|
||||
},
|
||||
{
|
||||
name: '@fluxer/email',
|
||||
localesPath: path.join(ROOT_DIR, 'email/src/email_i18n/locales'),
|
||||
outputFile: path.join(ROOT_DIR, 'email/src/email_i18n/EmailI18nTypes.generated.tsx'),
|
||||
isEmail: true,
|
||||
},
|
||||
{
|
||||
name: '@fluxer/marketing',
|
||||
localesPath: path.join(ROOT_DIR, 'marketing/src/marketing_i18n/locales'),
|
||||
outputFile: path.join(ROOT_DIR, 'marketing/src/marketing_i18n/MarketingI18nTypes.generated.tsx'),
|
||||
},
|
||||
];
|
||||
|
||||
function extractKeysFromYaml(filePath: string, isEmail = false): Array<string> {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = parseYaml(raw) as Record<string, unknown>;
|
||||
|
||||
if (isEmail) {
|
||||
const emailTemplates = parsed as Record<string, {subject: string; body: string}>;
|
||||
return Object.keys(emailTemplates).sort();
|
||||
}
|
||||
|
||||
return Object.keys(parsed).sort();
|
||||
}
|
||||
|
||||
function generateErrorI18nTypes(keys: Array<string>): string {
|
||||
const licenceHeader = getLicenceHeader();
|
||||
const unionType = keys.map((key) => `\t| '${key}'`).join('\n');
|
||||
|
||||
return `${licenceHeader}
|
||||
export type ErrorI18nKey =
|
||||
${unionType};
|
||||
`;
|
||||
}
|
||||
|
||||
function generateEmailI18nTypes(keys: Array<string>): string {
|
||||
const licenceHeader = getLicenceHeader();
|
||||
const unionType = keys.map((key) => `\t| '${key}'`).join('\n');
|
||||
|
||||
return `${licenceHeader}
|
||||
export type EmailTemplateKey =
|
||||
${unionType};
|
||||
|
||||
export interface EmailTemplate {
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function generateMarketingI18nTypes(keys: Array<string>): string {
|
||||
const licenceHeader = getLicenceHeader();
|
||||
const unionType = keys.map((key) => `\t| '${key}'`).join('\n');
|
||||
|
||||
return `${licenceHeader}
|
||||
export type MarketingI18nKey =
|
||||
${unionType};
|
||||
`;
|
||||
}
|
||||
|
||||
function getLicenceHeader(): string {
|
||||
return `/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
`;
|
||||
}
|
||||
|
||||
function generatePackageTypes(config: PackageConfig): void {
|
||||
const messagesFile = path.join(config.localesPath, 'messages.yaml');
|
||||
|
||||
if (!fs.existsSync(messagesFile)) {
|
||||
console.error(`messages file not found: ${messagesFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const keys = extractKeysFromYaml(messagesFile, config.isEmail);
|
||||
|
||||
let content: string;
|
||||
if (config.name === '@fluxer/errors') {
|
||||
content = generateErrorI18nTypes(keys);
|
||||
} else if (config.name === '@fluxer/email') {
|
||||
content = generateEmailI18nTypes(keys);
|
||||
} else if (config.name === '@fluxer/marketing') {
|
||||
content = generateMarketingI18nTypes(keys);
|
||||
} else {
|
||||
throw new Error(`unknown package: ${config.name}`);
|
||||
}
|
||||
|
||||
const outputDir = path.dirname(config.outputFile);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, {recursive: true});
|
||||
}
|
||||
|
||||
fs.writeFileSync(config.outputFile, content, 'utf8');
|
||||
console.log(`generated types for ${config.name} (${keys.length} keys) -> ${config.outputFile}`);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
console.log('generating i18n types...\n');
|
||||
|
||||
for (const config of PACKAGES) {
|
||||
generatePackageTypes(config);
|
||||
}
|
||||
|
||||
console.log('\nall i18n types generated successfully!');
|
||||
}
|
||||
|
||||
main();
|
||||
543
packages/i18n/scripts/PruneUnusedI18nKeys.ts
Normal file
543
packages/i18n/scripts/PruneUnusedI18nKeys.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {spawn} from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {isScalar, parseDocument, parse as parseYaml, Scalar, YAMLMap} from 'yaml';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ROOT_DIR = path.resolve(__dirname, '../..');
|
||||
|
||||
interface PackageConfig {
|
||||
name: string;
|
||||
packagePath: string;
|
||||
localesPath: string;
|
||||
i18nMethod: 'getMessage' | 'getTemplate';
|
||||
skip: boolean;
|
||||
}
|
||||
|
||||
const PACKAGES: Array<PackageConfig> = [
|
||||
{
|
||||
name: '@fluxer/errors',
|
||||
packagePath: path.join(ROOT_DIR, 'errors'),
|
||||
localesPath: path.join(ROOT_DIR, 'errors/src/i18n/locales'),
|
||||
i18nMethod: 'getMessage',
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: '@fluxer/marketing',
|
||||
packagePath: path.join(ROOT_DIR, 'marketing'),
|
||||
localesPath: path.join(ROOT_DIR, 'marketing/src/marketing_i18n/locales'),
|
||||
i18nMethod: 'getMessage',
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: '@fluxer/email',
|
||||
packagePath: path.join(ROOT_DIR, 'email'),
|
||||
localesPath: path.join(ROOT_DIR, 'email/src/email_i18n/locales'),
|
||||
i18nMethod: 'getTemplate',
|
||||
skip: true,
|
||||
},
|
||||
];
|
||||
|
||||
const GET_MESSAGE_REGEX = /ctx\.i18n\.getMessage\s*\([^)]*\)/g;
|
||||
const GET_MESSAGE_REGEX2 = /i18n\.getMessage\s*\([^)]*\)/g;
|
||||
const GET_MESSAGE_REGEX3 = /ctx\.i18n\.getMessage\s*\((?:[^()]|\([^()]*\))*\)/g;
|
||||
const GET_MESSAGE_REGEX4 = /i18n\.getMessage\s*\((?:[^()]|\([^()]*\))*\)/g;
|
||||
const GET_TEMPLATE_REGEX = /getTemplate\s*\((?:[^()]|\([^()]*\))*\)/g;
|
||||
|
||||
const STRING_LITERAL_REGEX = /['"`]([^'"`]+)['"`]/g;
|
||||
|
||||
function findTypeScriptFiles(dir: string, filePaths: Array<string> = []): Array<string> {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return filePaths;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(dir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!['node_modules', '.next', 'dist', 'build', 'coverage', '.turbo'].includes(entry.name)) {
|
||||
findTypeScriptFiles(fullPath, filePaths);
|
||||
}
|
||||
} else if (entry.isFile() && (fullPath.endsWith('.tsx') || fullPath.endsWith('.ts'))) {
|
||||
filePaths.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return filePaths;
|
||||
}
|
||||
|
||||
function extractUsedKeys(packagePath: string, method: 'getMessage' | 'getTemplate'): Set<string> {
|
||||
const files = findTypeScriptFiles(packagePath);
|
||||
const usedKeys = new Set<string>();
|
||||
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
|
||||
if (method === 'getMessage') {
|
||||
const regexes = [GET_MESSAGE_REGEX, GET_MESSAGE_REGEX2, GET_MESSAGE_REGEX3, GET_MESSAGE_REGEX4];
|
||||
for (const regex of regexes) {
|
||||
regex.lastIndex = 0;
|
||||
let callMatch: RegExpExecArray | null = null;
|
||||
while ((callMatch = regex.exec(content)) !== null) {
|
||||
const callText = callMatch[0];
|
||||
STRING_LITERAL_REGEX.lastIndex = 0;
|
||||
let keyMatch: RegExpExecArray | null = null;
|
||||
while ((keyMatch = STRING_LITERAL_REGEX.exec(callText)) !== null) {
|
||||
usedKeys.add(keyMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GET_TEMPLATE_REGEX.lastIndex = 0;
|
||||
let callMatch: RegExpExecArray | null = null;
|
||||
while ((callMatch = GET_TEMPLATE_REGEX.exec(content)) !== null) {
|
||||
const callText = callMatch[0];
|
||||
STRING_LITERAL_REGEX.lastIndex = 0;
|
||||
let keyMatch: RegExpExecArray | null = null;
|
||||
while ((keyMatch = STRING_LITERAL_REGEX.exec(callText)) !== null) {
|
||||
usedKeys.add(keyMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return usedKeys;
|
||||
}
|
||||
|
||||
function flattenObject(
|
||||
obj: Record<string, unknown>,
|
||||
prefix: string = '',
|
||||
): Map<string, {value: unknown; path: Array<string>}> {
|
||||
const result = new Map<string, {value: unknown; path: Array<string>}>();
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
const currentPath = prefix ? [...prefix.split('.'), key] : [key];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
result.set(fullKey, {value, path: currentPath});
|
||||
} else if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
'subject' in value &&
|
||||
'body' in value
|
||||
) {
|
||||
result.set(fullKey, {value, path: currentPath});
|
||||
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
const nested = flattenObject(value as Record<string, unknown>, fullKey);
|
||||
for (const [nestedKey, nestedValue] of nested) {
|
||||
result.set(nestedKey, nestedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getYamlKeys(filePath: string): Map<string, {path: Array<string>}> {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = parseYaml(raw) as Record<string, unknown>;
|
||||
const flat = flattenObject(parsed);
|
||||
const result = new Map<string, {path: Array<string>}>();
|
||||
|
||||
for (const [key] of flat) {
|
||||
result.set(key, {path: key.split('.')});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function prunePackage(
|
||||
config: PackageConfig,
|
||||
usedKeys: Set<string>,
|
||||
dryRun: boolean,
|
||||
): {
|
||||
totalKeys: number;
|
||||
usedKeys: number;
|
||||
unusedKeys: Array<string>;
|
||||
localeFiles: Array<string>;
|
||||
} {
|
||||
console.log(`\n${'='.repeat(80)}`);
|
||||
console.log(`processing: ${config.name}`);
|
||||
console.log(`${'='.repeat(80)}`);
|
||||
|
||||
const localeFiles = fs.readdirSync(config.localesPath).filter((f) => f.endsWith('.yaml'));
|
||||
const allUnusedKeys = new Map<string, {path: Array<string>}>();
|
||||
const allKeys = new Map<string, {path: Array<string>}>();
|
||||
|
||||
const messagesFile = path.join(config.localesPath, 'messages.yaml');
|
||||
const yamlKeys = getYamlKeys(messagesFile);
|
||||
|
||||
for (const [key, meta] of yamlKeys) {
|
||||
allKeys.set(key, meta);
|
||||
if (!usedKeys.has(key)) {
|
||||
allUnusedKeys.set(key, meta);
|
||||
}
|
||||
}
|
||||
|
||||
const totalKeys = allKeys.size;
|
||||
const usedKeyCount = usedKeys.size;
|
||||
const unusedKeyArray = Array.from(allUnusedKeys.keys()).sort();
|
||||
|
||||
console.log(`total keys in messages.yaml: ${totalKeys}`);
|
||||
console.log(`used keys in code: ${usedKeyCount}`);
|
||||
console.log(`unused keys: ${unusedKeyArray.length}`);
|
||||
|
||||
if (unusedKeyArray.length > 0) {
|
||||
console.log(`\nunused keys:\n ${unusedKeyArray.map((k) => `- ${k}`).join('\n ')}`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\ndry run - would remove ${unusedKeyArray.length} unused keys from all locale files`);
|
||||
} else {
|
||||
console.log(`\npruning - removing ${unusedKeyArray.length} unused keys from all locale files`);
|
||||
}
|
||||
|
||||
const unusedKeySet = new Set(allUnusedKeys.keys());
|
||||
const modifiedFilePaths: Array<string> = [];
|
||||
|
||||
for (const localeFile of localeFiles) {
|
||||
const filePath = path.join(config.localesPath, localeFile);
|
||||
|
||||
if (dryRun) {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = parseYaml(raw) as Record<string, unknown>;
|
||||
let wouldModify = false;
|
||||
|
||||
for (const [unusedKey] of allUnusedKeys) {
|
||||
if (unusedKey in parsed) {
|
||||
wouldModify = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (wouldModify) {
|
||||
console.log(` would update ${localeFile}`);
|
||||
}
|
||||
} else {
|
||||
const backup = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
try {
|
||||
const modified = deleteKeysFromYamlFile(filePath, unusedKeySet);
|
||||
|
||||
if (modified) {
|
||||
const validation = validateYamlFile(filePath);
|
||||
|
||||
if (!validation.valid) {
|
||||
console.error(` error ${localeFile}: parse error after pruning, rolling back`);
|
||||
console.error(` error: ${validation.error}`);
|
||||
fs.writeFileSync(filePath, backup, 'utf8');
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` updated ${localeFile}`);
|
||||
modifiedFilePaths.push(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(` error ${localeFile}: error during pruning, rolling back`);
|
||||
console.error(` error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
fs.writeFileSync(filePath, backup, 'utf8');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalKeys,
|
||||
usedKeys: usedKeyCount,
|
||||
unusedKeys: unusedKeyArray,
|
||||
localeFiles,
|
||||
};
|
||||
}
|
||||
|
||||
function removeQuotesFromScalars(node: unknown): void {
|
||||
if (isScalar(node)) {
|
||||
if (node.type === 'QUOTE_SINGLE' || node.type === 'QUOTE_DOUBLE') {
|
||||
node.type = 'PLAIN';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (node instanceof YAMLMap) {
|
||||
for (const pair of node.items) {
|
||||
if (isScalar(pair.key)) {
|
||||
if (pair.key.type === 'QUOTE_SINGLE' || pair.key.type === 'QUOTE_DOUBLE') {
|
||||
pair.key.type = 'PLAIN';
|
||||
}
|
||||
}
|
||||
if (pair.value) {
|
||||
removeQuotesFromScalars(pair.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deleteKeysFromYamlFile(filePath: string, keysToDelete: Set<string>): boolean {
|
||||
const contents = fs.readFileSync(filePath, 'utf8');
|
||||
const doc = parseDocument(contents, {schema: 'core', version: '1.2'});
|
||||
const root = doc.contents;
|
||||
|
||||
if (!(root instanceof YAMLMap)) {
|
||||
console.warn(` warning ${path.basename(filePath)}: root is not a yaml map`);
|
||||
return false;
|
||||
}
|
||||
|
||||
let modified = false;
|
||||
|
||||
for (const keyToDelete of keysToDelete) {
|
||||
const pairIndex = root.items.findIndex((p) => p.key instanceof Scalar && p.key.value === keyToDelete);
|
||||
|
||||
if (pairIndex !== -1) {
|
||||
root.items.splice(pairIndex, 1);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
removeQuotesFromScalars(root);
|
||||
const newYaml = String(doc);
|
||||
fs.writeFileSync(filePath, newYaml, 'utf8');
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
function validateYamlFile(filePath: string): {valid: boolean; error?: string} {
|
||||
try {
|
||||
const contents = fs.readFileSync(filePath, 'utf8');
|
||||
parseYaml(contents);
|
||||
return {valid: true};
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function formatYamlFiles(filePaths: Array<string>): Promise<void> {
|
||||
if (filePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n formatting ${filePaths.length} file(s) with prettier...`);
|
||||
|
||||
const prettierArgs = ['prettier', '--write', '--parser=yaml', '--print-width=120', ...filePaths];
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const process = spawn('npx', prettierArgs, {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`prettier exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
console.log(' formatting complete');
|
||||
} catch (error) {
|
||||
console.warn(` warning prettier formatting failed: ${error}`);
|
||||
console.warn(' files are still valid yaml, just not formatted');
|
||||
}
|
||||
}
|
||||
|
||||
interface LocaleValidationResult {
|
||||
file: string;
|
||||
missingKeys: Array<string>;
|
||||
extraKeys: Array<string>;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
function validateLocaleConsistency(localesPath: string, messagesFile: string): Array<LocaleValidationResult> {
|
||||
const messagesContent = fs.readFileSync(path.join(localesPath, messagesFile), 'utf8');
|
||||
const messagesParsed = parseYaml(messagesContent) as Record<string, unknown>;
|
||||
const messagesKeys = flattenObject(messagesParsed);
|
||||
|
||||
const localeFiles = fs.readdirSync(localesPath).filter((f) => f.endsWith('.yaml') && f !== messagesFile);
|
||||
const results: Array<LocaleValidationResult> = [];
|
||||
|
||||
for (const localeFile of localeFiles) {
|
||||
const content = fs.readFileSync(path.join(localesPath, localeFile), 'utf8');
|
||||
const parsed = parseYaml(content) as Record<string, unknown>;
|
||||
const localeKeys = flattenObject(parsed);
|
||||
|
||||
const missingKeys: Array<string> = [];
|
||||
const extraKeys: Array<string> = [];
|
||||
|
||||
for (const key of messagesKeys.keys()) {
|
||||
if (!localeKeys.has(key)) {
|
||||
missingKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of localeKeys.keys()) {
|
||||
if (!messagesKeys.has(key)) {
|
||||
extraKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
file: localeFile,
|
||||
missingKeys,
|
||||
extraKeys,
|
||||
isValid: missingKeys.length === 0 && extraKeys.length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = !args.includes('--apply');
|
||||
const force = args.includes('--force');
|
||||
|
||||
if (dryRun) {
|
||||
console.log('dry run mode - no files will be modified\n');
|
||||
console.log('run with --apply to actually prune unused keys');
|
||||
console.log('run with --force to skip confirmation prompts\n');
|
||||
} else {
|
||||
console.log('prune mode - this will delete unused keys from all locale files\n');
|
||||
|
||||
if (!force) {
|
||||
console.log('warning: this action cannot be easily undone!');
|
||||
console.log(' consider committing your changes first.\n');
|
||||
console.log('to proceed without confirmation, run with --force\n');
|
||||
console.log('press ctrl+c to cancel, or wait 5 seconds to continue...');
|
||||
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 5000) {}
|
||||
}
|
||||
}
|
||||
|
||||
let totalUnusedKeys = 0;
|
||||
const results: Array<{name: string; count: number}> = [];
|
||||
const allModifiedFiles: Array<string> = [];
|
||||
|
||||
for (const config of PACKAGES) {
|
||||
if (config.skip) {
|
||||
console.log(`\nskipping ${config.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`\nscanning ${config.name} for i18n usage...`);
|
||||
const usedKeys = extractUsedKeys(config.packagePath, config.i18nMethod);
|
||||
console.log(` found ${usedKeys.size} unique keys used in code`);
|
||||
|
||||
const result = prunePackage(config, usedKeys, dryRun);
|
||||
results.push({name: config.name, count: result.unusedKeys.length});
|
||||
totalUnusedKeys += result.unusedKeys.length;
|
||||
|
||||
if (!dryRun) {
|
||||
for (const localeFile of result.localeFiles) {
|
||||
const filePath = path.join(config.localesPath, localeFile);
|
||||
if (fs.existsSync(filePath)) {
|
||||
allModifiedFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun && allModifiedFiles.length > 0) {
|
||||
await formatYamlFiles(allModifiedFiles);
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
console.log(`\n${'='.repeat(80)}`);
|
||||
console.log('validating locale consistency');
|
||||
console.log(`${'='.repeat(80)}`);
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
for (const config of PACKAGES) {
|
||||
if (config.skip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const messagesFile = 'messages.yaml';
|
||||
const validationResults = validateLocaleConsistency(config.localesPath, messagesFile);
|
||||
|
||||
for (const result of validationResults) {
|
||||
if (!result.isValid) {
|
||||
hasErrors = true;
|
||||
console.error(`\nerror ${config.name}/${result.file}:`);
|
||||
if (result.missingKeys.length > 0) {
|
||||
console.error(` missing ${result.missingKeys.length} keys:`);
|
||||
for (const key of result.missingKeys.slice(0, 10)) {
|
||||
console.error(` - ${key}`);
|
||||
}
|
||||
if (result.missingKeys.length > 10) {
|
||||
console.error(` ... and ${result.missingKeys.length - 10} more`);
|
||||
}
|
||||
}
|
||||
if (result.extraKeys.length > 0) {
|
||||
console.error(` extra ${result.extraKeys.length} keys:`);
|
||||
for (const key of result.extraKeys.slice(0, 10)) {
|
||||
console.error(` - ${key}`);
|
||||
}
|
||||
if (result.extraKeys.length > 10) {
|
||||
console.error(` ... and ${result.extraKeys.length - 10} more`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
console.error(`\n${'='.repeat(80)}`);
|
||||
console.error('locale consistency validation failed');
|
||||
console.error(`${'='.repeat(80)}`);
|
||||
console.error('\nall locale files must have the exact same keys as messages.yaml.');
|
||||
console.error('please add the missing keys or remove the extra keys.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('all locale files are consistent with messages.yaml');
|
||||
}
|
||||
|
||||
console.log(`\n${'='.repeat(80)}`);
|
||||
console.log('summary');
|
||||
console.log(`${'='.repeat(80)}`);
|
||||
|
||||
for (const result of results) {
|
||||
console.log(` ${result.name}: ${result.count} unused keys`);
|
||||
}
|
||||
|
||||
console.log(`\ntotal unused keys across all packages: ${totalUnusedKeys}`);
|
||||
|
||||
if (dryRun) {
|
||||
console.log('\ndry run complete. run with --apply to prune these keys.');
|
||||
} else {
|
||||
console.log("\npruning complete. don't forget to regenerate i18n types:");
|
||||
console.log(' pnpm i18n:generate');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
41
packages/i18n/src/interpolation/SimpleInterpolator.tsx
Normal file
41
packages/i18n/src/interpolation/SimpleInterpolator.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export class SimpleInterpolator {
|
||||
interpolate(template: string, variables: Record<string, unknown> | ReadonlyArray<unknown>): string {
|
||||
return template.replace(/\{([^}]+)\}/g, (match, key: string) => {
|
||||
if (/^\d+$/.test(key)) {
|
||||
const index = Number.parseInt(key, 10);
|
||||
if (Array.isArray(variables) && index < variables.length) {
|
||||
return String(variables[index]);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
if (!Array.isArray(variables)) {
|
||||
const value = (variables as Record<string, unknown>)[key];
|
||||
if (value !== undefined) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
}
|
||||
}
|
||||
32
packages/i18n/src/io/LocaleFilePath.tsx
Normal file
32
packages/i18n/src/io/LocaleFilePath.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export function localeFilePath(locale: string, localesPath: string): string {
|
||||
return path.join(localesPath, `${locale}.yaml`);
|
||||
}
|
||||
|
||||
export function hasLocaleFile(locale: string, localesPath: string, defaultLocale: string): boolean {
|
||||
if (locale === defaultLocale) {
|
||||
return true;
|
||||
}
|
||||
return fs.existsSync(localeFilePath(locale, localesPath));
|
||||
}
|
||||
28
packages/i18n/src/io/ParseYamlRecord.tsx
Normal file
28
packages/i18n/src/io/ParseYamlRecord.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {parse as parseYaml} from 'yaml';
|
||||
|
||||
export function parseYamlRecord(raw: string): Record<string, unknown> {
|
||||
const parsed = parseYaml(raw);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
22
packages/i18n/src/normalization/IdentityLocale.tsx
Normal file
22
packages/i18n/src/normalization/IdentityLocale.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function identityLocale(locale: string): string {
|
||||
return locale;
|
||||
}
|
||||
39
packages/i18n/src/runtime/BuildTemplates.tsx
Normal file
39
packages/i18n/src/runtime/BuildTemplates.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {I18nConfig} from '@fluxer/i18n/src/runtime/I18nTypes';
|
||||
|
||||
export function buildTemplates<TKey extends string, TValue, TVariables>(
|
||||
record: Record<string, unknown>,
|
||||
config: I18nConfig<TKey, TValue, TVariables>,
|
||||
filePath: string,
|
||||
): Map<TKey, TValue> {
|
||||
const templates = new Map<TKey, TValue>();
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const template = config.parseTemplate(value, key);
|
||||
if (template !== null) {
|
||||
templates.set(key as TKey, template);
|
||||
} else {
|
||||
config.onWarning?.(`Skipping invalid template in ${filePath}: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
return templates;
|
||||
}
|
||||
30
packages/i18n/src/runtime/CompileTemplate.tsx
Normal file
30
packages/i18n/src/runtime/CompileTemplate.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {TemplateCompiler} from '@fluxer/i18n/src/runtime/I18nTypes';
|
||||
import type MessageFormat from '@messageformat/core';
|
||||
|
||||
export function compileTemplate<TValue, TVariables>(
|
||||
compiler: TemplateCompiler<TValue, TVariables>,
|
||||
template: TValue,
|
||||
variables: TVariables,
|
||||
mf: MessageFormat,
|
||||
): TValue {
|
||||
return compiler(template, variables, mf);
|
||||
}
|
||||
88
packages/i18n/src/runtime/CreateI18n.tsx
Normal file
88
packages/i18n/src/runtime/CreateI18n.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import {hasLocaleFile} from '@fluxer/i18n/src/io/LocaleFilePath';
|
||||
import {parseYamlRecord} from '@fluxer/i18n/src/io/ParseYamlRecord';
|
||||
import {buildTemplates} from '@fluxer/i18n/src/runtime/BuildTemplates';
|
||||
import {getTemplate} from '@fluxer/i18n/src/runtime/GetTemplate';
|
||||
import type {I18nConfig, I18nResult, I18nState, TemplateCompiler} from '@fluxer/i18n/src/runtime/I18nTypes';
|
||||
|
||||
interface I18nModule<TKey extends string, TValue, TVariables> {
|
||||
getTemplate(key: TKey, locale: string | null, variables: TVariables): I18nResult<TKey, TValue>;
|
||||
hasLocale(locale: string): boolean;
|
||||
getLoadedLocales(): Set<string>;
|
||||
reset(): void;
|
||||
getState(): I18nState<TKey, TValue, TVariables>;
|
||||
}
|
||||
|
||||
export function createI18n<TKey extends string, TValue, TVariables>(
|
||||
config: I18nConfig<TKey, TValue, TVariables>,
|
||||
compile: TemplateCompiler<TValue, TVariables>,
|
||||
): I18nModule<TKey, TValue, TVariables> {
|
||||
const onWarning = config.onWarning ?? console.warn;
|
||||
|
||||
const state: I18nState<TKey, TValue, TVariables> = {
|
||||
loadedLocales: new Set(),
|
||||
templatesByLocale: new Map(),
|
||||
messageFormatCache: new Map(),
|
||||
config: {...config, onWarning},
|
||||
};
|
||||
|
||||
loadDefaultLocale();
|
||||
|
||||
function loadDefaultLocale(): void {
|
||||
if (!fs.existsSync(state.config.defaultMessagesFile)) {
|
||||
onWarning(`Default messages bundle not found: ${state.config.defaultMessagesFile}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(state.config.defaultMessagesFile, 'utf8');
|
||||
const parsed = parseYamlRecord(raw);
|
||||
const templates = buildTemplates(parsed, state.config, state.config.defaultMessagesFile);
|
||||
|
||||
state.templatesByLocale.set(state.config.defaultLocale, templates);
|
||||
state.loadedLocales.add(state.config.defaultLocale);
|
||||
}
|
||||
|
||||
return {
|
||||
getTemplate(key: TKey, locale: string | null, variables: TVariables): I18nResult<TKey, TValue> {
|
||||
return getTemplate(state, key, locale, variables, compile);
|
||||
},
|
||||
|
||||
hasLocale(locale: string): boolean {
|
||||
return hasLocaleFile(locale, state.config.localesPath, state.config.defaultLocale);
|
||||
},
|
||||
|
||||
getLoadedLocales(): Set<string> {
|
||||
return new Set(state.loadedLocales);
|
||||
},
|
||||
|
||||
reset(): void {
|
||||
state.loadedLocales.clear();
|
||||
state.templatesByLocale.clear();
|
||||
state.messageFormatCache.clear();
|
||||
loadDefaultLocale();
|
||||
},
|
||||
|
||||
getState(): I18nState<TKey, TValue, TVariables> {
|
||||
return state;
|
||||
},
|
||||
};
|
||||
}
|
||||
44
packages/i18n/src/runtime/GetEffectiveLocale.tsx
Normal file
44
packages/i18n/src/runtime/GetEffectiveLocale.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {hasLocaleFile} from '@fluxer/i18n/src/io/LocaleFilePath';
|
||||
import type {I18nState} from '@fluxer/i18n/src/runtime/I18nTypes';
|
||||
|
||||
export function getEffectiveLocale<TKey extends string, TValue, TVariables>(
|
||||
state: I18nState<TKey, TValue, TVariables>,
|
||||
locale: string | null | undefined,
|
||||
): string {
|
||||
if (!locale) {
|
||||
return state.config.defaultLocale;
|
||||
}
|
||||
|
||||
const normalizeLocale = state.config.normalizeLocale ?? ((locale: string) => locale);
|
||||
const normalizedLocale = normalizeLocale(locale);
|
||||
|
||||
if (normalizedLocale === state.config.defaultLocale) {
|
||||
return state.config.defaultLocale;
|
||||
}
|
||||
|
||||
if (!hasLocaleFile(normalizedLocale, state.config.localesPath, state.config.defaultLocale)) {
|
||||
state.config.onWarning?.(`Unsupported locale, falling back to ${state.config.defaultLocale}: ${locale}`);
|
||||
return state.config.defaultLocale;
|
||||
}
|
||||
|
||||
return normalizedLocale;
|
||||
}
|
||||
93
packages/i18n/src/runtime/GetTemplate.tsx
Normal file
93
packages/i18n/src/runtime/GetTemplate.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {compileTemplate} from '@fluxer/i18n/src/runtime/CompileTemplate';
|
||||
import {getEffectiveLocale} from '@fluxer/i18n/src/runtime/GetEffectiveLocale';
|
||||
import type {I18nResult, I18nState, TemplateCompiler} from '@fluxer/i18n/src/runtime/I18nTypes';
|
||||
import {loadLocaleIfNotLoaded} from '@fluxer/i18n/src/runtime/LoadLocale';
|
||||
import MessageFormat from '@messageformat/core';
|
||||
|
||||
export function getTemplate<TKey extends string, TValue, TVariables>(
|
||||
state: I18nState<TKey, TValue, TVariables>,
|
||||
key: TKey,
|
||||
locale: string | null,
|
||||
variables: TVariables,
|
||||
compile: TemplateCompiler<TValue, TVariables>,
|
||||
): I18nResult<TKey, TValue> {
|
||||
const effectiveLocale = getEffectiveLocale(state, locale);
|
||||
|
||||
loadLocaleIfNotLoaded(state, effectiveLocale);
|
||||
|
||||
const sourceTemplate = state.templatesByLocale.get(state.config.defaultLocale)?.get(key);
|
||||
if (!sourceTemplate) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'missing-template',
|
||||
key,
|
||||
message: `Missing template ${key}`,
|
||||
},
|
||||
locale: effectiveLocale,
|
||||
};
|
||||
}
|
||||
|
||||
const translatedTemplate = state.templatesByLocale.get(effectiveLocale)?.get(key);
|
||||
const template = translatedTemplate ?? sourceTemplate;
|
||||
const validationError = state.config.validateVariables?.(key, template, variables);
|
||||
|
||||
if (validationError) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'invalid-variables',
|
||||
key,
|
||||
message: validationError,
|
||||
},
|
||||
locale: effectiveLocale,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const compiled = compileTemplate(compile, template, variables, getMessageFormat(state, effectiveLocale));
|
||||
return {ok: true, value: compiled, locale: effectiveLocale};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'compile-failed',
|
||||
key,
|
||||
message: error instanceof Error ? error.message : 'Failed to compile template',
|
||||
},
|
||||
locale: effectiveLocale,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageFormat<TKey extends string, TValue, TVariables>(
|
||||
state: I18nState<TKey, TValue, TVariables>,
|
||||
locale: string,
|
||||
): MessageFormat {
|
||||
const cached = state.messageFormatCache.get(locale);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const mf = new MessageFormat(locale);
|
||||
state.messageFormatCache.set(locale, mf);
|
||||
return mf;
|
||||
}
|
||||
55
packages/i18n/src/runtime/I18nTypes.tsx
Normal file
55
packages/i18n/src/runtime/I18nTypes.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type MessageFormat from '@messageformat/core';
|
||||
|
||||
export type I18nErrorKind = 'missing-template' | 'invalid-variables' | 'compile-failed';
|
||||
|
||||
export interface I18nError<TKey extends string> {
|
||||
kind: I18nErrorKind;
|
||||
key: TKey;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type I18nResult<TKey extends string, TValue> =
|
||||
| {ok: true; value: TValue; locale: string}
|
||||
| {ok: false; error: I18nError<TKey>; locale: string};
|
||||
|
||||
export interface I18nState<TKey extends string, TValue, TVariables> {
|
||||
loadedLocales: Set<string>;
|
||||
templatesByLocale: Map<string, Map<TKey, TValue>>;
|
||||
messageFormatCache: Map<string, MessageFormat>;
|
||||
config: I18nConfig<TKey, TValue, TVariables>;
|
||||
}
|
||||
|
||||
export interface I18nConfig<TKey extends string, TValue, TVariables> {
|
||||
localesPath: string;
|
||||
defaultLocale: string;
|
||||
defaultMessagesFile: string;
|
||||
normalizeLocale?: (locale: string) => string;
|
||||
parseTemplate: (value: unknown, key: string) => TValue | null;
|
||||
onWarning?: (message: string) => void;
|
||||
validateVariables?: (key: TKey, template: TValue, variables: TVariables) => string | null;
|
||||
}
|
||||
|
||||
export type TemplateCompiler<TValue, TVariables> = (
|
||||
template: TValue,
|
||||
variables: TVariables,
|
||||
mf: MessageFormat,
|
||||
) => TValue;
|
||||
62
packages/i18n/src/runtime/LoadLocale.tsx
Normal file
62
packages/i18n/src/runtime/LoadLocale.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import {localeFilePath} from '@fluxer/i18n/src/io/LocaleFilePath';
|
||||
import {parseYamlRecord} from '@fluxer/i18n/src/io/ParseYamlRecord';
|
||||
import {buildTemplates} from '@fluxer/i18n/src/runtime/BuildTemplates';
|
||||
import type {I18nState} from '@fluxer/i18n/src/runtime/I18nTypes';
|
||||
|
||||
export function loadLocaleIfNotLoaded<TKey extends string, TValue, TVariables>(
|
||||
state: I18nState<TKey, TValue, TVariables>,
|
||||
locale: string,
|
||||
): void {
|
||||
if (locale === state.config.defaultLocale) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.loadedLocales.has(locale)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = localeFilePath(locale, state.config.localesPath);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
state.config.onWarning?.(
|
||||
`Locale file not found for ${locale}: ${filePath}. Falling back to ${state.config.defaultLocale}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = parseYamlRecord(raw);
|
||||
const templates = buildTemplates(parsed, state.config, filePath);
|
||||
|
||||
let localeMap = state.templatesByLocale.get(locale);
|
||||
if (!localeMap) {
|
||||
localeMap = new Map();
|
||||
state.templatesByLocale.set(locale, localeMap);
|
||||
}
|
||||
|
||||
for (const [key, template] of templates) {
|
||||
localeMap.set(key, template);
|
||||
}
|
||||
|
||||
state.loadedLocales.add(locale);
|
||||
}
|
||||
180
packages/i18n/src/runtime/tests/CreateI18n.test.tsx
Normal file
180
packages/i18n/src/runtime/tests/CreateI18n.test.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {createI18n} from '@fluxer/i18n/src/runtime/CreateI18n';
|
||||
import type {I18nResult} from '@fluxer/i18n/src/runtime/I18nTypes';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
type TestKey = 'greeting' | 'farewell' | 'with_vars';
|
||||
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), {recursive: true});
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
function unwrapResult(result: I18nResult<TestKey, string>): string {
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
return result.value;
|
||||
}
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
|
||||
describe('createI18n', () => {
|
||||
let tempDir: string;
|
||||
let localesPath: string;
|
||||
let messagesFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fluxer-i18n-'));
|
||||
localesPath = path.join(tempDir, 'locales');
|
||||
messagesFile = path.join(localesPath, 'messages.yaml');
|
||||
writeFile(
|
||||
messagesFile,
|
||||
["'farewell': 'Bye'", "'greeting': 'Hello {name}'", "'with_vars': 'Hello {name}, you have {count}'", ''].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
writeFile(path.join(localesPath, 'fr.yaml'), ["'greeting': 'Salut {name}'", ''].join('\n'));
|
||||
});
|
||||
|
||||
it('returns compiled templates from the default locale', () => {
|
||||
const i18n = createI18n<TestKey, string, Record<string, unknown>>(
|
||||
{
|
||||
localesPath,
|
||||
defaultLocale: 'en-US',
|
||||
defaultMessagesFile: messagesFile,
|
||||
parseTemplate: (value) => (typeof value === 'string' ? value : null),
|
||||
},
|
||||
(template, variables, mf) => String(mf.compile(template)(variables)),
|
||||
);
|
||||
|
||||
const result = i18n.getTemplate('greeting', 'en-US', {name: 'Taylor'});
|
||||
|
||||
expect(unwrapResult(result)).toBe('Hello Taylor');
|
||||
});
|
||||
|
||||
it('loads locale files on demand', () => {
|
||||
const i18n = createI18n<TestKey, string, Record<string, unknown>>(
|
||||
{
|
||||
localesPath,
|
||||
defaultLocale: 'en-US',
|
||||
defaultMessagesFile: messagesFile,
|
||||
parseTemplate: (value) => (typeof value === 'string' ? value : null),
|
||||
},
|
||||
(template, variables, mf) => String(mf.compile(template)(variables)),
|
||||
);
|
||||
|
||||
const result = i18n.getTemplate('greeting', 'fr', {name: 'Taylor'});
|
||||
|
||||
expect(unwrapResult(result)).toBe('Salut Taylor');
|
||||
});
|
||||
|
||||
it('returns a missing-template error result', () => {
|
||||
const i18n = createI18n<TestKey, string, Record<string, unknown>>(
|
||||
{
|
||||
localesPath,
|
||||
defaultLocale: 'en-US',
|
||||
defaultMessagesFile: messagesFile,
|
||||
parseTemplate: (value) => (typeof value === 'string' ? value : null),
|
||||
},
|
||||
(template, variables, mf) => String(mf.compile(template)(variables)),
|
||||
);
|
||||
|
||||
const result = i18n.getTemplate('missing' as TestKey, 'en-US', {name: 'Taylor'});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.kind).toBe('missing-template');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns invalid-variables error when validation fails', () => {
|
||||
const i18n = createI18n<TestKey, string, Record<string, unknown> | undefined>(
|
||||
{
|
||||
localesPath,
|
||||
defaultLocale: 'en-US',
|
||||
defaultMessagesFile: messagesFile,
|
||||
parseTemplate: (value) => (typeof value === 'string' ? value : null),
|
||||
validateVariables: (key, template, variables) => {
|
||||
if (variables) {
|
||||
return null;
|
||||
}
|
||||
if (template.includes('{')) {
|
||||
return `Missing variables for ${key}`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
(template, variables, mf) => String(mf.compile(template)(variables ?? {})),
|
||||
);
|
||||
|
||||
const result = i18n.getTemplate('with_vars', 'en-US', undefined);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.kind).toBe('invalid-variables');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns compile-failed when the compiler throws', () => {
|
||||
const i18n = createI18n<TestKey, string, Record<string, unknown>>(
|
||||
{
|
||||
localesPath,
|
||||
defaultLocale: 'en-US',
|
||||
defaultMessagesFile: messagesFile,
|
||||
parseTemplate: (value) => (typeof value === 'string' ? value : null),
|
||||
},
|
||||
() => {
|
||||
throw new Error('compile failed');
|
||||
},
|
||||
);
|
||||
|
||||
const result = i18n.getTemplate('greeting', 'en-US', {name: 'Taylor'});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.kind).toBe('compile-failed');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns default locale for unsupported locales', () => {
|
||||
const warnings: Array<string> = [];
|
||||
const i18n = createI18n<TestKey, string, Record<string, unknown>>(
|
||||
{
|
||||
localesPath,
|
||||
defaultLocale: 'en-US',
|
||||
defaultMessagesFile: messagesFile,
|
||||
parseTemplate: (value) => (typeof value === 'string' ? value : null),
|
||||
onWarning: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
},
|
||||
(template, variables, mf) => String(mf.compile(template)(variables)),
|
||||
);
|
||||
|
||||
const result = i18n.getTemplate('greeting', 'pt-BR', {name: 'Taylor'});
|
||||
|
||||
expect(unwrapResult(result)).toBe('Hello Taylor');
|
||||
expect(warnings).toContain('Unsupported locale, falling back to en-US: pt-BR');
|
||||
});
|
||||
});
|
||||
9
packages/i18n/tsconfig.json
Normal file
9
packages/i18n/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfigs/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
44
packages/i18n/vitest.config.ts
Normal file
44
packages/i18n/vitest.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user