Skip to content

Commit c0eed18

Browse files
authored
feat(l10n): migrate to per-language JSON files with Weblate integration (#563)
1 parent 3295104 commit c0eed18

60 files changed

Lines changed: 4284 additions & 4200 deletions

File tree

Some content is hidden

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

.github/workflows/ci.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ jobs:
4141
- name: Install dependencies
4242
run: yarn install
4343

44-
# Step 4: Run linters (ESLint)
44+
# Step 4: Validate l10n files
45+
- name: Validate l10n files
46+
run: node scripts/validate-l10n.js
47+
48+
# Step 5: Run linters (ESLint)
4549
- name: Run ESLint
4650
run: yarn lint
4751

.github/workflows/l10n-upload.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Weblate Translation Workflow Lifecycle:
2+
#
3+
# 1. Developer adds/changes English strings in src/locales/en.json, merges PR to main
4+
# 2. This workflow auto-uploads en.json to Weblate on merge
5+
# 3. Translators work in Weblate UI at https://hosted.weblate.org/translate/pocketpal-ai
6+
# 4. Weblate auto-creates a PR with translated files (requires Weblate admin to
7+
# configure push mode to GitHub)
8+
# 5. Human reviews and merges the Weblate PR
9+
# 6. scripts/sync-weblate.js download remains as a backup/convenience tool
10+
#
11+
# Required admin setup:
12+
# - Configure the Weblate component with GitHub PR push mode
13+
# - Set WEBLATE_TOKEN secret in repo settings -> Secrets -> Actions
14+
15+
name: Upload translations to Weblate
16+
17+
on:
18+
push:
19+
branches:
20+
- main
21+
paths:
22+
- 'src/locales/en.json'
23+
24+
permissions:
25+
contents: read
26+
27+
jobs:
28+
upload:
29+
runs-on: ubuntu-latest
30+
steps:
31+
- name: Check out code
32+
uses: actions/checkout@v4
33+
34+
- name: Set up Node.js
35+
uses: actions/setup-node@v4
36+
with:
37+
node-version: '22.21.0'
38+
39+
- name: Install dependencies
40+
run: yarn install
41+
42+
- name: Upload to Weblate
43+
env:
44+
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
45+
run: node scripts/sync-weblate.js upload

.weblate

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ url = https://hosted.weblate.org/
1010
url = https://hosted.weblate.org/projects/pocketpal-ai/translations/
1111

1212
# File format configuration
13-
file_format = json
13+
file_format = json-nested
1414
new_lang = add
1515
source_language = en
1616

App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {useTheme} from './src/hooks';
1818
import {useDeepLinking} from './src/hooks/useDeepLinking';
1919
import {Theme} from './src/utils/types';
2020

21-
import {l10n} from './src/utils/l10n';
21+
import {l10n} from './src/locales';
2222
import {initLocale} from './src/utils';
2323
import {L10nContext} from './src/utils';
2424
import {ROUTES} from './src/utils/navigationConstants';

CONTRIBUTING.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ Please add tests for any new features or changes. We use **Jest** for unit testi
6565

6666
If your changes affect the app's behavior, ensure you include or update tests as appropriate.
6767

68+
### Translations (Localization)
69+
70+
PocketPal uses [Weblate](https://hosted.weblate.org/translate/pocketpal-ai) for managing translations. When adding or changing user-facing strings:
71+
72+
- **Only edit `src/locales/en.json`** (the English source file). Do not edit `ja.json`, `zh.json`, or other language files directly — they are managed by translators through Weblate and will be overwritten.
73+
- Use `{{placeholder}}` syntax (double braces) for dynamic values, e.g. `"Imported {{count}} sessions"`.
74+
- Run `yarn l10n:validate` to check that your JSON is valid and placeholders are consistent.
75+
76+
**To contribute translations**, visit [PocketPal on Weblate](https://hosted.weblate.org/translate/pocketpal-ai) — no code changes needed.
77+
6878
### Commit Message Guidelines
6979

7080
We follow the **Conventional Commits** specification for our commit messages to ensure clarity and consistency. Use one of the following prefixes for your commits:

__mocks__/stores/uiStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {l10n} from '../../src/utils/l10n';
1+
import {l10n} from '../../src/locales';
22

33
export class UIStore {
44
static readonly GROUP_KEYS = {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"clean": "yarn clean:ios && yarn clean:android",
1919
"lint": "eslint .",
2020
"typecheck": "tsc --noEmit",
21+
"l10n:validate": "node scripts/validate-l10n.js",
2122
"lint:fix": "eslint \"**/*.{js,ts,tsx}\" --fix",
2223
"format": "prettier --write \"**/*.{js,ts,tsx,json,md}\"",
2324
"start": "react-native start",
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
const {execSync} = require('child_process');
2+
const fs = require('fs');
3+
const path = require('path');
4+
const os = require('os');
5+
6+
const SCRIPT_PATH = path.join(__dirname, '..', 'validate-l10n.js');
7+
const LOCALES_DIR = path.join(__dirname, '..', '..', 'src', 'locales');
8+
9+
/**
10+
* Run the validate-l10n.js script against a temporary locale directory.
11+
* This avoids modifying the real locale files (which would cause race conditions
12+
* when Jest runs tests in parallel).
13+
*
14+
* Creates a modified copy of the script that points to the temp directory,
15+
* copies the locale files there, applies any overrides, and runs.
16+
*/
17+
function runWithLocales(overrides = {}) {
18+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'l10n-test-'));
19+
const tmpLocalesDir = path.join(tmpDir, 'locales');
20+
fs.mkdirSync(tmpLocalesDir);
21+
22+
try {
23+
// Copy original locale files to temp dir
24+
for (const filename of ['en.json', 'ja.json', 'zh.json']) {
25+
const src = path.join(LOCALES_DIR, filename);
26+
const dest = path.join(tmpLocalesDir, filename);
27+
fs.copyFileSync(src, dest);
28+
}
29+
30+
// Apply overrides
31+
for (const [filename, content] of Object.entries(overrides)) {
32+
const dest = path.join(tmpLocalesDir, filename);
33+
fs.writeFileSync(dest, content, 'utf-8');
34+
}
35+
36+
// Create a modified copy of the script that points to the temp dir
37+
let scriptContent = fs.readFileSync(SCRIPT_PATH, 'utf-8');
38+
scriptContent = scriptContent.replace(
39+
/const LOCALES_DIR = .+;/,
40+
`const LOCALES_DIR = ${JSON.stringify(tmpLocalesDir)};`,
41+
);
42+
scriptContent = scriptContent.replace(
43+
/const EN_PATH = .+;/,
44+
`const EN_PATH = ${JSON.stringify(path.join(tmpLocalesDir, 'en.json'))};`,
45+
);
46+
const tmpScriptPath = path.join(tmpDir, 'validate-l10n.js');
47+
fs.writeFileSync(tmpScriptPath, scriptContent, 'utf-8');
48+
49+
// Run the modified script
50+
try {
51+
const output = execSync(`node "${tmpScriptPath}" 2>&1`, {
52+
encoding: 'utf-8',
53+
timeout: 10000,
54+
});
55+
return {exitCode: 0, output};
56+
} catch (e) {
57+
return {
58+
exitCode: e.status,
59+
output: (e.stdout || '') + (e.stderr || ''),
60+
};
61+
}
62+
} finally {
63+
// Clean up temp directory
64+
fs.rmSync(tmpDir, {recursive: true, force: true});
65+
}
66+
}
67+
68+
describe('validate-l10n.js', () => {
69+
it('passes with valid locale files', () => {
70+
const result = runWithLocales();
71+
expect(result.exitCode).toBe(0);
72+
expect(result.output).toContain('en.json: valid JSON');
73+
expect(result.output).toContain('ja.json: valid JSON');
74+
expect(result.output).toContain('zh.json: valid JSON');
75+
expect(result.output).toContain('All l10n files valid');
76+
});
77+
78+
it('reports the number of keys in en.json', () => {
79+
const result = runWithLocales();
80+
expect(result.output).toMatch(/en\.json: \d+ keys/);
81+
});
82+
83+
it('fails on invalid JSON in ja.json', () => {
84+
const result = runWithLocales({
85+
'ja.json': '{ invalid json content',
86+
});
87+
expect(result.exitCode).not.toBe(0);
88+
expect(result.output).toContain('INVALID JSON');
89+
});
90+
91+
it('fails on invalid JSON in zh.json', () => {
92+
const result = runWithLocales({
93+
'zh.json': '{ "unclosed": ',
94+
});
95+
expect(result.exitCode).not.toBe(0);
96+
expect(result.output).toContain('INVALID JSON');
97+
});
98+
99+
it('warns about missing keys in translation files', () => {
100+
const result = runWithLocales({
101+
'ja.json': JSON.stringify({common: {cancel: 'test'}}),
102+
});
103+
// Missing keys are warnings, not errors -- script should still pass
104+
expect(result.exitCode).toBe(0);
105+
expect(result.output).toContain('missing keys');
106+
});
107+
108+
it('fails on placeholder mismatch', () => {
109+
// en.json has storage.lowStorage with {{modelSize}} and {{freeSpace}}
110+
// Create ja.json with a wrong placeholder at that path
111+
const enData = JSON.parse(
112+
fs.readFileSync(path.join(LOCALES_DIR, 'en.json'), 'utf-8'),
113+
);
114+
const jaModified = JSON.parse(JSON.stringify(enData));
115+
jaModified.storage.lowStorage = 'wrong {{wrong}} > {{freeSpace}}';
116+
117+
const result = runWithLocales({
118+
'ja.json': JSON.stringify(jaModified),
119+
});
120+
expect(result.exitCode).not.toBe(0);
121+
expect(result.output).toContain('placeholder mismatch');
122+
});
123+
124+
it('passes when translation file has identical placeholders to en', () => {
125+
const enContent = fs.readFileSync(
126+
path.join(LOCALES_DIR, 'en.json'),
127+
'utf-8',
128+
);
129+
const result = runWithLocales({
130+
'ja.json': enContent,
131+
});
132+
expect(result.exitCode).toBe(0);
133+
expect(result.output).toContain('All l10n files valid');
134+
});
135+
});

scripts/sync-weblate.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,30 @@ async function uploadSourceFile() {
2323
const enPath = path.join(__dirname, '../src/locales/en.json');
2424

2525
if (!fs.existsSync(enPath)) {
26-
console.error('Source file en.json not found');
26+
console.error('Source file en.json not found');
2727
process.exit(1);
2828
}
2929

3030
try {
31-
const formData = new FormData();
3231
const fileContent = fs.readFileSync(enPath);
32+
const formData = new FormData();
3333
formData.append('file', new Blob([fileContent]), 'en.json');
34+
formData.append('method', 'replace');
35+
36+
const response = await axios.post(
37+
`${WEBLATE_API_URL}/translations/${PROJECT_SLUG}/${COMPONENT_SLUG}/en/file/`,
38+
formData,
39+
{
40+
headers: {
41+
Authorization: `Token ${WEBLATE_TOKEN}`,
42+
},
43+
},
44+
);
3445

35-
console.log('Uploaded source file to Weblate');
46+
console.log('Uploaded source file to Weblate:', response.status);
3647
} catch (error) {
3748
console.error(
38-
'Failed to upload source file:',
49+
'Failed to upload source file:',
3950
error.response?.data || error.message,
4051
);
4152
}
@@ -47,7 +58,7 @@ async function downloadTranslations() {
4758
for (const lang of languages) {
4859
try {
4960
const response = await axios.get(
50-
`${WEBLATE_API_URL}/projects/${PROJECT_SLUG}/components/${COMPONENT_SLUG}/translations/${lang}/file/`,
61+
`${WEBLATE_API_URL}/translations/${PROJECT_SLUG}/${COMPONENT_SLUG}/${lang}/file/`,
5162
{
5263
headers: {
5364
Authorization: `Token ${WEBLATE_TOKEN}`,

scripts/validate-l10n.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env node
2+
const fs = require('fs');
3+
const path = require('path');
4+
5+
const LOCALES_DIR = path.join(__dirname, '../src/locales');
6+
const EN_PATH = path.join(LOCALES_DIR, 'en.json');
7+
8+
function getKeys(obj, prefix = '') {
9+
const keys = [];
10+
for (const key in obj) {
11+
const fullKey = prefix ? `${prefix}.${key}` : key;
12+
if (typeof obj[key] === 'object' && obj[key] !== null) {
13+
keys.push(...getKeys(obj[key], fullKey));
14+
} else {
15+
keys.push(fullKey);
16+
}
17+
}
18+
return keys;
19+
}
20+
21+
function getPlaceholders(str) {
22+
const matches = str.match(/\{\{(\w+)\}\}/g) || [];
23+
return matches.sort();
24+
}
25+
26+
function getValueAtPath(obj, keyPath) {
27+
return keyPath.split('.').reduce((o, k) => o && o[k], obj);
28+
}
29+
30+
let errors = 0;
31+
32+
// 1. Validate JSON parsing
33+
let en;
34+
try {
35+
en = JSON.parse(fs.readFileSync(EN_PATH, 'utf-8'));
36+
console.log('en.json: valid JSON');
37+
} catch (e) {
38+
console.error('en.json: INVALID JSON:', e);
39+
process.exit(1);
40+
}
41+
42+
const enKeys = getKeys(en);
43+
console.log(`en.json: ${enKeys.length} keys`);
44+
45+
for (const lang of ['ja', 'zh']) {
46+
const langPath = path.join(LOCALES_DIR, `${lang}.json`);
47+
let langData;
48+
49+
try {
50+
langData = JSON.parse(fs.readFileSync(langPath, 'utf-8'));
51+
console.log(`${lang}.json: valid JSON`);
52+
} catch (e) {
53+
console.error(`${lang}.json: INVALID JSON:`, e);
54+
errors++;
55+
continue;
56+
}
57+
58+
const langKeys = getKeys(langData);
59+
60+
// 2. Check missing keys (warnings, not errors - fallback handles them)
61+
const missingKeys = enKeys.filter(k => !langKeys.includes(k));
62+
if (missingKeys.length > 0) {
63+
console.warn(
64+
`${lang}.json: ${missingKeys.length} missing keys (will fall back to English)`,
65+
);
66+
}
67+
68+
// 3. Check placeholder consistency (errors - mismatched placeholders are bugs)
69+
for (const key of langKeys) {
70+
const enValue = getValueAtPath(en, key);
71+
const langValue = getValueAtPath(langData, key);
72+
73+
if (typeof enValue === 'string' && typeof langValue === 'string') {
74+
const enPlaceholders = getPlaceholders(enValue);
75+
const langPlaceholders = getPlaceholders(langValue);
76+
77+
if (JSON.stringify(enPlaceholders) !== JSON.stringify(langPlaceholders)) {
78+
console.error(
79+
`${lang}.json: placeholder mismatch at "${key}": ` +
80+
`en has [${enPlaceholders.join(', ')}] but ${lang} has [${langPlaceholders.join(', ')}]`,
81+
);
82+
errors++;
83+
}
84+
}
85+
}
86+
}
87+
88+
if (errors > 0) {
89+
console.error(`\nValidation failed with ${errors} error(s)`);
90+
process.exit(1);
91+
} else {
92+
console.log('\nAll l10n files valid');
93+
}

0 commit comments

Comments
 (0)