Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3b28a03
fix(schema): preserve plural forms (few/many) in Polish and similar l…
radeknapora Aug 21, 2025
254651f
Merge branch 'develop' into fix(dashboard)/pluralization-schema-pl
willbouch Aug 22, 2025
047a69a
Add changeset
radeknapora Aug 22, 2025
ee112d7
Fix changeset
radeknapora Aug 22, 2025
9722e2a
Merge branch 'develop' into fix(dashboard)/pluralization-schema-pl
willbouch Sep 11, 2025
0d322f9
Merge branch 'develop' into fix(dashboard)/pluralization-schema-pl
willbouch Sep 17, 2025
ad108ec
Merge branch 'develop' into fix(dashboard)/pluralization-schema-pl
willbouch Sep 18, 2025
1d63878
Merge branch 'develop' into fix(dashboard)/pluralization-schema-pl
radeknapora Oct 8, 2025
c4405bd
WIP: Proposed solution for CLDR-compliant i18n pluralization
radeknapora Oct 8, 2025
3812d0f
Merge branch 'develop' into fix(dashboard)/pluralization-schema-pl
radeknapora Oct 17, 2025
ec2123c
feat(i18n): implement CLDR-compliant plural validation with language-…
radeknapora Oct 19, 2025
2c19546
Merge branch 'develop' into fix(dashboard)/pluralization-schema-pl
radeknapora Oct 19, 2025
2796aa2
Merge branch 'develop' into fix(dashboard)/pluralization-schema-pl
radeknapora Nov 5, 2025
60f420b
Update plural config and translation schema
radeknapora Nov 5, 2025
711ebc2
Merge branch 'develop' into fix(dashboard)/pluralization-schema-pl
radeknapora Nov 11, 2025
a408f38
Update changeset
radeknapora Nov 11, 2025
1750e08
Merge branch 'develop' into fix(dashboard)/pluralization-schema-pl
radeknapora Nov 11, 2025
ed659d6
Merge branch 'develop' into fix(dashboard)/pluralization-schema-pl
radeknapora Nov 17, 2025
b65eb07
Merge branch 'develop' into fix(dashboard)/pluralization-schema-pl
radeknapora Nov 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chatty-kids-count.md
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
31 changes: 29 additions & 2 deletions packages/admin/dashboard/scripts/i18n/generate-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -20,12 +31,28 @@ function generateSchemaFromObject(obj) {

const properties = {}
const required = []
const localPluralBaseKeys = new Set()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const localPluralBaseKeys = new Set()
const localePluralBaseKeys = 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

properties[key] = generateSchemaFromObject(value)
required.push(key)
})
Comment on lines +36 to 48
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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" }
})
})
Comment on lines +50 to +54
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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,
Expand All @@ -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)

Expand All @@ -61,4 +88,4 @@ async function outputSchema() {
})
}

outputSchema()
main()
148 changes: 118 additions & 30 deletions packages/admin/dashboard/scripts/i18n/validate-translation.js
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`)
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))
}
})

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)
33 changes: 33 additions & 0 deletions packages/admin/dashboard/src/i18n/plural-config.json
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"]
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Missing language entries in plural configuration

The plural-config.json file is missing entries for several languages that have translation files: el, ro, mk, mn, zhCN, lt, and id. The validation script at scripts/i18n/validate-translation.js requires every language to have an entry in plural-config.json and exits with an error if not found (line 20-23). This means validation will fail for these seven languages, preventing their translation files from being validated or used.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add newly added languages so that the validation doesn't fail.

Loading
Loading