Skip to content

Commit 369a007

Browse files
committed
Add tasks for handling plurals
1 parent 0d5f2da commit 369a007

File tree

3 files changed

+110
-132
lines changed

3 files changed

+110
-132
lines changed

apps/meteor/.scripts/translation-check.ts

Lines changed: 1 addition & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -42,28 +42,6 @@ const parseFile = async (path: PathLike) => {
4242
return json;
4343
};
4444

45-
const insertTranslation = (json: Record<string, string>, refKey: string, [key, value]: [key: string, value: string]) => {
46-
const entries = Object.entries(json);
47-
48-
const refIndex = entries.findIndex(([entryKey]) => entryKey === refKey);
49-
50-
if (refIndex === -1) {
51-
throw new Error(`Reference key ${refKey} not found`);
52-
}
53-
54-
const movingEntries = entries.slice(refIndex + 1);
55-
56-
for (const [key] of movingEntries) {
57-
delete json[key];
58-
}
59-
60-
json[key] = value;
61-
62-
for (const [key, value] of movingEntries) {
63-
json[key] = value;
64-
}
65-
};
66-
6745
const persistFile = async (path: PathLike, json: Record<string, string>) => {
6846
const content = JSON.stringify(json, null, 2);
6947

@@ -124,102 +102,11 @@ export const extractSingularKeys = (json: Record<string, string>, lng: string) =
124102
return [singularKeys, pluralSuffixes] as const;
125103
};
126104

127-
const checkMissingPlurals = async ({
128-
json,
129-
path,
130-
lng,
131-
fix = false,
132-
}: {
133-
json: Record<string, string>;
134-
path: PathLike;
135-
lng: string;
136-
fix?: boolean;
137-
}) => {
138-
const [singularKeys, pluralSuffixes] = extractSingularKeys(json, lng);
139-
140-
const missingPluralKeys: { singularKey: string; existing: string[]; missing: string[] }[] = [];
141-
142-
for (const singularKey of singularKeys) {
143-
if (singularKey in json) {
144-
continue;
145-
}
146-
147-
const pluralKeys = pluralSuffixes.map((suffix) => `${singularKey}${suffix}`);
148-
149-
const existing = pluralKeys.filter((key) => key in json);
150-
const missing = pluralKeys.filter((key) => !(key in json));
151-
152-
if (missing.length > 0) {
153-
missingPluralKeys.push({ singularKey, existing, missing });
154-
}
155-
}
156-
157-
if (missingPluralKeys.length > 0) {
158-
const message = `Missing plural keys on file ${path}: ${inspect(missingPluralKeys, { colors: !!supportsColor.stdout })}`;
159-
160-
if (fix) {
161-
console.warn(message);
162-
163-
for (const { existing, missing } of missingPluralKeys) {
164-
for (const missingKey of missing) {
165-
const refKey = existing.slice(-1)[0];
166-
const value = json[refKey];
167-
insertTranslation(json, refKey, [missingKey, value]);
168-
}
169-
}
170-
171-
await persistFile(path, json);
172-
173-
return;
174-
}
175-
176-
throw new Error(message);
177-
}
178-
};
179-
180-
const checkExceedingKeys = async ({
181-
json,
182-
path,
183-
lng,
184-
sourceJson,
185-
sourceLng,
186-
fix = false,
187-
}: {
188-
json: Record<string, string>;
189-
path: PathLike;
190-
lng: string;
191-
sourceJson: Record<string, string>;
192-
sourceLng: string;
193-
fix?: boolean;
194-
}) => {
195-
const [singularKeys] = extractSingularKeys(json, lng);
196-
const [sourceSingularKeys] = extractSingularKeys(sourceJson, sourceLng);
197-
198-
const exceedingKeys = [...singularKeys].filter((key) => !sourceSingularKeys.has(key));
199-
200-
if (exceedingKeys.length > 0) {
201-
const message = `Exceeding keys on file ${path}: ${inspect(exceedingKeys, { colors: !!supportsColor.stdout })}`;
202-
203-
if (fix) {
204-
for (const key of exceedingKeys) {
205-
delete json[key];
206-
}
207-
208-
await persistFile(path, json);
209-
210-
return;
211-
}
212-
213-
throw new Error(message);
214-
}
215-
};
216-
217105
const checkFiles = async (sourceDirPath: string, sourceLng: string, fix = false) => {
218106
const sourcePath = join(sourceDirPath, `${sourceLng}.i18n.json`);
219107
const sourceJson = await parseFile(sourcePath);
220108

221109
await checkPlaceholdersFormat({ json: sourceJson, path: sourcePath, fix });
222-
await checkMissingPlurals({ json: sourceJson, path: sourcePath, lng: sourceLng, fix });
223110

224111
const i18nFiles = await fg([join(sourceDirPath, `**/*.i18n.json`), `!${sourcePath}`]);
225112

@@ -235,10 +122,8 @@ const checkFiles = async (sourceDirPath: string, sourceLng: string, fix = false)
235122
}),
236123
);
237124

238-
for await (const { path, json, lng } of translations) {
125+
for await (const { path, json } of translations) {
239126
await checkPlaceholdersFormat({ json, path, fix });
240-
await checkExceedingKeys({ json, path, lng, sourceJson, sourceLng, fix });
241-
await checkMissingPlurals({ json, path, lng, fix });
242127
}
243128
};
244129

packages/i18n/src/scripts/check.mts

Lines changed: 97 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { argv, stderr, stdout } from 'node:process';
22
import { fileURLToPath } from 'node:url';
33
import { formatWithOptions, parseArgs, styleText } from 'node:util';
44

5-
import { baseLanguage, getResourceLanguages, readResource, writeResource } from './common.mts';
5+
import { baseLanguage, getLanguagePlurals, getResourceLanguages, readResource, writeResource } from './common.mts';
66

77
type TaskOptions = {
88
fix?: boolean;
@@ -14,17 +14,26 @@ const describeTask =
1414
(
1515
task: string,
1616
fn: () => AsyncGenerator<{
17-
lint: (collectError: (format?: any, ...param: any[]) => void) => Promise<void>;
18-
fix: () => Promise<void>;
17+
lint: (reportError: (format?: any, ...param: any[]) => void) => Promise<void>;
18+
fix: (throwError: (format?: any, ...param: any[]) => void) => Promise<void>;
1919
}>,
2020
) =>
2121
async (options: TaskOptions) => {
2222
for await (const result of fn()) {
2323
if (!result) continue;
2424

2525
if (options.fix) {
26-
await result.fix();
27-
console.log(styleText('blue', '✔', { stream: stdout }), styleText('gray', `${task}:`, { stream: stdout }), 'fixes applied');
26+
try {
27+
await result.fix((format, ...param) => {
28+
throw new Error(formatWithOptions({ colors: !!styleText('blue', `.`, { stream: stdout }) }, format, ...param));
29+
});
30+
31+
console.log(styleText('blue', '✔', { stream: stdout }), styleText('gray', `${task}:`, { stream: stdout }), 'fixes applied');
32+
} catch (error) {
33+
console.error(styleText('red', '✘', { stream: stdout }), styleText('gray', `${task}:`, { stream: stdout }), error instanceof Error ? error.message : error);
34+
console.error(styleText('gray', ` cannot apply fixes automatically, run without --fix to see all errors`, { stream: stdout }));
35+
errorCount++;
36+
}
2837
} else {
2938
const stderrSupportsColor = styleText('blue', `.`, { stream: stderr }) !== '.';
3039

@@ -41,8 +50,7 @@ const describeTask =
4150
};
4251

4352
/**
44-
* Sort keys of the base language (en) alphabetically
45-
* and write back the sorted resource file if necessary
53+
* Sort keys of the base language (en) alphabetically and write back the sorted resource file if necessary
4654
*/
4755
const sortBaseKeys = describeTask('sort-base-keys', async function* () {
4856
const baseResource = await readResource(baseLanguage);
@@ -53,7 +61,7 @@ const sortBaseKeys = describeTask('sort-base-keys', async function* () {
5361
if (keys.join(',') === sortedKeys.join(',')) return;
5462

5563
yield {
56-
lint: async (collectError) => {
64+
lint: async (reportError) => {
5765
for (let i = 0; i < keys.length; i++) {
5866
const key = keys[i];
5967
const beforeKey = keys.at(i - 1);
@@ -62,9 +70,9 @@ const sortBaseKeys = describeTask('sort-base-keys', async function* () {
6270

6371
if (beforeKey !== expectedBeforeKey) {
6472
if (expectedBeforeKey) {
65-
collectError('%o should be after %o', keys[i], expectedBeforeKey);
73+
reportError('%o should be after %o', keys[i], expectedBeforeKey);
6674
} else {
67-
collectError('%o should be the first key', keys[i]);
75+
reportError('%o should be the first key', keys[i]);
6876
}
6977
}
7078
}
@@ -110,7 +118,7 @@ const sortKeys = describeTask('sort-keys', async function* () {
110118
if (Object.keys(resource).join(',') === Object.keys(sortedResource).join(',')) continue;
111119

112120
yield {
113-
lint: async (collectError) => {
121+
lint: async (reportError) => {
114122
const keys = Object.keys(resource);
115123
const sortedKeys = Object.keys(sortedResource);
116124

@@ -124,9 +132,9 @@ const sortKeys = describeTask('sort-keys', async function* () {
124132

125133
if (beforeKey !== expectedBeforeKey) {
126134
if (expectedBeforeKey) {
127-
collectError('%s: %o should be after %o', language, keys[i], expectedBeforeKey);
135+
reportError('%s: %o should be after %o', language, keys[i], expectedBeforeKey);
128136
} else {
129-
collectError('%s: %o should be the first key', language, keys[i]);
137+
reportError('%s: %o should be the first key', language, keys[i]);
130138
}
131139
}
132140
}
@@ -156,10 +164,10 @@ const wipeExtraKeys = describeTask('wipe-extra-keys', async function* () {
156164
if (resourceKeys.difference(baseKeys).size === 0) continue;
157165

158166
yield {
159-
lint: async (collectError) => {
167+
lint: async (reportError) => {
160168
const extraKeys = resourceKeys.difference(baseKeys);
161169
for (const key of extraKeys) {
162-
collectError('%s: has extra key %o', language, key);
170+
reportError('%s: has extra key %o', language, key);
163171
}
164172
},
165173
fix: async () => {
@@ -176,15 +184,88 @@ const wipeExtraKeys = describeTask('wipe-extra-keys', async function* () {
176184
}
177185
});
178186

187+
/**
188+
* Wipes invalid plural forms from all language files (only "zero", "one", "two", "few", "many", "other" are valid)
189+
*/
190+
const wipeInvalidPlurals = describeTask('wipe-invalid-plurals', async function* () {
191+
const languages = await getResourceLanguages();
192+
193+
for await (const language of languages) {
194+
const resource = await readResource(language);
195+
const plurals = getLanguagePlurals(language).concat(['zero']); // 'zero' is special in i18next
196+
197+
for (const [key, translation] of Object.entries(resource)) {
198+
if (typeof translation !== 'object' || !translation) continue;
199+
200+
const translationPlurals = Object.keys(translation);
201+
for (const plural of translationPlurals) {
202+
if (!plurals.includes(plural)) {
203+
yield {
204+
lint: async (reportError) => {
205+
reportError('%s: key %o has invalid plural form %o', language, key, plural);
206+
},
207+
fix: async () => {
208+
const fixedResource: Record<string, unknown> = { ...resource };
209+
fixedResource[key] = Object.fromEntries(
210+
Object.entries(translation).filter(([p]) => plurals.includes(p)),
211+
);
212+
await writeResource(language, fixedResource);
213+
}
214+
};
215+
}
216+
}
217+
}
218+
}
219+
});
220+
221+
/**
222+
* Finds missing plural forms in all language files
223+
*/
224+
const findMissingPlurals = describeTask('find-missing-plurals', async function* () {
225+
const languages = await getResourceLanguages();
226+
227+
for await (const language of languages) {
228+
if (language === baseLanguage) continue;
229+
230+
const resource = await readResource(language);
231+
const baseResource = await readResource(baseLanguage);
232+
const plurals = getLanguagePlurals(language);
233+
234+
for (const [key, translation] of Object.entries(baseResource)) {
235+
if (typeof translation !== 'object' || !translation) continue;
236+
if (!(key in resource)) continue;
237+
238+
const translationPlurals = Object.keys(translation);
239+
const resourceTranslation = resource[key];
240+
if (typeof resourceTranslation !== 'object' || !resourceTranslation) continue;
241+
242+
for (const plural of translationPlurals) {
243+
if (!plurals.includes(plural)) continue;
244+
if (plural in resourceTranslation) continue;
245+
yield {
246+
lint: async (reportError) => {
247+
reportError('%s: key %o is missing plural form %o', language, key, plural);
248+
},
249+
fix: async (throwError) => {
250+
throwError('%s: key %o is missing plural form %o', language, key, plural);
251+
}
252+
};
253+
}
254+
}
255+
}
256+
});
257+
179258
const tasksByName = {
180259
'sort-base-keys': sortBaseKeys,
181260
'sort-keys': sortKeys,
182261
'wipe-extra-keys': wipeExtraKeys,
262+
'wipe-invalid-plurals': wipeInvalidPlurals,
263+
'find-missing-plurals': findMissingPlurals,
183264
} as const;
184265

185266
async function check({ fix, task }: { fix?: boolean; task?: string[] } = {}) {
186267
// We're lenient by default excluding 'sort-base-keys' from the default tasks
187-
const tasks = new Set<keyof typeof tasksByName>(['sort-keys', 'wipe-extra-keys']);
268+
const tasks = new Set<keyof typeof tasksByName>(['sort-keys', 'wipe-extra-keys', 'wipe-invalid-plurals', 'find-missing-plurals']);
188269

189270
if (task?.length) {
190271
tasks.clear();

packages/i18n/src/scripts/common.mts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,25 @@ import { readdir, readFile, writeFile } from 'node:fs/promises';
22
import { basename, dirname, join } from 'node:path';
33
import { fileURLToPath } from 'node:url';
44

5+
import i18next from 'i18next';
6+
57
export const baseLanguage = 'en';
68

79
export async function getResourceLanguages() {
810
const resourceFiles = await readdir(resourcesDirectory);
911
return resourceFiles.map((file) => languageFromBasename(file));
1012
}
1113

14+
export const getLanguagePlurals = (language: string): string[] => {
15+
// @ts-expect-error - faulty module resolution from ESM package
16+
if (!i18next.isInitialized) {
17+
i18next.init({ initImmediate: false });
18+
}
19+
20+
// @ts-expect-error - faulty module resolution from ESM package
21+
return i18next.services.pluralResolver.getSuffixes(language).map((suffix: string) => suffix.slice(1));
22+
};
23+
1224
export async function readResource(language: string) {
1325
return JSON.parse(await readFile(join(resourcesDirectory, resourceBasename(language)), 'utf8'));
1426
}

0 commit comments

Comments
 (0)