-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
fix(dashboard): enforce locale-specific plural forms in translations #13268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 16 commits
3b28a03
254651f
047a69a
ee112d7
9722e2a
0d322f9
ad108ec
1d63878
c4405bd
3812d0f
ec2123c
2c19546
2796aa2
60f420b
711ebc2
a408f38
1750e08
ed659d6
b65eb07
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@medusajs/dashboard": patch | ||
| --- | ||
|
|
||
| fix(dashboard): enforce locale-specific plural forms in translations |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,17 @@ const translationsDir = path.join(__dirname, "../../src/i18n/translations") | |
| const enPath = path.join(translationsDir, "en.json") | ||
| const schemaPath = path.join(translationsDir, "$schema.json") | ||
|
|
||
| const ALL_PLURAL_FORMS = ["zero", "one", "two", "few", "many", "other"] | ||
|
|
||
| function getBaseKey(key) { | ||
| for (const form of ALL_PLURAL_FORMS) { | ||
| if (key.endsWith(`_${form}`)) { | ||
| return key.slice(0, -(form.length + 1)) | ||
| } | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| function generateSchemaFromObject(obj) { | ||
| if (typeof obj !== "object" || obj === null) { | ||
| return { type: typeof obj } | ||
|
|
@@ -20,12 +31,28 @@ function generateSchemaFromObject(obj) { | |
|
|
||
| const properties = {} | ||
| const required = [] | ||
| const localPluralBaseKeys = new Set() | ||
|
|
||
| Object.keys(obj).forEach((key) => { | ||
| const baseKey = getBaseKey(key) | ||
| if (baseKey) { | ||
| localPluralBaseKeys.add(baseKey) | ||
| } | ||
| }) | ||
|
|
||
| Object.entries(obj).forEach(([key, value]) => { | ||
| if (getBaseKey(key)) return | ||
|
|
||
radeknapora marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| properties[key] = generateSchemaFromObject(value) | ||
| required.push(key) | ||
| }) | ||
|
Comment on lines
+36
to
48
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. q: why aren't we doing this in a single loop? |
||
|
|
||
| localPluralBaseKeys.forEach((baseKey) => { | ||
| ALL_PLURAL_FORMS.forEach((form) => { | ||
| properties[`${baseKey}_${form}`] = { type: "string" } | ||
| }) | ||
radeknapora marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }) | ||
|
Comment on lines
+50
to
+54
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: similarly, I think this can be part of the same loop. |
||
|
|
||
| return { | ||
| type: "object", | ||
| properties, | ||
|
|
@@ -34,7 +61,7 @@ function generateSchemaFromObject(obj) { | |
| } | ||
| } | ||
|
|
||
| async function outputSchema() { | ||
| async function main() { | ||
| const enContent = await fs.readFile(enPath, "utf-8") | ||
| const enJson = JSON.parse(enContent) | ||
|
|
||
|
|
@@ -61,4 +88,4 @@ async function outputSchema() { | |
| }) | ||
| } | ||
|
|
||
| outputSchema() | ||
| main() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,47 +1,135 @@ | ||
| const Ajv = require("ajv") | ||
| const fs = require("fs") | ||
| const path = require("path") | ||
|
|
||
| const schema = require("../../src/i18n/translations/$schema.json") | ||
| const pluralConfig = require("../../src/i18n/plural-config.json") | ||
|
|
||
| const ajv = new Ajv({ allErrors: true }) | ||
| const validate = ajv.compile(schema) | ||
| const ajv = new Ajv({ allErrors: true, strict: false }) | ||
| const validateSchema = ajv.compile(schema) | ||
|
|
||
| // Get file name from command line arguments | ||
| const fileName = process.argv[2] | ||
|
|
||
| if (!fileName) { | ||
| console.error("Please provide a file name (e.g., en.json) as an argument.") | ||
| process.exit(1) | ||
| } | ||
|
|
||
| const langCode = fileName.replace(".json", "") | ||
| const requiredForms = pluralConfig[langCode] | ||
|
|
||
| if (!requiredForms) { | ||
| console.error(`Language "${langCode}" not found in plural-config.json`) | ||
radeknapora marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| process.exit(1) | ||
| } | ||
|
|
||
| const filePath = path.join(__dirname, "../../src/i18n/translations", fileName) | ||
| const translations = JSON.parse(fs.readFileSync(filePath, "utf-8")) | ||
|
|
||
| try { | ||
| const translations = JSON.parse(fs.readFileSync(filePath, "utf-8")) | ||
|
|
||
| if (!validate(translations)) { | ||
| console.error(`\nValidation failed for ${fileName}:`) | ||
| validate.errors?.forEach((error) => { | ||
| if (error.keyword === "required") { | ||
| const missingKeys = error.params.missingProperty | ||
| console.error( | ||
| ` Missing required key: "${missingKeys}" at ${error.instancePath}` | ||
| ) | ||
| } else if (error.keyword === "additionalProperties") { | ||
| const extraKey = error.params.additionalProperty | ||
| console.error( | ||
| ` Unexpected key: "${extraKey}" at ${error.instancePath}` | ||
| ) | ||
| } else { | ||
| console.error(` Error: ${error.message} at ${error.instancePath}`) | ||
| } | ||
| }) | ||
| process.exit(1) | ||
| } else { | ||
| console.log(`${fileName} matches the schema.`) | ||
| process.exit(0) | ||
| function findAllPluralKeys(obj, currentPath = "") { | ||
| const pluralKeys = [] | ||
|
|
||
| if (typeof obj !== "object" || obj === null || Array.isArray(obj)) { | ||
| return pluralKeys | ||
| } | ||
| } catch (error) { | ||
| console.error(`Error reading or parsing file: ${error.message}`) | ||
|
|
||
| Object.entries(obj).forEach(([key, value]) => { | ||
| if (/_(?:zero|one|two|few|many|other)$/.test(key)) { | ||
| pluralKeys.push({ path: currentPath, key }) | ||
| } | ||
|
|
||
| if (typeof value === "object" && value !== null && !Array.isArray(value)) { | ||
| const fullPath = currentPath ? `${currentPath}.${key}` : key | ||
| pluralKeys.push(...findAllPluralKeys(value, fullPath)) | ||
| } | ||
radeknapora marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }) | ||
|
|
||
| return pluralKeys | ||
| } | ||
|
|
||
| function getNestedValue(obj, path) { | ||
| if (!path) return obj | ||
| return path.split(".").reduce((current, key) => current?.[key], obj) | ||
| } | ||
|
|
||
| const schemaValid = validateSchema(translations) | ||
|
|
||
| const errors = [] | ||
| const allPluralKeys = findAllPluralKeys(translations) | ||
| const pluralGroups = new Map() | ||
|
|
||
| allPluralKeys.forEach(({ path, key }) => { | ||
| const baseKey = key.replace(/_(?:zero|one|two|few|many|other)$/, "") | ||
| const groupKey = path ? `${path}.${baseKey}` : baseKey | ||
|
|
||
| if (!pluralGroups.has(groupKey)) { | ||
| pluralGroups.set(groupKey, { path, baseKey }) | ||
| } | ||
| }) | ||
|
|
||
| pluralGroups.forEach(({ path, baseKey }, groupKey) => { | ||
| const parent = getNestedValue(translations, path) | ||
| const instancePath = path ? `/${path.replace(/\./g, "/")}` : "" | ||
|
|
||
| requiredForms.forEach((form) => { | ||
| const key = `${baseKey}_${form}` | ||
| if (!parent?.[key]) { | ||
| errors.push({ | ||
| type: "missing", | ||
| instancePath, | ||
| key, | ||
| }) | ||
| } | ||
| }) | ||
|
|
||
| const allForms = ["zero", "one", "two", "few", "many", "other"] | ||
|
|
||
| allForms.forEach((form) => { | ||
| const key = `${baseKey}_${form}` | ||
| if (parent?.[key] && !requiredForms.includes(form)) { | ||
| errors.push({ | ||
| type: "extra", | ||
| instancePath, | ||
| key, | ||
| form, | ||
| }) | ||
| } | ||
| }) | ||
| }) | ||
|
|
||
| if (!schemaValid) { | ||
| console.error(`\nSchema validation failed for ${fileName}:`) | ||
| validateSchema.errors?.forEach((error) => { | ||
| const location = error.instancePath || "root" | ||
| if (error.keyword === "required") { | ||
| const missingKey = error.params.missingProperty | ||
| console.error(` Missing required key: "${missingKey}" at ${location}`) | ||
| } else if (error.keyword === "additionalProperties") { | ||
| const extraKey = error.params.additionalProperty | ||
| console.error(` Unexpected key: "${extraKey}" at ${location}`) | ||
| } else { | ||
| console.error(` Error: ${error.message} at ${location}`) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| if (errors.length > 0) { | ||
| console.error( | ||
| `\nPlural validation failed for ${fileName} [${requiredForms.join(", ")}]:` | ||
| ) | ||
|
|
||
| errors.forEach(({ type, instancePath, key }) => { | ||
| const location = instancePath || "root" | ||
| if (type === "missing") { | ||
| console.error(` Missing required plural key: "${key}" at ${location}`) | ||
| } else { | ||
| console.error(` Unexpected plural key: "${key}" at ${location}`) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| if (!schemaValid || errors.length > 0) { | ||
| process.exit(1) | ||
| } | ||
|
|
||
| console.log(`\n✓ ${fileName} is valid and matches the schema.`) | ||
| process.exit(0) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| { | ||
| "ar": ["zero", "one", "two", "few", "many", "other"], | ||
| "bg": ["one", "other"], | ||
| "bs": ["one", "few", "other"], | ||
| "cs": ["one", "few", "many", "other"], | ||
| "da": ["one", "other"], | ||
| "de": ["one", "other"], | ||
| "en": ["one", "other"], | ||
| "es": ["one", "other"], | ||
| "fa": ["one", "other"], | ||
| "fi": ["one", "other"], | ||
| "fr": ["one", "other"], | ||
| "he": ["one", "two", "many", "other"], | ||
| "hr": ["one", "few", "other"], | ||
| "hu": ["one", "other"], | ||
| "it": ["one", "other"], | ||
| "ja": ["other"], | ||
| "ko": ["other"], | ||
| "nl": ["one", "other"], | ||
| "no": ["one", "other"], | ||
| "pl": ["one", "few", "many", "other"], | ||
| "pt": ["one", "other"], | ||
| "ptBR": ["one", "other"], | ||
| "ptPT": ["one", "other"], | ||
| "ru": ["one", "few", "many", "other"], | ||
| "sk": ["one", "few", "many", "other"], | ||
| "sv": ["one", "other"], | ||
| "th": ["other"], | ||
| "tr": ["one", "other"], | ||
| "uk": ["one", "few", "many", "other"], | ||
| "vi": ["other"], | ||
| "zh": ["other"] | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Missing language entries in plural configurationThe
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add newly added languages so that the validation doesn't fail. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.