snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
170
packages/i18n/scripts/generate-types.ts
Normal file
170
packages/i18n/scripts/generate-types.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// Usage: npx tsx packages/i18n/scripts/generate-types.ts
|
||||
// Reads: src/locales/en/*.json
|
||||
// Writes: src/types/keys.generated.ts
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const COPYRIGHT_HEADER = `/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/`;
|
||||
|
||||
type NestedObject = { [key: string]: string | NestedObject };
|
||||
|
||||
/**
|
||||
* Recursively flatten a nested object into dot-notated keys.
|
||||
* Returns an array of flattened key strings.
|
||||
*/
|
||||
function flattenKeys(obj: NestedObject, prefix = ""): string[] {
|
||||
const keys: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (typeof value === "string") {
|
||||
keys.push(fullKey);
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
keys.push(...flattenKeys(value as NestedObject, fullKey));
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect path conflicts where a key is both a leaf (string value) and a prefix
|
||||
* of another key. For example, "workspace" as a leaf and "workspace.settings" as
|
||||
* another key would be a conflict.
|
||||
*/
|
||||
function detectPathConflicts(keys: string[]): string[] {
|
||||
// Build a set of all prefixes used across keys
|
||||
const prefixes = new Set<string>();
|
||||
for (const key of keys) {
|
||||
const parts = key.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
prefixes.add(parts.slice(0, i).join("."));
|
||||
}
|
||||
}
|
||||
|
||||
// A conflict exists when a leaf key is also a prefix
|
||||
const conflicts: string[] = [];
|
||||
for (const key of keys) {
|
||||
if (prefixes.has(key)) {
|
||||
const extending = keys.find((k) => k.startsWith(key + "."));
|
||||
if (extending) {
|
||||
conflicts.push(`Path conflict: "${key}" is both a leaf key and a prefix of "${extending}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const rootDir = import.meta.dirname;
|
||||
const localesDir = path.resolve(rootDir, "..", "src", "locales", "en");
|
||||
const outputDir = path.resolve(rootDir, "..", "src", "types");
|
||||
const outputFile = path.join(outputDir, "keys.generated.ts");
|
||||
|
||||
// Read all JSON files from the English locales directory
|
||||
if (!fs.existsSync(localesDir)) {
|
||||
console.error(`Error: Locales directory not found: ${localesDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const jsonFiles = fs
|
||||
.readdirSync(localesDir)
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
if (jsonFiles.length === 0) {
|
||||
console.error(`Error: No JSON files found in ${localesDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Track keys per namespace file for collision detection
|
||||
const keysByFile = new Map<string, string[]>();
|
||||
const allKeys = new Set<string>();
|
||||
const collisions: string[] = [];
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
const filePath = path.join(localesDir, file);
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return JSON.parse(content) as NestedObject;
|
||||
} catch {
|
||||
console.error(`Error: Failed to parse JSON in ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
const fileKeys = flattenKeys(parsed);
|
||||
keysByFile.set(file, fileKeys);
|
||||
|
||||
// Detect cross-namespace collisions
|
||||
for (const key of fileKeys) {
|
||||
if (allKeys.has(key)) {
|
||||
// Find which file already had this key
|
||||
for (const [otherFile, otherKeys] of keysByFile.entries()) {
|
||||
if (otherFile !== file && otherKeys.includes(key)) {
|
||||
collisions.push(`Cross-namespace collision: key "${key}" exists in both "${otherFile}" and "${file}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
allKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (collisions.length > 0) {
|
||||
console.error("Error: Cross-namespace key collisions detected:");
|
||||
for (const collision of collisions) {
|
||||
console.error(` ${collision}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Detect path conflicts
|
||||
const sortedKeys = [...allKeys].sort();
|
||||
const pathConflicts = detectPathConflicts(sortedKeys);
|
||||
|
||||
if (pathConflicts.length > 0) {
|
||||
console.error("Error: Path conflicts detected:");
|
||||
for (const conflict of pathConflicts) {
|
||||
console.error(` ${conflict}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate the output file
|
||||
const keyLines = sortedKeys.map((key) => ` | "${key}"`).join("\n");
|
||||
const output = `${COPYRIGHT_HEADER}
|
||||
|
||||
// AUTO-GENERATED — DO NOT EDIT
|
||||
// Generated from ${jsonFiles.length} English namespace files (${sortedKeys.length} keys)
|
||||
// Run: pnpm run generate:types
|
||||
|
||||
export type TTranslationKeys =
|
||||
${keyLines}
|
||||
;
|
||||
`;
|
||||
|
||||
fs.writeFileSync(outputFile, output, "utf-8");
|
||||
|
||||
console.log(`Generated ${sortedKeys.length} keys from ${jsonFiles.length} namespace files`);
|
||||
}
|
||||
|
||||
main();
|
||||
77
packages/i18n/scripts/lib/locale-io.ts
Normal file
77
packages/i18n/scripts/lib/locale-io.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const LOCALES_DIR = path.resolve(import.meta.dirname, "../../src/locales");
|
||||
|
||||
/** Recursively flatten an object into dot-notation keys. */
|
||||
export function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const full = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
||||
keys.push(...flattenKeys(v as Record<string, unknown>, full));
|
||||
} else {
|
||||
keys.push(full);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Parse JSON from a file, including the file path in any error message. */
|
||||
export function readJsonFile(filePath: string): Record<string, unknown> {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to parse JSON at ${filePath}: ${message}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
export interface NamespaceData {
|
||||
name: string; // file stem, e.g. "common"
|
||||
keys: Set<string>;
|
||||
data: Record<string, unknown>; // original parsed object
|
||||
}
|
||||
|
||||
export interface LocaleData {
|
||||
locale: string;
|
||||
namespaces: NamespaceData[];
|
||||
allKeys: Set<string>;
|
||||
}
|
||||
|
||||
export function listLocales(): string[] {
|
||||
const entries = fs.readdirSync(LOCALES_DIR, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
export function loadLocale(locale: string): LocaleData {
|
||||
const localeDir = path.join(LOCALES_DIR, locale);
|
||||
const files = fs.readdirSync(localeDir).filter((f) => f.endsWith(".json"));
|
||||
|
||||
const namespaces: NamespaceData[] = [];
|
||||
const allKeys = new Set<string>();
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(localeDir, file);
|
||||
const data = readJsonFile(filePath);
|
||||
const name = path.basename(file, ".json");
|
||||
const keys = flattenKeys(data);
|
||||
const keySet = new Set(keys);
|
||||
namespaces.push({ name, keys: keySet, data });
|
||||
for (const key of keys) {
|
||||
allKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return { locale, namespaces, allKeys };
|
||||
}
|
||||
245
packages/i18n/scripts/sync-check.ts
Normal file
245
packages/i18n/scripts/sync-check.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// Usage:
|
||||
// tsx packages/i18n/scripts/sync-check.ts # Report only
|
||||
// tsx packages/i18n/scripts/sync-check.ts --ci # Exit 1 if issues found
|
||||
|
||||
import type { LocaleData } from "./lib/locale-io.js";
|
||||
import { LOCALES_DIR, listLocales, loadLocale } from "./lib/locale-io.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Format a number with commas (e.g. 7712 -> "7,712"). */
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CollisionEntry {
|
||||
key: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
/** Cross-namespace collision check: same flattened key in multiple namespace files. */
|
||||
function findCollisions(localeData: LocaleData): CollisionEntry[] {
|
||||
const keyToFiles = new Map<string, string[]>();
|
||||
for (const ns of localeData.namespaces) {
|
||||
for (const key of ns.keys) {
|
||||
const existing = keyToFiles.get(key);
|
||||
if (existing) {
|
||||
existing.push(`${ns.name}.json`);
|
||||
} else {
|
||||
keyToFiles.set(key, [`${ns.name}.json`]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const collisions: CollisionEntry[] = [];
|
||||
for (const [key, files] of keyToFiles) {
|
||||
if (files.length > 1) {
|
||||
collisions.push({ key, files });
|
||||
}
|
||||
}
|
||||
return collisions.toSorted((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
interface PathConflict {
|
||||
leaf: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
/** Path conflict check: a key is both a leaf AND a prefix of another key. */
|
||||
function findPathConflicts(localeData: LocaleData): PathConflict[] {
|
||||
const allKeysArray = [...localeData.allKeys].toSorted();
|
||||
const conflicts: PathConflict[] = [];
|
||||
|
||||
// Build a set of all prefixes used in the keys
|
||||
const prefixes = new Set<string>();
|
||||
for (const key of allKeysArray) {
|
||||
const parts = key.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
prefixes.add(parts.slice(0, i).join("."));
|
||||
}
|
||||
}
|
||||
|
||||
// A conflict exists when a leaf key is also a prefix
|
||||
for (const key of allKeysArray) {
|
||||
if (prefixes.has(key)) {
|
||||
// Find one example of a key that extends this prefix
|
||||
const extending = allKeysArray.find((k) => k.startsWith(key + "."));
|
||||
if (extending) {
|
||||
conflicts.push({ leaf: key, branch: extending });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
interface LocaleComparison {
|
||||
locale: string;
|
||||
totalKeys: number;
|
||||
missingKeys: string[];
|
||||
staleKeys: string[];
|
||||
coverage: number; // 0-100
|
||||
}
|
||||
|
||||
function compareToEnglish(enKeys: Set<string>, other: LocaleData): LocaleComparison {
|
||||
const missingKeys: string[] = [];
|
||||
const staleKeys: string[] = [];
|
||||
|
||||
for (const key of enKeys) {
|
||||
if (!other.allKeys.has(key)) {
|
||||
missingKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of other.allKeys) {
|
||||
if (!enKeys.has(key)) {
|
||||
staleKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const coverage = enKeys.size > 0 ? ((enKeys.size - missingKeys.length) / enKeys.size) * 100 : 100;
|
||||
|
||||
return {
|
||||
locale: other.locale,
|
||||
totalKeys: other.allKeys.size,
|
||||
missingKeys: missingKeys.toSorted(),
|
||||
staleKeys: staleKeys.toSorted(),
|
||||
coverage,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
const ciMode = process.argv.includes("--ci");
|
||||
|
||||
// Discover all locale directories
|
||||
const localeDirs = listLocales();
|
||||
|
||||
if (!localeDirs.includes("en")) {
|
||||
console.error("ERROR: English locale (en) not found in", LOCALES_DIR);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Load all locales
|
||||
const localeDataMap = new Map<string, LocaleData>();
|
||||
for (const locale of localeDirs) {
|
||||
localeDataMap.set(locale, loadLocale(locale));
|
||||
}
|
||||
|
||||
const enData = localeDataMap.get("en")!;
|
||||
|
||||
// Run checks
|
||||
const collisions = findCollisions(enData);
|
||||
const pathConflicts = findPathConflicts(enData);
|
||||
|
||||
const comparisons: LocaleComparison[] = [];
|
||||
for (const locale of localeDirs) {
|
||||
if (locale === "en") continue;
|
||||
comparisons.push(compareToEnglish(enData.allKeys, localeDataMap.get(locale)!));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Print report
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
let hasFailure = false;
|
||||
|
||||
console.log("\n=== Sync Check Results ===\n");
|
||||
console.log(` en: ${fmt(enData.allKeys.size)} keys (source)\n`);
|
||||
|
||||
for (const comp of comparisons) {
|
||||
const status = comp.missingKeys.length === 0 ? "✓" : "✗";
|
||||
const missingStr = comp.missingKeys.length > 0 ? ` — ${fmt(comp.missingKeys.length)} missing` : "";
|
||||
const staleStr = comp.staleKeys.length > 0 ? `, ${fmt(comp.staleKeys.length)} stale` : "";
|
||||
console.log(
|
||||
` ${status} ${comp.locale.padEnd(10)} ${fmt(comp.totalKeys)} keys (${comp.coverage.toFixed(1)}%)${missingStr}${staleStr}`
|
||||
);
|
||||
if (comp.missingKeys.length > 0) {
|
||||
hasFailure = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-namespace collisions
|
||||
if (collisions.length > 0) {
|
||||
hasFailure = true;
|
||||
console.log("\nCROSS-NAMESPACE COLLISIONS:");
|
||||
for (const c of collisions) {
|
||||
console.log(` ✗ "${c.key}" exists in: ${c.files.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Path conflicts
|
||||
if (pathConflicts.length > 0) {
|
||||
hasFailure = true;
|
||||
console.log("\nPATH CONFLICTS:");
|
||||
for (const pc of pathConflicts) {
|
||||
console.log(` ✗ "${pc.leaf}" is a leaf but "${pc.branch}" extends it`);
|
||||
}
|
||||
}
|
||||
|
||||
// Missing keys detail
|
||||
const withMissing = comparisons.filter((c) => c.missingKeys.length > 0);
|
||||
if (withMissing.length > 0) {
|
||||
console.log("\n--- Missing Keys Detail ---\n");
|
||||
for (const comp of withMissing) {
|
||||
console.log(`${comp.locale} (${fmt(comp.missingKeys.length)} missing):`);
|
||||
const show = comp.missingKeys.slice(0, 20);
|
||||
for (const key of show) {
|
||||
console.log(` - ${key}`);
|
||||
}
|
||||
if (comp.missingKeys.length > 20) {
|
||||
console.log(` ... and ${fmt(comp.missingKeys.length - 20)} more`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
// Stale keys detail
|
||||
const withStale = comparisons.filter((c) => c.staleKeys.length > 0);
|
||||
if (withStale.length > 0) {
|
||||
console.log("--- Stale Keys Detail ---\n");
|
||||
for (const comp of withStale) {
|
||||
console.log(`${comp.locale} (${fmt(comp.staleKeys.length)} stale):`);
|
||||
const show = comp.staleKeys.slice(0, 20);
|
||||
for (const key of show) {
|
||||
console.log(` - ${key}`);
|
||||
}
|
||||
if (comp.staleKeys.length > 20) {
|
||||
console.log(` ... and ${fmt(comp.staleKeys.length - 20)} more`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
// CI exit code
|
||||
if (ciMode && hasFailure) {
|
||||
console.log("CI mode: exiting with code 1 due to missing keys, collisions, or path conflicts.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!hasFailure) {
|
||||
console.log("\nAll locales are in sync with English. No issues found.");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error("Sync check failed:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
12
packages/i18n/scripts/tsconfig.json
Normal file
12
packages/i18n/scripts/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["."]
|
||||
}
|
||||
Reference in New Issue
Block a user