Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
51 changes: 51 additions & 0 deletions .github/workflows/i18n-sync-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: i18n sync check

on:
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "reopened"
- "ready_for_review"
paths:
- "packages/i18n/**"
- ".github/workflows/i18n-sync-check.yml"
push:
branches:
- "preview"
paths:
- "packages/i18n/**"

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
sync-check:
name: check:sync
runs-on: ubuntu-latest
timeout-minutes: 5
if: github.event_name == 'push' || github.event.pull_request.draft == false
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
filter: blob:none

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "22.18.0"

- name: Enable Corepack and pnpm
run: corepack enable pnpm

- name: Run sync check
run: pnpm dlx tsx packages/i18n/scripts/sync-check.ts --ci
7 changes: 4 additions & 3 deletions packages/i18n/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
"scripts": {
"dev": "tsdown --watch --no-clean",
"build": "pnpm run generate:types && tsdown",
"generate:types": "npx tsx@4.19.2 scripts/generate-types.ts",
"sync:check": "npx tsx@4.19.2 scripts/sync-check.ts",
"check:sync": "npx tsx@4.19.2 scripts/sync-check.ts --ci",
"generate:types": "tsx scripts/generate-types.ts",
"sync:check": "tsx scripts/sync-check.ts",
"check:sync": "tsx scripts/sync-check.ts --ci",
"check:lint": "oxlint --max-warnings=9 .",
"check:types": "pnpm run generate:types && tsc --noEmit",
"check:format": "oxfmt --check .",
Expand All @@ -37,6 +37,7 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"tsdown": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:"
}
}
77 changes: 77 additions & 0 deletions packages/i18n/scripts/lib/locale-io.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
94 changes: 19 additions & 75 deletions packages/i18n/scripts/sync-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,74 +5,21 @@
*/

// Usage:
// npx tsx packages/i18n/scripts/sync-check.ts # Report only
// npx tsx packages/i18n/scripts/sync-check.ts --ci # Exit 1 if issues found
// tsx packages/i18n/scripts/sync-check.ts # Report only
// tsx packages/i18n/scripts/sync-check.ts --ci # Exit 1 if issues found

import fs from "node:fs";
import path from "node:path";
import type { LocaleData } from "./lib/locale-io.js";
import { LOCALES_DIR, listLocales, loadLocale } from "./lib/locale-io.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const LOCALES_DIR = path.resolve(import.meta.dirname, "../src/locales");

/** Recursively flatten an object into dot-notation keys. */
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;
}

/** Format a number with commas (e.g. 7712 -> "7,712"). */
function fmt(n: number): string {
return n.toLocaleString("en-US");
}

// ---------------------------------------------------------------------------
// Load locale data
// ---------------------------------------------------------------------------

interface NamespaceData {
name: string; // file stem, e.g. "translations"
keys: Set<string>; // flattened dot-notation keys
}

interface LocaleData {
locale: string;
namespaces: NamespaceData[];
allKeys: Set<string>;
}

async function loadLocale(locale: string): Promise<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 obj: Record<string, unknown> = JSON.parse(fs.readFileSync(filePath, "utf-8"));
const name = path.basename(file, ".json");
const keys = flattenKeys(obj);
const keySet = new Set(keys);
namespaces.push({ name, keys: keySet });
for (const key of keys) {
allKeys.add(key);
}
}

return { locale, namespaces, allKeys };
}

// ---------------------------------------------------------------------------
// Checks
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -102,8 +49,7 @@ function findCollisions(localeData: LocaleData): CollisionEntry[] {
collisions.push({ key, files });
}
}

return collisions.sort((a, b) => a.key.localeCompare(b.key));
return collisions.toSorted((a, b) => a.key.localeCompare(b.key));
}

interface PathConflict {
Expand All @@ -113,7 +59,7 @@ interface PathConflict {

/** Path conflict check: a key is both a leaf AND a prefix of another key. */
function findPathConflicts(localeData: LocaleData): PathConflict[] {
const allKeysArray = [...localeData.allKeys].sort();
const allKeysArray = [...localeData.allKeys].toSorted();
const conflicts: PathConflict[] = [];

// Build a set of all prefixes used in the keys
Expand Down Expand Up @@ -168,8 +114,8 @@ function compareToEnglish(enKeys: Set<string>, other: LocaleData): LocaleCompari
return {
locale: other.locale,
totalKeys: other.allKeys.size,
missingKeys: missingKeys.sort(),
staleKeys: staleKeys.sort(),
missingKeys: missingKeys.toSorted(),
staleKeys: staleKeys.toSorted(),
coverage,
};
}
Expand All @@ -178,15 +124,11 @@ function compareToEnglish(enKeys: Set<string>, other: LocaleData): LocaleCompari
// Main
// ---------------------------------------------------------------------------

async function main() {
function main() {
const ciMode = process.argv.includes("--ci");

// Discover all locale directories
const entries = fs.readdirSync(LOCALES_DIR, { withFileTypes: true });
const localeDirs = entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
const localeDirs = listLocales();

if (!localeDirs.includes("en")) {
console.error("ERROR: English locale (en) not found in", LOCALES_DIR);
Expand All @@ -196,7 +138,7 @@ async function main() {
// Load all locales
const localeDataMap = new Map<string, LocaleData>();
for (const locale of localeDirs) {
localeDataMap.set(locale, await loadLocale(locale));
localeDataMap.set(locale, loadLocale(locale));
}

const enData = localeDataMap.get("en")!;
Expand All @@ -221,8 +163,8 @@ async function main() {
console.log(` en: ${fmt(enData.allKeys.size)} keys (source)\n`);

for (const comp of comparisons) {
const status = comp.missingKeys.length === 0 ? "\u2713" : "\u2717";
const missingStr = comp.missingKeys.length > 0 ? ` \u2014 ${fmt(comp.missingKeys.length)} missing` : "";
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}`
Expand All @@ -237,7 +179,7 @@ async function main() {
hasFailure = true;
console.log("\nCROSS-NAMESPACE COLLISIONS:");
for (const c of collisions) {
console.log(` \u2717 "${c.key}" exists in: ${c.files.join(", ")}`);
console.log(` "${c.key}" exists in: ${c.files.join(", ")}`);
}
}

Expand All @@ -246,7 +188,7 @@ async function main() {
hasFailure = true;
console.log("\nPATH CONFLICTS:");
for (const pc of pathConflicts) {
console.log(` \u2717 "${pc.leaf}" is a leaf but "${pc.branch}" extends it`);
console.log(` "${pc.leaf}" is a leaf but "${pc.branch}" extends it`);
}
}

Expand Down Expand Up @@ -295,7 +237,9 @@ async function main() {
}
}

main().catch((err) => {
try {
main();
} catch (err) {
console.error("Sync check failed:", err);
process.exit(1);
});
}
Loading