chore(dashboard): Setup test and script for validating i18n (#9799)

**What**
- Adds a test that validates that en.json and $schema.json matches, this will help us catch missing translations, and prevent us from forgetting to update the schema when we delete/add new keys.
- Adds a script for validating translations against the translation schema. This can be used to validate that community PRs for i18n contain all the required keys. To use the script you can run `yarn i18n:validate <file name>` e.g. `yarn i18n:validate da.json` which will look for a da.json file in the translation folder, and validate it against the schema. We handle this with a script as we don't want to do so through a test. Doing it with a test would mean that if we update the schema, we would also have to update all translations files that we don't maintain ourselves. The purpose of the script is just to allow us to easily review community PRs and also as a tool for people opening PR's to check their translations agains the schema, as it will print missing/additional keys.
- Also adds a script to generate a schema from the en.json file. After adding/deleting keys to en.json you should run `yarn i18n:generate`.
This commit is contained in:
Kasper Fabricius Kristensen
2024-11-04 10:45:07 +01:00
committed by GitHub
parent 300ef8dbb9
commit e2058683f4
7 changed files with 11665 additions and 143 deletions

View File

@@ -7,6 +7,9 @@
"build": "tsup && node ./scripts/generate-types.js", "build": "tsup && node ./scripts/generate-types.js",
"build:preview": "vite build", "build:preview": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest --run",
"i18n:validate": "node ./scripts/i18n/validate-translation.js",
"i18n:schema": "node ./scripts/i18n/generate-schema.js",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
}, },
"main": "dist/app.js", "main": "dist/app.js",
@@ -80,6 +83,7 @@
"@types/react": "^18.2.79", "@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25", "@types/react-dom": "^18.2.25",
"@vitejs/plugin-react": "4.2.1", "@vitejs/plugin-react": "4.2.1",
"ajv": "^8.17.1",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"postcss": "^8.4.33", "postcss": "^8.4.33",
"prettier": "^3.1.1", "prettier": "^3.1.1",
@@ -87,7 +91,8 @@
"tsup": "^8.0.2", "tsup": "^8.0.2",
"typescript": "5.2.2", "typescript": "5.2.2",
"vite": "^5.2.11", "vite": "^5.2.11",
"vite-plugin-inspect": "^0.8.7" "vite-plugin-inspect": "^0.8.7",
"vitest": "^2.1.4"
}, },
"packageManager": "yarn@3.2.1" "packageManager": "yarn@3.2.1"
} }

View File

@@ -0,0 +1,49 @@
const fs = require("fs")
const path = require("path")
const translationsDir = path.join(__dirname, "../../src/i18n/translations")
const enPath = path.join(translationsDir, "en.json")
const schemaPath = path.join(translationsDir, "$schema.json")
function generateSchemaFromObject(obj) {
if (typeof obj !== "object" || obj === null) {
return { type: typeof obj }
}
if (Array.isArray(obj)) {
return {
type: "array",
items: generateSchemaFromObject(obj[0] || "string"),
}
}
const properties = {}
const required = []
Object.entries(obj).forEach(([key, value]) => {
properties[key] = generateSchemaFromObject(value)
required.push(key)
})
return {
type: "object",
properties,
required,
additionalProperties: false,
}
}
try {
const enJson = JSON.parse(fs.readFileSync(enPath, "utf-8"))
const schema = {
$schema: "http://json-schema.org/draft-07/schema#",
...generateSchemaFromObject(enJson),
}
fs.writeFileSync(schemaPath, JSON.stringify(schema, null, 2))
console.log("Schema generated successfully at:", schemaPath)
} catch (error) {
console.error("Error generating schema:", error.message)
process.exit(1)
}

View File

@@ -0,0 +1,47 @@
const Ajv = require("ajv")
const fs = require("fs")
const path = require("path")
const schema = require("../../src/i18n/translations/$schema.json")
const ajv = new Ajv({ allErrors: true })
const validate = 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 filePath = path.join(__dirname, "../../src/i18n/translations", fileName)
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)
}
} catch (error) {
console.error(`Error reading or parsing file: ${error.message}`)
process.exit(1)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
import fs from "fs"
import path from "path"
import { describe, expect, test } from "vitest"
import schema from "../$schema.json"
const translationsDir = path.join(__dirname, "..")
function getRequiredKeysFromSchema(schema: any, prefix = ""): string[] {
const keys: string[] = []
if (schema.type === "object" && schema.properties) {
Object.entries(schema.properties).forEach(([key, value]: [string, any]) => {
const newPrefix = prefix ? `${prefix}.${key}` : key
if (value.type === "object") {
keys.push(...getRequiredKeysFromSchema(value, newPrefix))
} else {
keys.push(newPrefix)
}
})
}
return keys.sort()
}
function getTranslationKeys(obj: any, prefix = ""): string[] {
const keys: string[] = []
Object.entries(obj).forEach(([key, value]) => {
const newPrefix = prefix ? `${prefix}.${key}` : key
if (value && typeof value === "object") {
keys.push(...getTranslationKeys(value, newPrefix))
} else {
keys.push(newPrefix)
}
})
return keys.sort()
}
describe("translation schema validation", () => {
test("en.json should have all keys defined in schema", () => {
const enPath = path.join(translationsDir, "en.json")
const enTranslations = JSON.parse(fs.readFileSync(enPath, "utf-8"))
const schemaKeys = getRequiredKeysFromSchema(schema)
const translationKeys = getTranslationKeys(enTranslations)
const missingInTranslations = schemaKeys.filter(
(key) => !translationKeys.includes(key)
)
const extraInTranslations = translationKeys.filter(
(key) => !schemaKeys.includes(key)
)
if (missingInTranslations.length > 0) {
console.error("\nMissing keys in en.json:", missingInTranslations)
}
if (extraInTranslations.length > 0) {
console.error("\nExtra keys in en.json:", extraInTranslations)
}
expect(missingInTranslations).toEqual([])
expect(extraInTranslations).toEqual([])
})
})

View File

@@ -3,5 +3,6 @@
"compilerOptions": { "compilerOptions": {
"noImplicitAny": false, "noImplicitAny": false,
"composite": true "composite": true
} },
"exclude": ["**/*.spec.ts", "**/*.spec.tsx"]
} }

186
yarn.lock
View File

@@ -5612,6 +5612,7 @@ __metadata:
"@types/react-dom": ^18.2.25 "@types/react-dom": ^18.2.25
"@uiw/react-json-view": ^2.0.0-alpha.17 "@uiw/react-json-view": ^2.0.0-alpha.17
"@vitejs/plugin-react": 4.2.1 "@vitejs/plugin-react": 4.2.1
ajv: ^8.17.1
autoprefixer: ^10.4.17 autoprefixer: ^10.4.17
cmdk: ^0.2.0 cmdk: ^0.2.0
date-fns: ^3.6.0 date-fns: ^3.6.0
@@ -5638,6 +5639,7 @@ __metadata:
typescript: 5.2.2 typescript: 5.2.2
vite: ^5.2.11 vite: ^5.2.11
vite-plugin-inspect: ^0.8.7 vite-plugin-inspect: ^0.8.7
vitest: ^2.1.4
zod: 3.22.4 zod: 3.22.4
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -14440,6 +14442,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/expect@npm:2.1.4":
version: 2.1.4
resolution: "@vitest/expect@npm:2.1.4"
dependencies:
"@vitest/spy": 2.1.4
"@vitest/utils": 2.1.4
chai: ^5.1.2
tinyrainbow: ^1.2.0
checksum: cd20ec6f92479fe5d155221d7623cf506a84e10f537639c93b8a2ffba7314b65f0fcab3754ba31308a0381470fea2e3c53d283e5f5be2c592a69d7e817a85571
languageName: node
linkType: hard
"@vitest/mocker@npm:2.1.3": "@vitest/mocker@npm:2.1.3":
version: 2.1.3 version: 2.1.3
resolution: "@vitest/mocker@npm:2.1.3" resolution: "@vitest/mocker@npm:2.1.3"
@@ -14460,6 +14474,25 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/mocker@npm:2.1.4":
version: 2.1.4
resolution: "@vitest/mocker@npm:2.1.4"
dependencies:
"@vitest/spy": 2.1.4
estree-walker: ^3.0.3
magic-string: ^0.30.12
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
checksum: 3327ec34d05f25e17c0a083877e204a31ffc4150fb259e8f82191aa5328f456e81374b977e56db17c835bd29a7eaba249e011c21b27a52bf31fd4127104d4662
languageName: node
linkType: hard
"@vitest/pretty-format@npm:2.0.5": "@vitest/pretty-format@npm:2.0.5":
version: 2.0.5 version: 2.0.5
resolution: "@vitest/pretty-format@npm:2.0.5" resolution: "@vitest/pretty-format@npm:2.0.5"
@@ -14487,6 +14520,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/pretty-format@npm:2.1.4, @vitest/pretty-format@npm:^2.1.4":
version: 2.1.4
resolution: "@vitest/pretty-format@npm:2.1.4"
dependencies:
tinyrainbow: ^1.2.0
checksum: dc20f04f64c95731bf9640fc53ae918d928ab93e70a56d9e03f201700098cdb041b50a8f6a5f30604d4a048c15f315537453f33054e29590a05d5b368ae6849d
languageName: node
linkType: hard
"@vitest/runner@npm:0.32.4": "@vitest/runner@npm:0.32.4":
version: 0.32.4 version: 0.32.4
resolution: "@vitest/runner@npm:0.32.4" resolution: "@vitest/runner@npm:0.32.4"
@@ -14508,6 +14550,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/runner@npm:2.1.4":
version: 2.1.4
resolution: "@vitest/runner@npm:2.1.4"
dependencies:
"@vitest/utils": 2.1.4
pathe: ^1.1.2
checksum: be51bb7f63b6d524bed2b44bafa8022ac5019bc01a411497c8b607d13601dae40a592bad6b8e21096f02827bd256296354947525d038a2c04032fdaa9ca991f0
languageName: node
linkType: hard
"@vitest/snapshot@npm:0.32.4": "@vitest/snapshot@npm:0.32.4":
version: 0.32.4 version: 0.32.4
resolution: "@vitest/snapshot@npm:0.32.4" resolution: "@vitest/snapshot@npm:0.32.4"
@@ -14530,6 +14582,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/snapshot@npm:2.1.4":
version: 2.1.4
resolution: "@vitest/snapshot@npm:2.1.4"
dependencies:
"@vitest/pretty-format": 2.1.4
magic-string: ^0.30.12
pathe: ^1.1.2
checksum: 50e15398420870755e03d7d0cb7825642021e4974cb26760b8159f0c8273796732694b6a9a703a7cff88790ca4bb09f38bfc174396bcc7cbb93b96e5ac21d1d7
languageName: node
linkType: hard
"@vitest/spy@npm:0.32.4": "@vitest/spy@npm:0.32.4":
version: 0.32.4 version: 0.32.4
resolution: "@vitest/spy@npm:0.32.4" resolution: "@vitest/spy@npm:0.32.4"
@@ -14557,6 +14620,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/spy@npm:2.1.4":
version: 2.1.4
resolution: "@vitest/spy@npm:2.1.4"
dependencies:
tinyspy: ^3.0.2
checksum: a983efa140fa5211dc96a0c7c5110883c8095d00c45e711ecde1cc4a862560055b0e24907ae55970ab4a034e52265b7e8e70168f0da4b500b448d3d214eb045e
languageName: node
linkType: hard
"@vitest/utils@npm:0.32.4": "@vitest/utils@npm:0.32.4":
version: 0.32.4 version: 0.32.4
resolution: "@vitest/utils@npm:0.32.4" resolution: "@vitest/utils@npm:0.32.4"
@@ -14591,6 +14663,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/utils@npm:2.1.4":
version: 2.1.4
resolution: "@vitest/utils@npm:2.1.4"
dependencies:
"@vitest/pretty-format": 2.1.4
loupe: ^3.1.2
tinyrainbow: ^1.2.0
checksum: fd632dbc2496d14bcc609230f1dad73039c9f52f4ca533d6b68fa1a04dd448e03510f2a8e4a368fd274cbb8902a6cd800140ab366dd055256beb2c0dcafcd9f2
languageName: node
linkType: hard
"@vitest/utils@npm:^2.0.5": "@vitest/utils@npm:^2.0.5":
version: 2.1.2 version: 2.1.2
resolution: "@vitest/utils@npm:2.1.2" resolution: "@vitest/utils@npm:2.1.2"
@@ -14863,7 +14946,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ajv@npm:^8.12.0": "ajv@npm:^8.12.0, ajv@npm:^8.17.1":
version: 8.17.1 version: 8.17.1
resolution: "ajv@npm:8.17.1" resolution: "ajv@npm:8.17.1"
dependencies: dependencies:
@@ -16296,6 +16379,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"chai@npm:^5.1.2":
version: 5.1.2
resolution: "chai@npm:5.1.2"
dependencies:
assertion-error: ^2.0.1
check-error: ^2.1.1
deep-eql: ^5.0.1
loupe: ^3.1.0
pathval: ^2.0.0
checksum: 6c04ff8495b6e535df9c1b062b6b094828454e9a3c9493393e55b2f4dbff7aa2a29a4645133cad160fb00a16196c4dc03dc9bb37e1f4ba9df3b5f50d7533a736
languageName: node
linkType: hard
"chalk@npm:*, chalk@npm:^5.0.0, chalk@npm:^5.2.0": "chalk@npm:*, chalk@npm:^5.0.0, chalk@npm:^5.2.0":
version: 5.3.0 version: 5.3.0
resolution: "chalk@npm:5.3.0" resolution: "chalk@npm:5.3.0"
@@ -17872,7 +17968,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"debug@npm:^4.3.5, debug@npm:^4.3.6": "debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.3.7":
version: 4.3.7 version: 4.3.7
resolution: "debug@npm:4.3.7" resolution: "debug@npm:4.3.7"
dependencies: dependencies:
@@ -19906,6 +20002,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"expect-type@npm:^1.1.0":
version: 1.1.0
resolution: "expect-type@npm:1.1.0"
checksum: 5af0febbe8fe18da05a6d51e3677adafd75213512285408156b368ca471252565d5ca6e59e4bddab25121f3cfcbbebc6a5489f8cc9db131cc29e69dcdcc7ae15
languageName: node
linkType: hard
"expect@npm:^29.0.0, expect@npm:^29.7.0": "expect@npm:^29.0.0, expect@npm:^29.7.0":
version: 29.7.0 version: 29.7.0
resolution: "expect@npm:29.7.0" resolution: "expect@npm:29.7.0"
@@ -24289,7 +24392,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"loupe@npm:^3.1.0, loupe@npm:^3.1.1": "loupe@npm:^3.1.0, loupe@npm:^3.1.1, loupe@npm:^3.1.2":
version: 3.1.2 version: 3.1.2
resolution: "loupe@npm:3.1.2" resolution: "loupe@npm:3.1.2"
checksum: b13c02e3ddd6a9d5f8bf84133b3242de556512d824dddeea71cce2dbd6579c8f4d672381c4e742d45cf4423d0701765b4a6e5fbc24701def16bc2b40f8daa96a checksum: b13c02e3ddd6a9d5f8bf84133b3242de556512d824dddeea71cce2dbd6579c8f4d672381c4e742d45cf4423d0701765b4a6e5fbc24701def16bc2b40f8daa96a
@@ -24438,7 +24541,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"magic-string@npm:^0.30.11": "magic-string@npm:^0.30.11, magic-string@npm:^0.30.12":
version: 0.30.12 version: 0.30.12
resolution: "magic-string@npm:0.30.12" resolution: "magic-string@npm:0.30.12"
dependencies: dependencies:
@@ -31201,6 +31304,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tinyexec@npm:^0.3.1":
version: 0.3.1
resolution: "tinyexec@npm:0.3.1"
checksum: 11e7a7c5d8b3bddf8b5cbe82a9290d70a6fad84d528421d5d18297f165723cb53d2e737d8f58dcce5ca56f2e4aa2d060f02510b1f8971784f97eb3e9aec28f09
languageName: node
linkType: hard
"tinypool@npm:^0.5.0": "tinypool@npm:^0.5.0":
version: 0.5.0 version: 0.5.0
resolution: "tinypool@npm:0.5.0" resolution: "tinypool@npm:0.5.0"
@@ -31208,7 +31318,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tinypool@npm:^1.0.0": "tinypool@npm:^1.0.0, tinypool@npm:^1.0.1":
version: 1.0.1 version: 1.0.1
resolution: "tinypool@npm:1.0.1" resolution: "tinypool@npm:1.0.1"
checksum: 90939d6a03f1519c61007bf416632dc1f0b9c1a9dd673c179ccd9e36a408437384f984fc86555a5d040d45b595abc299c3bb39d354439e98a090766b5952e73d checksum: 90939d6a03f1519c61007bf416632dc1f0b9c1a9dd673c179ccd9e36a408437384f984fc86555a5d040d45b595abc299c3bb39d354439e98a090766b5952e73d
@@ -31229,7 +31339,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tinyspy@npm:^3.0.0": "tinyspy@npm:^3.0.0, tinyspy@npm:^3.0.2":
version: 3.0.2 version: 3.0.2
resolution: "tinyspy@npm:3.0.2" resolution: "tinyspy@npm:3.0.2"
checksum: 55ffad24e346622b59292e097c2ee30a63919d5acb7ceca87fc0d1c223090089890587b426e20054733f97a58f20af2c349fb7cc193697203868ab7ba00bcea0 checksum: 55ffad24e346622b59292e097c2ee30a63919d5acb7ceca87fc0d1c223090089890587b426e20054733f97a58f20af2c349fb7cc193697203868ab7ba00bcea0
@@ -32776,6 +32886,20 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"vite-node@npm:2.1.4":
version: 2.1.4
resolution: "vite-node@npm:2.1.4"
dependencies:
cac: ^6.7.14
debug: ^4.3.7
pathe: ^1.1.2
vite: ^5.0.0
bin:
vite-node: vite-node.mjs
checksum: 4c09128f27ded3f681d2c034f0bb74856cef9cad9c437951bc7f95dab92fc95a5d1ee7f54e32067458ad1105e1f24975e8bc64aa7ed8f5b33449b4f5fea65919
languageName: node
linkType: hard
"vite-plugin-inspect@npm:^0.8.7": "vite-plugin-inspect@npm:^0.8.7":
version: 0.8.7 version: 0.8.7
resolution: "vite-plugin-inspect@npm:0.8.7" resolution: "vite-plugin-inspect@npm:0.8.7"
@@ -33037,6 +33161,56 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"vitest@npm:^2.1.4":
version: 2.1.4
resolution: "vitest@npm:2.1.4"
dependencies:
"@vitest/expect": 2.1.4
"@vitest/mocker": 2.1.4
"@vitest/pretty-format": ^2.1.4
"@vitest/runner": 2.1.4
"@vitest/snapshot": 2.1.4
"@vitest/spy": 2.1.4
"@vitest/utils": 2.1.4
chai: ^5.1.2
debug: ^4.3.7
expect-type: ^1.1.0
magic-string: ^0.30.12
pathe: ^1.1.2
std-env: ^3.7.0
tinybench: ^2.9.0
tinyexec: ^0.3.1
tinypool: ^1.0.1
tinyrainbow: ^1.2.0
vite: ^5.0.0
vite-node: 2.1.4
why-is-node-running: ^2.3.0
peerDependencies:
"@edge-runtime/vm": "*"
"@types/node": ^18.0.0 || >=20.0.0
"@vitest/browser": 2.1.4
"@vitest/ui": 2.1.4
happy-dom: "*"
jsdom: "*"
peerDependenciesMeta:
"@edge-runtime/vm":
optional: true
"@types/node":
optional: true
"@vitest/browser":
optional: true
"@vitest/ui":
optional: true
happy-dom:
optional: true
jsdom:
optional: true
bin:
vitest: vitest.mjs
checksum: 96068ea6d40186c8ca946ee688ba3717dbd0947c56a2bcd625c14a5df25776342ff2f1eb326b06cb6f538d9568633b3e821991aa7c95a98e458be9fc2b3ca59e
languageName: node
linkType: hard
"void-elements@npm:3.1.0": "void-elements@npm:3.1.0":
version: 3.1.0 version: 3.1.0
resolution: "void-elements@npm:3.1.0" resolution: "void-elements@npm:3.1.0"