Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6f2a3b23 | ||
|
|
73927287a3 | ||
|
|
ae5c7fd813 | ||
|
|
4f201e088f | ||
|
|
9f41d7c4bd | ||
|
|
954270993b | ||
|
|
b13cc985ca | ||
|
|
4579e0a875 | ||
|
|
b3c36ca8e0 | ||
|
|
39ad110ca0 | ||
|
|
447bafecdc | ||
|
|
36fbdd836c | ||
|
|
a87bc7d6e4 | ||
|
|
d2fda49914 | ||
|
|
3322ee2e6a | ||
|
|
3d1288579a | ||
|
|
3a91dcc3cd |
@@ -1,4 +1,12 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/library.js"],
|
||||
overrides: [
|
||||
{
|
||||
env: {
|
||||
jest: true,
|
||||
node: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/scripts/locale-manager/.temp/
|
||||
/coverage/
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
roots: ["<rootDir>/src", "<rootDir>/scripts"],
|
||||
testMatch: ["**/__tests__/**/*.test.ts"],
|
||||
collectCoverageFrom: ["src/**/*.ts", "scripts/**/*.ts", "!**/*.d.ts", "!**/*.test.ts"],
|
||||
};
|
||||
@@ -13,6 +13,10 @@
|
||||
"check:lint": "eslint . --max-warnings 2",
|
||||
"check:types": "tsc --noEmit",
|
||||
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"check:translations": "tsx scripts/check-missing-translations.ts",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"fix:lint": "eslint . --fix",
|
||||
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
|
||||
@@ -28,10 +32,15 @@
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/node": "^22.17.2",
|
||||
"@types/react": "catalog:",
|
||||
"ansis": "^4.1.0",
|
||||
"jest": "^30.0.5",
|
||||
"ts-jest": "^29.4.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"tsdown": "catalog:",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { blue, yellow, white, red, green } from "ansis";
|
||||
import { checkMissingTranslations } from "./locale/check-translations";
|
||||
|
||||
async function runTranslationCheck() {
|
||||
try {
|
||||
console.log(blue`\nChecking for missing translations...\n`);
|
||||
|
||||
const result = await checkMissingTranslations();
|
||||
const { hasMissingTranslations, missingTranslations } = result;
|
||||
|
||||
if (missingTranslations.length > 0) {
|
||||
missingTranslations.forEach(({ key, file, missingLocales }) => {
|
||||
console.log(white`\n🔑 Key: ${key}`);
|
||||
missingLocales.forEach((locale) => {
|
||||
console.log(red` - Missing in 📂 locales/${locale}/${file}.json`);
|
||||
});
|
||||
console.log();
|
||||
});
|
||||
} else {
|
||||
console.log(green`✓ All translations present`);
|
||||
}
|
||||
|
||||
if (hasMissingTranslations) {
|
||||
console.error(red`\n⚠️ Some translations are missing. Please add them to maintain full language support.\n`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(green`\n✓ All translations are complete!\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(red`\nError checking translations: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTranslationCheck();
|
||||
@@ -0,0 +1,11 @@
|
||||
import { checkMissingTranslations } from "../check-translations";
|
||||
|
||||
describe("i18n Translation Check", () => {
|
||||
it("should detect when translations are missing", async () => {
|
||||
const result = await checkMissingTranslations();
|
||||
|
||||
expect(result).toHaveProperty("hasMissingTranslations");
|
||||
|
||||
expect(result.hasMissingTranslations).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { BASE_LOCALE } from "./constants";
|
||||
import { LocaleManager } from "./manager";
|
||||
import { TranslationRow, TranslationStatus } from "./types";
|
||||
|
||||
/**
|
||||
* Represents a missing translation entry.
|
||||
* Contains information about which key is missing in which locales(eg- [id, cs, de]).
|
||||
*/
|
||||
export interface MissingTranslation {
|
||||
key: string;
|
||||
file: string;
|
||||
missingLocales: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result object returned by the checkMissingTranslations function.
|
||||
* Contains information about whether there are any missing translations
|
||||
* and details about each missing translation.
|
||||
*/
|
||||
export interface CheckTranslationsResult {
|
||||
/** Flag indicating if any translations are missing */
|
||||
hasMissingTranslations: boolean;
|
||||
/** Array of missing translation details */
|
||||
missingTranslations: MissingTranslation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for missing translations across all locale files.
|
||||
*
|
||||
* This function:
|
||||
* 1. Updates all generated translations
|
||||
* 2. Iterates through each translation file
|
||||
* 3. Identifies missing translations by comparing against the base locale
|
||||
* 4. Collects details about missing translations including which keys are missing in which locales
|
||||
*
|
||||
* @returns {Promise<CheckTranslationsResult>} Object containing missing translation information
|
||||
*/
|
||||
async function checkMissingTranslations(): Promise<CheckTranslationsResult> {
|
||||
const manager = new LocaleManager();
|
||||
await manager.updateAllGeneratedTranslations();
|
||||
|
||||
const files = manager.translationFiles;
|
||||
let hasMissingTranslations = false;
|
||||
const missingTranslations: MissingTranslation[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const rows = await manager.generateTranslationRows(file);
|
||||
|
||||
const missingRows = rows.filter((row: TranslationRow) =>
|
||||
Object.entries(row.translations).some(
|
||||
([locale, translation]: [string, TranslationStatus]) =>
|
||||
locale !== BASE_LOCALE && translation.status === "missing"
|
||||
)
|
||||
);
|
||||
|
||||
if (missingRows.length > 0) {
|
||||
hasMissingTranslations = true;
|
||||
|
||||
missingRows.forEach((row: TranslationRow) => {
|
||||
const missingLocales = Object.entries(row.translations)
|
||||
.filter(
|
||||
([locale, translation]: [string, TranslationStatus]) =>
|
||||
locale !== BASE_LOCALE && translation.status === "missing"
|
||||
)
|
||||
.map(([locale]) => locale);
|
||||
|
||||
missingTranslations.push({
|
||||
key: row.key,
|
||||
file,
|
||||
missingLocales,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasMissingTranslations,
|
||||
missingTranslations,
|
||||
};
|
||||
}
|
||||
|
||||
export { checkMissingTranslations };
|
||||
@@ -0,0 +1,11 @@
|
||||
import path from "path";
|
||||
import { TranslationFile, TranslationLocale } from "./types";
|
||||
|
||||
/** Root path for all translation files */
|
||||
export const TRANSLATION_ROOT_PATH = path.join(__dirname, "../../src/locales");
|
||||
|
||||
/** list of translation file categories */
|
||||
export const TRANSLATION_FILES: TranslationFile[] = ["translations", "accessibility", "editor", "core"];
|
||||
|
||||
/** base locale */
|
||||
export const BASE_LOCALE: TranslationLocale = "en";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { promises as fs } from "fs";
|
||||
|
||||
export class FileService {
|
||||
/**
|
||||
* Reads the content of a file at the specified path
|
||||
* @param filePath Path to the file to read
|
||||
* @returns The file content as a string, or null if the file doesn't exist or can't be read
|
||||
* @throws Never - returns null instead of throwing
|
||||
*/
|
||||
async read(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(filePath, "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes content to a file at the specified path
|
||||
* @param filePath Path to the file to write
|
||||
* @param content Content to write to the file
|
||||
* @throws If the file can't be written to (e.g., permissions, disk space)
|
||||
*/
|
||||
async write(filePath: string, content: string): Promise<void> {
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file exists at the specified path
|
||||
* @param filePath Path to check for file existence
|
||||
* @returns True if the file exists and is accessible, false otherwise
|
||||
* @throws Never - returns false instead of throwing
|
||||
*/
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import _ from "lodash";
|
||||
|
||||
export interface NestedTranslations {
|
||||
[key: string]: string | NestedTranslations;
|
||||
}
|
||||
|
||||
export class JsonService {
|
||||
/**
|
||||
* Flattens a nested translations object into a flat key-value structure
|
||||
* @param obj The nested translations object to flatten
|
||||
* @param prefix Optional prefix for nested keys
|
||||
* @returns A flattened object with dot-notation keys
|
||||
*/
|
||||
flatten(obj: NestedTranslations, prefix = ""): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
Object.assign(result, this.flatten(value as NestedTranslations, newKey));
|
||||
} else {
|
||||
result[newKey] = value === "" ? "" : (value as string);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value at a specific path in the translations object
|
||||
* @param obj The translations object to modify
|
||||
* @param key The dot-notation path where to set the value
|
||||
* @param value The translation string to set
|
||||
* @returns The modified translations object
|
||||
*/
|
||||
set(obj: NestedTranslations, key: string, value: string): NestedTranslations {
|
||||
_.set(obj, key, value);
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a value at a specific path in the translations object
|
||||
* @param obj The translations object to modify
|
||||
* @param key The dot-notation path to remove
|
||||
* @returns The modified translations object
|
||||
*/
|
||||
unset(obj: NestedTranslations, key: string): NestedTranslations {
|
||||
_.unset(obj, key);
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { TRANSLATION_ROOT_PATH, TRANSLATION_FILES, BASE_LOCALE } from "./constants";
|
||||
import { FileService } from "./file-service";
|
||||
import { JsonService, NestedTranslations } from "./json-service";
|
||||
import { TranslationFile, TranslationLocale, TranslationRow, TranslationStatus } from "./types";
|
||||
|
||||
/**
|
||||
* Validates if a directory is a valid locale directory (e.g., 'en', 'fr', 'de')
|
||||
* @param dir Directory name to validate
|
||||
* @returns True if the directory is a valid locale directory
|
||||
*/
|
||||
const isValidLocaleDirectory = (dir: string): boolean => /^[a-z]{2}(-[A-Z]{2})?$/.test(dir);
|
||||
|
||||
/**
|
||||
* Manages translation files across multiple locales.
|
||||
* Handles reading, writing, and generating translation files in a structured format.
|
||||
*/
|
||||
export class LocaleManager {
|
||||
private fileService = new FileService();
|
||||
private jsonService = new JsonService();
|
||||
public readonly rootPath: string;
|
||||
public readonly translationFiles: TranslationFile[];
|
||||
|
||||
/**
|
||||
* Initializes the TranslationManager with default paths and files.
|
||||
* Automatically updates generated translations for each file.
|
||||
*/
|
||||
constructor() {
|
||||
this.rootPath = TRANSLATION_ROOT_PATH;
|
||||
this.translationFiles = TRANSLATION_FILES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all available locale directories (e.g., 'en', 'fr', 'de')
|
||||
* @returns Array of valid locale identifiers
|
||||
* @throws If the root directory cannot be read
|
||||
*/
|
||||
private async getLocales(): Promise<TranslationLocale[]> {
|
||||
const files = await fs.readdir(this.rootPath);
|
||||
return files.filter((f) => isValidLocaleDirectory(f)) as TranslationLocale[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all the generated translation files in the memory
|
||||
*/
|
||||
async updateAllGeneratedTranslations(): Promise<void> {
|
||||
for (const file of this.translationFiles) {
|
||||
await this.updateGeneratedTranslations(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the full file path for a translation file
|
||||
* @param locale The locale identifier (e.g., 'en', 'fr')
|
||||
* @param file The translation file category (e.g., "translations", "accessibility", "editor", "core")
|
||||
* @returns Absolute path to the translation file
|
||||
*/
|
||||
private getFilePath(locale: TranslationLocale, file: TranslationFile): string {
|
||||
return path.join(this.rootPath, locale, `${file}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and flattens translations for a specific locale and file
|
||||
* @param locale The locale to get translations for
|
||||
* @param file The translation file category (e.g., "translations", "accessibility", "editor", "core")
|
||||
* @returns Flattened key-value pairs of translations
|
||||
*/
|
||||
async getTranslations(locale: TranslationLocale, file: TranslationFile): Promise<Record<string, string>> {
|
||||
const filePath = this.getFilePath(locale, file);
|
||||
const raw = await this.fileService.read(filePath);
|
||||
if (!raw) return {};
|
||||
return this.jsonService.flatten(JSON.parse(raw) as NestedTranslations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single translation key for a specific locale
|
||||
* @param locale The locale to update
|
||||
* @param key The translation key (dot-notation path)
|
||||
* @param value The new translation value
|
||||
* @param file The translation file category (e.g., "translations", "accessibility", "editor", "core")
|
||||
* @throws If the file cannot be written
|
||||
*/
|
||||
async updateTranslation(locale: TranslationLocale, key: string, value: string, file: TranslationFile): Promise<void> {
|
||||
const filePath = this.getFilePath(locale, file);
|
||||
const raw = (await this.fileService.read(filePath)) || "{}";
|
||||
const json = JSON.parse(raw) as NestedTranslations;
|
||||
this.jsonService.set(json, key, value);
|
||||
await this.fileService.write(filePath, JSON.stringify(json, null, 2));
|
||||
await this.updateGeneratedTranslations(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a translation key from all locales
|
||||
* @param key The translation key to delete
|
||||
* @param file The translation file category (e.g., "translations", "accessibility", "editor", "core")
|
||||
* @throws If any file cannot be written
|
||||
*/
|
||||
async deleteTranslation(key: string, file: TranslationFile): Promise<void> {
|
||||
const locales = await this.getLocales();
|
||||
for (const locale of locales) {
|
||||
const filePath = this.getFilePath(locale, file);
|
||||
const raw = (await this.fileService.read(filePath)) || "{}";
|
||||
const json = JSON.parse(raw) as NestedTranslations;
|
||||
this.jsonService.unset(json, key);
|
||||
await this.fileService.write(filePath, JSON.stringify(json, null, 2));
|
||||
}
|
||||
await this.updateGeneratedTranslations(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates translation rows for all locales in a specific file
|
||||
* @param file The translation file category (e.g., "translations", "accessibility", "editor", "core")
|
||||
* @returns Array of translation rows with status for each locale
|
||||
* @throws If English translations are not found
|
||||
*/
|
||||
async generateTranslationRows(file: TranslationFile): Promise<TranslationRow[]> {
|
||||
const locales = await this.getLocales();
|
||||
const translations: Record<TranslationLocale, Record<string, string>> = {
|
||||
en: {},
|
||||
cs: {},
|
||||
de: {},
|
||||
es: {},
|
||||
fr: {},
|
||||
id: {},
|
||||
it: {},
|
||||
ja: {},
|
||||
ko: {},
|
||||
pl: {},
|
||||
"pt-BR": {},
|
||||
ro: {},
|
||||
ru: {},
|
||||
sk: {},
|
||||
ua: {},
|
||||
"vi-VN": {},
|
||||
"zh-CN": {},
|
||||
"zh-TW": {},
|
||||
"tr-TR": {},
|
||||
};
|
||||
|
||||
for (const locale of locales) {
|
||||
translations[locale] = await this.getTranslations(locale, file);
|
||||
}
|
||||
|
||||
const en = translations[BASE_LOCALE];
|
||||
if (!en) throw new Error("English translations not found");
|
||||
|
||||
return Object.keys(en).map((key) => ({
|
||||
id: key,
|
||||
key,
|
||||
fullPath: path.join(this.rootPath, key),
|
||||
translations: Object.fromEntries(
|
||||
locales.map((locale) => [
|
||||
locale,
|
||||
{
|
||||
status: translations[locale]?.[key] ? "added" : "missing",
|
||||
value: translations[locale]?.[key] || "",
|
||||
} as TranslationStatus,
|
||||
])
|
||||
) as Record<TranslationLocale, TranslationStatus>,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates multiple translations for a single key across multiple locales
|
||||
* @param key The translation key to update
|
||||
* @param translations Map of locale to translation value
|
||||
* @param file The translation file category
|
||||
* @throws If any translation update fails
|
||||
*/
|
||||
async bulkUpdateTranslations(
|
||||
key: string,
|
||||
translations: Record<TranslationLocale, string>,
|
||||
file: TranslationFile
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
Object.entries(translations).map(([locale, value]) =>
|
||||
this.updateTranslation(locale as TranslationLocale, key, value, file)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the generated translation file for a specific category
|
||||
* @param file The translation file category
|
||||
* @throws If the temporary directory cannot be created or the file cannot be written
|
||||
*/
|
||||
async updateGeneratedTranslations(file: TranslationFile): Promise<void> {
|
||||
const data = await this.generateTranslationRows(file);
|
||||
const tempDir = path.join(__dirname, ".temp");
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await this.fileService.write(path.join(tempDir, `generated-${file}.json`), JSON.stringify(data, null, 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type TranslationLocale =
|
||||
| "en"
|
||||
| "cs"
|
||||
| "de"
|
||||
| "es"
|
||||
| "fr"
|
||||
| "id"
|
||||
| "it"
|
||||
| "ja"
|
||||
| "ko"
|
||||
| "pl"
|
||||
| "pt-BR"
|
||||
| "ro"
|
||||
| "ru"
|
||||
| "sk"
|
||||
| "ua"
|
||||
| "vi-VN"
|
||||
| "zh-CN"
|
||||
| "zh-TW"
|
||||
| "tr-TR";
|
||||
|
||||
export type TranslationFile = "core" | "translations" | "accessibility" | "editor";
|
||||
|
||||
export interface TranslationStatus {
|
||||
status: "added" | "missing";
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface TranslationRow {
|
||||
id: string;
|
||||
key: string;
|
||||
fullPath: string;
|
||||
translations: Record<TranslationLocale, TranslationStatus>;
|
||||
}
|
||||
@@ -5,6 +5,6 @@
|
||||
"lib": ["esnext", "dom"],
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["./src"],
|
||||
"include": ["./src", "./scripts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
|
||||
Generated
+1717
-118
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user