Compare commits

...
Author SHA1 Message Date
Jayash Tripathy 8a6f2a3b23 fix: lint 2025-09-15 13:58:34 +05:30
Jayash Tripathy 73927287a3 fix: lint 2025-09-15 13:57:12 +05:30
Jayash Tripathy ae5c7fd813 chore(i18n): add lodash as a dependency in package.json 2025-09-15 13:56:10 +05:30
Jayash Tripathy 4f201e088f chore(i18n): add coverage directory to .gitignore 2025-09-15 13:53:25 +05:30
Jayash Tripathy 9f41d7c4bd refactor(i18n): remove redundant console log in translation check script 2025-09-15 13:50:33 +05:30
Jayash Tripathy 954270993b refactor(i18n): simplify translation test structure by removing nested describe 2025-09-15 13:43:27 +05:30
Jayash Tripathy b13cc985ca feat(i18n): enhance translation check script with detailed documentation 2025-09-15 13:41:23 +05:30
Jayash Tripathy 4579e0a875 feat(i18n): add Jest configuration and translation check script with tests
- Introduced Jest configuration for testing in the i18n package.
- translations, improving translation management.
- Updated ESLint configuration to support Jest environment.
- Created tests for the translation check functionality.
2025-09-15 13:34:14 +05:30
Jayash Tripathy b3c36ca8e0 refactor(i18n): add method to update all generated translations and refactor flattening logic in JsonService 2025-09-15 00:37:27 +05:30
Jayash Tripathy 39ad110ca0 refactor(i18n): lint 2025-09-14 12:08:38 +05:30
Jayash Tripathy 447bafecdc fix(i18n): update comments for clarity in constants and manager files 2025-09-14 11:28:35 +05:30
Jayash Tripathy 36fbdd836c fix(i18n): lint 2025-09-14 02:06:12 +05:30
Jayash Tripathy a87bc7d6e4 fix(i18n): lint 2025-09-14 01:58:39 +05:30
Jayash Tripathy d2fda49914 feat(i18n): Introduced scripts for managing translations, including checking for missing translations. 2025-09-14 01:56:21 +05:30
Jayash Tripathy 3322ee2e6a Merge branch 'preview' of https://github.com/makeplane/plane into preview 2025-09-12 23:22:33 +05:30
Jayash Tripathy 3d1288579a Merge branch 'preview' of https://github.com/makeplane/plane into preview 2025-09-11 17:37:18 +05:30
Jayash Tripathy 3a91dcc3cd ♻️ refactor: add fill in barchart bar stroke 2025-09-11 17:12:55 +05:30
14 changed files with 2209 additions and 120 deletions
+8
View File
@@ -1,4 +1,12 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
overrides: [
{
env: {
jest: true,
node: true,
},
},
],
};
+2
View File
@@ -0,0 +1,2 @@
/scripts/locale-manager/.temp/
/coverage/
+7
View File
@@ -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"],
};
+10 -1
View File
@@ -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 };
+11
View File
@@ -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;
}
}
+194
View File
@@ -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));
}
}
+34
View File
@@ -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>;
}
+1 -1
View File
@@ -5,6 +5,6 @@
"lib": ["esnext", "dom"],
"resolveJsonModule": true
},
"include": ["./src"],
"include": ["./src", "./scripts"],
"exclude": ["dist", "build", "node_modules"]
}
+1717 -118
View File
File diff suppressed because it is too large Load Diff