Skip to content

Commit 9a1a761

Browse files
anshgoyalevilasyncapi-botsambhavgupta0705akshatnema
authored
feat: add check-locales workflow (#3950)
Co-authored-by: Ansh Goyal <anshgoyal1704@gmail.com> Co-authored-by: Chan <bot+chan@asyncapi.io> Co-authored-by: Sambhav Gupta <81870866+sambhavgupta0705@users.noreply.github.com> Co-authored-by: akshatnema <akshatnema.school@gmail.com>
1 parent 262159f commit 9a1a761

File tree

4 files changed

+468
-0
lines changed

4 files changed

+468
-0
lines changed

.github/workflows/if-nodejs-pr-testing.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,57 @@ jobs:
116116
delete: true
117117
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
118118

119+
# Run the test:locales script and capture output with cleaner formatting
120+
- if: ${{ steps.packagejson.outputs.exists == 'true' && matrix.os == 'ubuntu-latest' }}
121+
name: Run locale checks
122+
id: locale_check
123+
run: |
124+
# Run the test and capture any errors
125+
set +e
126+
LOCALE_OUTPUT=$(npm run test:locales 2>&1)
127+
EXIT_CODE=$?
128+
set -e
129+
130+
# If the command failed, extract and format the error message
131+
if [ $EXIT_CODE -ne 0 ]; then
132+
# Extract the found languages
133+
LANGUAGES=$(echo "$LOCALE_OUTPUT" | grep "Found" | sed 's/.*Found \(.*\) languages.*/Found \1 languages:/')
134+
135+
# Extract the missing keys information without timestamps
136+
MISSING_KEYS=$(echo "$LOCALE_OUTPUT" | grep -A 100 "Missing keys in" | grep -v "\[.*PM\] \|^\s*$")
137+
138+
# Combine the cleaned output
139+
CLEANED_OUTPUT="$LANGUAGES\n\n$MISSING_KEYS"
140+
141+
echo "locale_output<<EOF" >> $GITHUB_OUTPUT
142+
echo -e "$CLEANED_OUTPUT" >> $GITHUB_OUTPUT
143+
echo "EOF" >> $GITHUB_OUTPUT
144+
fi
145+
146+
# Post a comment using sticky-pull-request-comment
147+
- name: Comment on PR with locale issues
148+
if: ${{ steps.locale_check.outputs.locale_output != '' && matrix.os == 'ubuntu-latest' }}
149+
uses: marocchino/sticky-pull-request-comment@3d60a5b2dae89d44e0c6ddc69dd7536aec2071cd
150+
with:
151+
header: locale-check-error
152+
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
153+
message: |
154+
### Locale Check Results
155+
We found issues with locale keys:
156+
```
157+
${{ steps.locale_check.outputs.locale_output }}
158+
```
159+
Please make sure all locale files have the same translation keys.
160+
161+
# Delete the comment if there are no issues
162+
- if: ${{ steps.locale_check.outputs.locale_output == '' && steps.packagejson.outputs.exists == 'true' && matrix.os == 'ubuntu-latest' }}
163+
name: Delete locale check comment if no issues
164+
uses: marocchino/sticky-pull-request-comment@3d60a5b2dae89d44e0c6ddc69dd7536aec2071cd
165+
with:
166+
header: locale-check-error
167+
delete: true
168+
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
169+
119170
- if: steps.packagejson.outputs.exists == 'true'
120171
name: Upload Coverage to Codecov
121172
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"test:netlify": "deno test --allow-env --trace-ops netlify/**/*.test.ts",
2727
"test:md": "tsx scripts/markdown/check-markdown.ts",
2828
"test:editlinks": "tsx scripts/markdown/check-edit-links.ts",
29+
"test:locales": "tsx scripts/utils/check-locales.ts",
2930
"dev:storybook": "storybook dev -p 6006",
3031
"build:storybook": "storybook build"
3132
},

scripts/utils/check-locales.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import fs from 'fs';
2+
import lodash from 'lodash';
3+
import path, { dirname } from 'path';
4+
import { fileURLToPath } from 'url';
5+
6+
import { logger } from './logger';
7+
8+
const { flatten, fromPairs, uniq } = lodash;
9+
10+
const currentFilePath = fileURLToPath(import.meta.url);
11+
const currentDirPath = dirname(currentFilePath);
12+
13+
const localesDir = path.resolve(
14+
currentDirPath,
15+
'..',
16+
'..',
17+
'public',
18+
'locales',
19+
);
20+
21+
/**
22+
* Extracts all keys from a JSON object, including nested keys using lodash
23+
*
24+
* @param {Record<string, any>} obj - The JSON object to extract keys from
25+
* @returns {string[]} Array of keys with their full paths (using dot notation for nested keys)
26+
*/
27+
function extractKeys(obj: Record<string, any>): string[] {
28+
const extractNestedKeys = (
29+
nestedObj: Record<string, any>,
30+
prefix = '',
31+
result: string[] = [],
32+
): string[] => {
33+
for (const [key, value] of Object.entries(nestedObj)) {
34+
const currentKey = prefix ? `${prefix}.${key}` : key;
35+
36+
if (value !== null && typeof value === 'object') {
37+
extractNestedKeys(value, currentKey, result);
38+
} else {
39+
result.push(currentKey);
40+
}
41+
}
42+
43+
return result;
44+
};
45+
46+
return extractNestedKeys(obj);
47+
}
48+
49+
/**
50+
* Reads all JSON files in a directory and returns their contents
51+
*
52+
* @param {string} dir - Path to the directory containing JSON files
53+
* @returns {Record<string, any>} Object with filenames as keys and parsed JSON contents as values
54+
*/
55+
function readJSONFilesInDir(dir: string): Record<string, any> {
56+
try {
57+
const files = fs.readdirSync(dir);
58+
59+
return fromPairs(
60+
files
61+
.filter((file) => path.extname(file) === '.json')
62+
.map((file) => {
63+
const filePath = path.join(dir, file);
64+
65+
try {
66+
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
67+
68+
return [file, content];
69+
} catch (error) {
70+
logger.error(`Error reading ${filePath}`, error);
71+
72+
return [file, {}];
73+
}
74+
}),
75+
);
76+
} catch (error) {
77+
logger.error(`Error reading directory ${dir}`, error);
78+
79+
return {};
80+
}
81+
}
82+
83+
/**
84+
* Validates that all locale files have the same keys across different languages
85+
*
86+
* @returns {void}
87+
* @throws {Error} If validation fails or encounters an error
88+
*/
89+
function validateLocales(): void {
90+
try {
91+
const languages = fs
92+
.readdirSync(localesDir)
93+
.filter((file) => fs.statSync(path.join(localesDir, file)).isDirectory());
94+
95+
if (languages.length === 0) {
96+
const error = new Error(`No language directories found in ${localesDir}`);
97+
logger.error('No language directories found:', error);
98+
throw error;
99+
}
100+
101+
logger.info(`Found ${languages.length} languages: ${languages.join(', ')}`);
102+
103+
const languageFiles: Record<string, Record<string, any>> = {};
104+
const fileKeys: Record<string, Record<string, string[]>> = {};
105+
106+
for (const lang of languages) {
107+
const langDir = path.join(localesDir, lang);
108+
languageFiles[lang] = readJSONFilesInDir(langDir);
109+
fileKeys[lang] = fromPairs(
110+
Object.entries(languageFiles[lang]).map(([file, content]) => [
111+
file,
112+
extractKeys(content),
113+
]),
114+
);
115+
}
116+
117+
const allFiles = uniq(
118+
flatten(Object.values(languageFiles).map((files) => Object.keys(files))),
119+
);
120+
121+
let hasErrors = false;
122+
123+
for (const file of allFiles) {
124+
const langsWithFile = languages.filter(
125+
(lang) => languageFiles[lang][file],
126+
);
127+
128+
if (langsWithFile.length <= 1) {
129+
logger.info(
130+
`Skipping '${file}' (only found in ${langsWithFile.length} language)`,
131+
);
132+
continue;
133+
}
134+
135+
const allKeysAcrossLanguages = uniq(
136+
flatten(langsWithFile.map((lang) => fileKeys[lang][file])),
137+
);
138+
139+
const missingKeysByLang = fromPairs(
140+
langsWithFile.map((lang) => {
141+
const langKeysSet = new Set(fileKeys[lang][file]);
142+
const missingKeys = allKeysAcrossLanguages.filter(
143+
(key) => !langKeysSet.has(key),
144+
);
145+
146+
return [lang, missingKeys];
147+
}),
148+
);
149+
150+
const langsWithMissingKeys = Object.entries(missingKeysByLang).filter(
151+
([, missing]) => missing.length > 0,
152+
);
153+
154+
if (langsWithMissingKeys.length > 0) {
155+
logger.info(`\nMissing keys in '${file}':`);
156+
langsWithMissingKeys.forEach(([lang, missing]) => {
157+
logger.error(
158+
`❌ Language '${lang}' is missing these keys: ${missing.join(', ')}`,
159+
);
160+
hasErrors = true;
161+
});
162+
}
163+
}
164+
165+
if (hasErrors) {
166+
const error = new Error(
167+
'\n❌ Some translation keys are missing. Please fix the issues above.',
168+
);
169+
logger.error('Translation validation failed:', error);
170+
throw error;
171+
}
172+
173+
logger.info('✅ All locale files have the same keys across all languages!');
174+
} catch (error) {
175+
logger.error('Error validating locales:', error);
176+
throw error;
177+
}
178+
}
179+
180+
export { validateLocales, extractKeys, readJSONFilesInDir };
181+
182+
/* istanbul ignore next */
183+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
184+
validateLocales();
185+
}

0 commit comments

Comments
 (0)