-
Notifications
You must be signed in to change notification settings - Fork 10.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor(i18n): Translation's lint and load (#31343)
- Loading branch information
Showing
43 changed files
with
515 additions
and
318 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,249 @@ | ||
import type { PathLike } from 'node:fs'; | ||
import { readFile, writeFile } from 'node:fs/promises'; | ||
import { join } from 'node:path'; | ||
import { inspect } from 'node:util'; | ||
|
||
import fg from 'fast-glob'; | ||
import i18next from 'i18next'; | ||
import supportsColor from 'supports-color'; | ||
|
||
const hasDuplicatedKeys = (content: string, json: Record<string, string>) => { | ||
const matchKeys = content.matchAll(/^\s+"([^"]+)"/gm); | ||
|
||
const allKeys = [...matchKeys]; | ||
|
||
return allKeys.length !== Object.keys(json).length; | ||
}; | ||
|
||
const parseFile = async (path: PathLike) => { | ||
const content = await readFile(path, 'utf-8'); | ||
let json: Record<string, string>; | ||
try { | ||
json = JSON.parse(content); | ||
} catch (e) { | ||
if (e instanceof SyntaxError) { | ||
const matches = /^Unexpected token .* in JSON at position (\d+)$/.exec(e.message); | ||
|
||
if (matches) { | ||
const [, positionStr] = matches; | ||
const position = parseInt(positionStr, 10); | ||
const line = content.slice(0, position).split('\n').length; | ||
const column = position - content.slice(0, position).lastIndexOf('\n'); | ||
throw new SyntaxError(`Invalid JSON on file ${path}:${line}:${column}`); | ||
} | ||
} | ||
throw new SyntaxError(`Invalid JSON on file ${path}: ${e.message}`); | ||
} | ||
|
||
if (hasDuplicatedKeys(content, json)) { | ||
throw new SyntaxError(`Duplicated keys found on file ${path}`); | ||
} | ||
|
||
return json; | ||
}; | ||
|
||
const insertTranslation = (json: Record<string, string>, refKey: string, [key, value]: [key: string, value: string]) => { | ||
const entries = Object.entries(json); | ||
|
||
const refIndex = entries.findIndex(([entryKey]) => entryKey === refKey); | ||
|
||
if (refIndex === -1) { | ||
throw new Error(`Reference key ${refKey} not found`); | ||
} | ||
|
||
const movingEntries = entries.slice(refIndex + 1); | ||
|
||
for (const [key] of movingEntries) { | ||
delete json[key]; | ||
} | ||
|
||
json[key] = value; | ||
|
||
for (const [key, value] of movingEntries) { | ||
json[key] = value; | ||
} | ||
}; | ||
|
||
const persistFile = async (path: PathLike, json: Record<string, string>) => { | ||
const content = JSON.stringify(json, null, 2); | ||
|
||
await writeFile(path, content, 'utf-8'); | ||
}; | ||
|
||
const oldPlaceholderFormat = /__([a-zA-Z_]+)__/g; | ||
|
||
const checkPlaceholdersFormat = async ({ json, path, fix = false }: { json: Record<string, string>; path: PathLike; fix?: boolean }) => { | ||
const outdatedKeys = Object.entries(json) | ||
.map(([key, value]) => ({ | ||
key, | ||
value, | ||
placeholders: value.match(oldPlaceholderFormat), | ||
})) | ||
.filter((outdatedKey): outdatedKey is { key: string; value: string; placeholders: RegExpMatchArray } => !!outdatedKey.placeholders); | ||
|
||
if (outdatedKeys.length > 0) { | ||
const message = `Outdated placeholder format on file ${path}: ${inspect(outdatedKeys, { colors: !!supportsColor.stdout })}`; | ||
|
||
if (fix) { | ||
console.warn(message); | ||
|
||
for (const { key, value } of outdatedKeys) { | ||
const newValue = value.replace(oldPlaceholderFormat, (_, name) => `{{${name}}}`); | ||
|
||
json[key] = newValue; | ||
} | ||
|
||
await persistFile(path, json); | ||
|
||
return; | ||
} | ||
|
||
throw new Error(message); | ||
} | ||
}; | ||
|
||
export const extractSingularKeys = (json: Record<string, string>, lng: string) => { | ||
if (!i18next.isInitialized) { | ||
i18next.init({ initImmediate: false }); | ||
} | ||
|
||
const pluralSuffixes = i18next.services.pluralResolver.getSuffixes(lng) as string[]; | ||
|
||
const singularKeys = new Set( | ||
Object.keys(json).map((key) => { | ||
for (const pluralSuffix of pluralSuffixes) { | ||
if (key.endsWith(pluralSuffix)) { | ||
return key.slice(0, -pluralSuffix.length); | ||
} | ||
} | ||
|
||
return key; | ||
}), | ||
); | ||
|
||
return [singularKeys, pluralSuffixes] as const; | ||
}; | ||
|
||
const checkMissingPlurals = async ({ | ||
json, | ||
path, | ||
lng, | ||
fix = false, | ||
}: { | ||
json: Record<string, string>; | ||
path: PathLike; | ||
lng: string; | ||
fix?: boolean; | ||
}) => { | ||
const [singularKeys, pluralSuffixes] = extractSingularKeys(json, lng); | ||
|
||
const missingPluralKeys: { singularKey: string; existing: string[]; missing: string[] }[] = []; | ||
|
||
for (const singularKey of singularKeys) { | ||
if (singularKey in json) { | ||
continue; | ||
} | ||
|
||
const pluralKeys = pluralSuffixes.map((suffix) => `${singularKey}${suffix}`); | ||
|
||
const existing = pluralKeys.filter((key) => key in json); | ||
const missing = pluralKeys.filter((key) => !(key in json)); | ||
|
||
if (missing.length > 0) { | ||
missingPluralKeys.push({ singularKey, existing, missing }); | ||
} | ||
} | ||
|
||
if (missingPluralKeys.length > 0) { | ||
const message = `Missing plural keys on file ${path}: ${inspect(missingPluralKeys, { colors: !!supportsColor.stdout })}`; | ||
|
||
if (fix) { | ||
console.warn(message); | ||
|
||
for (const { existing, missing } of missingPluralKeys) { | ||
for (const missingKey of missing) { | ||
const refKey = existing.slice(-1)[0]; | ||
const value = json[refKey]; | ||
insertTranslation(json, refKey, [missingKey, value]); | ||
} | ||
} | ||
|
||
await persistFile(path, json); | ||
|
||
return; | ||
} | ||
|
||
throw new Error(message); | ||
} | ||
}; | ||
|
||
const checkExceedingKeys = async ({ | ||
json, | ||
path, | ||
lng, | ||
sourceJson, | ||
sourceLng, | ||
fix = false, | ||
}: { | ||
json: Record<string, string>; | ||
path: PathLike; | ||
lng: string; | ||
sourceJson: Record<string, string>; | ||
sourceLng: string; | ||
fix?: boolean; | ||
}) => { | ||
const [singularKeys] = extractSingularKeys(json, lng); | ||
const [sourceSingularKeys] = extractSingularKeys(sourceJson, sourceLng); | ||
|
||
const exceedingKeys = [...singularKeys].filter((key) => !sourceSingularKeys.has(key)); | ||
|
||
if (exceedingKeys.length > 0) { | ||
const message = `Exceeding keys on file ${path}: ${inspect(exceedingKeys, { colors: !!supportsColor.stdout })}`; | ||
|
||
if (fix) { | ||
for (const key of exceedingKeys) { | ||
delete json[key]; | ||
} | ||
|
||
await persistFile(path, json); | ||
|
||
return; | ||
} | ||
|
||
throw new Error(message); | ||
} | ||
}; | ||
|
||
const checkFiles = async (sourceDirPath: string, sourceLng: string, fix = false) => { | ||
const sourcePath = join(sourceDirPath, `${sourceLng}.i18n.json`); | ||
const sourceJson = await parseFile(sourcePath); | ||
|
||
await checkPlaceholdersFormat({ json: sourceJson, path: sourcePath, fix }); | ||
await checkMissingPlurals({ json: sourceJson, path: sourcePath, lng: sourceLng, fix }); | ||
|
||
const i18nFiles = await fg([join(sourceDirPath, `**/*.i18n.json`), `!${sourcePath}`]); | ||
|
||
const languageFileRegex = /\/([^\/]*?).i18n.json$/; | ||
const translations = await Promise.all( | ||
i18nFiles.map(async (path) => { | ||
const lng = languageFileRegex.exec(path)?.[1]; | ||
if (!lng) { | ||
throw new Error(`Invalid language file path ${path}`); | ||
} | ||
|
||
return { path, json: await parseFile(path), lng }; | ||
}), | ||
); | ||
|
||
for await (const { path, json, lng } of translations) { | ||
await checkPlaceholdersFormat({ json, path, fix }); | ||
await checkMissingPlurals({ json, path, lng, fix }); | ||
await checkExceedingKeys({ json, path, lng, sourceJson, sourceLng, fix }); | ||
} | ||
}; | ||
|
||
const fix = process.argv[2] === '--fix'; | ||
checkFiles('./packages/rocketchat-i18n/i18n', 'en', fix).catch((e) => { | ||
console.error(e); | ||
process.exit(1); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.