Files
upstream-plane/packages/i18n/scripts/sync-check.ts
upstream-mirror 23059a1de3
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
snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
2026-09-15 15:14:58 +05:00

246 lines
7.1 KiB
TypeScript

/**
* 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);
}