Skip to content

Commit 31c4102

Browse files
authored
feat: translations now use crowdin (translate.unraid.net) (#1739)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - App-wide internationalization: dynamic locale detection/loading, many new locale bundles, and CLI helpers to extract/sort translation keys. - **Accessibility** - Brand button supports keyboard activation (Enter/Space). - **Documentation** - Internationalization guidance added to API and Web READMEs. - **Refactor** - UI updated to use centralized i18n keys and a unified locale loading approach. - **Tests** - Test utilities updated to support i18n and localized assertions. - **Chores** - Crowdin config and i18n scripts added; runtime locale exposed for selection. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent fabe6a2 commit 31c4102

File tree

164 files changed

+16740
-2480
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

164 files changed

+16740
-2480
lines changed

api/.eslintrc.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ export default tseslint.config(
4242
'ignorePackages',
4343
{
4444
js: 'always',
45-
ts: 'always',
45+
mjs: 'always',
46+
cjs: 'always',
47+
ts: 'never',
48+
tsx: 'never',
4649
},
4750
],
4851
'no-restricted-globals': [

api/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ unraid-api report -vv
7171

7272
If you found this file you're likely a developer. If you'd like to know more about the API and when it's available please join [our discord](https://discord.unraid.net/).
7373

74+
## Internationalization
75+
76+
- Run `pnpm --filter @unraid/api i18n:extract` to scan the Nest.js source for translation helper usages and update `src/i18n/en.json` with any new keys. The extractor keeps existing translations intact and appends new keys with their English source text.
77+
7478
## License
7579

7680
Copyright Lime Technology Inc. All rights reserved.

api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
"// GraphQL Codegen": "",
3131
"codegen": "graphql-codegen --config codegen.ts",
3232
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
33+
"// Internationalization": "",
34+
"i18n:extract": "node ./scripts/extract-translations.mjs",
3335
"// Code Quality": "",
3436
"lint": "eslint --config .eslintrc.ts src/",
3537
"lint:fix": "eslint --fix --config .eslintrc.ts src/",
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#!/usr/bin/env node
2+
3+
import { readFile, writeFile } from 'node:fs/promises';
4+
import path from 'node:path';
5+
import { glob } from 'glob';
6+
import ts from 'typescript';
7+
8+
const projectRoot = process.cwd();
9+
const sourcePatterns = 'src/**/*.{ts,js}';
10+
const ignorePatterns = [
11+
'**/__tests__/**',
12+
'**/__test__/**',
13+
'**/*.spec.ts',
14+
'**/*.spec.js',
15+
'**/*.test.ts',
16+
'**/*.test.js',
17+
];
18+
19+
const englishLocaleFile = path.resolve(projectRoot, 'src/i18n/en.json');
20+
21+
const identifierTargets = new Set(['t', 'translate']);
22+
const propertyTargets = new Set([
23+
'i18n.t',
24+
'i18n.translate',
25+
'ctx.t',
26+
'this.translate',
27+
'this.i18n.translate',
28+
'this.i18n.t',
29+
]);
30+
31+
function getPropertyChain(node) {
32+
if (ts.isIdentifier(node)) {
33+
return node.text;
34+
}
35+
if (ts.isPropertyAccessExpression(node)) {
36+
const left = getPropertyChain(node.expression);
37+
if (!left) return undefined;
38+
return `${left}.${node.name.text}`;
39+
}
40+
return undefined;
41+
}
42+
43+
function extractLiteral(node) {
44+
if (ts.isStringLiteralLike(node)) {
45+
return node.text;
46+
}
47+
if (ts.isNoSubstitutionTemplateLiteral(node)) {
48+
return node.text;
49+
}
50+
return undefined;
51+
}
52+
53+
function collectKeysFromSource(sourceFile) {
54+
const keys = new Set();
55+
56+
function visit(node) {
57+
if (ts.isCallExpression(node)) {
58+
const expr = node.expression;
59+
let matches = false;
60+
61+
if (ts.isIdentifier(expr) && identifierTargets.has(expr.text)) {
62+
matches = true;
63+
} else if (ts.isPropertyAccessExpression(expr)) {
64+
const chain = getPropertyChain(expr);
65+
if (chain && propertyTargets.has(chain)) {
66+
matches = true;
67+
}
68+
}
69+
70+
if (matches) {
71+
const [firstArg] = node.arguments;
72+
if (firstArg) {
73+
const literal = extractLiteral(firstArg);
74+
if (literal) {
75+
keys.add(literal);
76+
}
77+
}
78+
}
79+
}
80+
81+
ts.forEachChild(node, visit);
82+
}
83+
84+
visit(sourceFile);
85+
return keys;
86+
}
87+
88+
async function loadEnglishCatalog() {
89+
try {
90+
const raw = await readFile(englishLocaleFile, 'utf8');
91+
const parsed = raw.trim() ? JSON.parse(raw) : {};
92+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
93+
throw new Error('English locale file must contain a JSON object.');
94+
}
95+
return parsed;
96+
} catch (error) {
97+
if (error && error.code === 'ENOENT') {
98+
return {};
99+
}
100+
throw error;
101+
}
102+
}
103+
104+
async function ensureEnglishCatalog(keys) {
105+
const existingCatalog = await loadEnglishCatalog();
106+
const existingKeys = new Set(Object.keys(existingCatalog));
107+
108+
let added = 0;
109+
const combinedKeys = new Set([...existingKeys, ...keys]);
110+
const sortedKeys = Array.from(combinedKeys).sort((a, b) => a.localeCompare(b));
111+
const nextCatalog = {};
112+
113+
for (const key of sortedKeys) {
114+
if (Object.prototype.hasOwnProperty.call(existingCatalog, key)) {
115+
nextCatalog[key] = existingCatalog[key];
116+
} else {
117+
nextCatalog[key] = key;
118+
added += 1;
119+
}
120+
}
121+
122+
const nextJson = `${JSON.stringify(nextCatalog, null, 2)}\n`;
123+
const existingJson = JSON.stringify(existingCatalog, null, 2) + '\n';
124+
125+
if (nextJson !== existingJson) {
126+
await writeFile(englishLocaleFile, nextJson, 'utf8');
127+
}
128+
129+
return added;
130+
}
131+
132+
async function main() {
133+
const files = await glob(sourcePatterns, {
134+
cwd: projectRoot,
135+
ignore: ignorePatterns,
136+
absolute: true,
137+
});
138+
139+
const collectedKeys = new Set();
140+
141+
await Promise.all(
142+
files.map(async (file) => {
143+
const content = await readFile(file, 'utf8');
144+
const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true);
145+
const keys = collectKeysFromSource(sourceFile);
146+
keys.forEach((key) => collectedKeys.add(key));
147+
}),
148+
);
149+
150+
const added = await ensureEnglishCatalog(collectedKeys);
151+
152+
if (added === 0) {
153+
console.log('[i18n] No new backend translation keys detected.');
154+
} else {
155+
console.log(`[i18n] Added ${added} key(s) to src/i18n/en.json.`);
156+
}
157+
}
158+
159+
main().catch((error) => {
160+
console.error('[i18n] Failed to extract backend translations.', error);
161+
process.exitCode = 1;
162+
});

api/src/i18n/ar.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

api/src/i18n/bn.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

api/src/i18n/ca.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

api/src/i18n/cs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

api/src/i18n/da.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

api/src/i18n/de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

0 commit comments

Comments
 (0)