diff --git a/crowdin/config.yml b/crowdin/config.yml index 7a87ee73..fa1ca6e7 100644 --- a/crowdin/config.yml +++ b/crowdin/config.yml @@ -20,4 +20,10 @@ files: [ dest: "/backend.pot", translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po" }, + { + source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json", + dest: "/impress.json", + translation: "/frontend/packages/i18n/locales/impress/%two_letters_code%/translations.json", + skip_untranslated_strings: true, + }, ] diff --git a/src/frontend/packages/i18n/.eslintrc.js b/src/frontend/packages/i18n/.eslintrc.js new file mode 100644 index 00000000..475c786b --- /dev/null +++ b/src/frontend/packages/i18n/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + root: true, + extends: ['impress/jest', 'plugin:import/recommended'], + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest', + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, + ignorePatterns: ['node_modules'], +}; diff --git a/src/frontend/packages/i18n/.gitignore b/src/frontend/packages/i18n/.gitignore new file mode 100644 index 00000000..a3068018 --- /dev/null +++ b/src/frontend/packages/i18n/.gitignore @@ -0,0 +1 @@ +locales diff --git a/src/frontend/packages/i18n/__tests__/i18n.test.ts b/src/frontend/packages/i18n/__tests__/i18n.test.ts new file mode 100644 index 00000000..98e45e25 --- /dev/null +++ b/src/frontend/packages/i18n/__tests__/i18n.test.ts @@ -0,0 +1,104 @@ +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +describe('integration testing on i18n package', () => { + afterAll(() => { + fs.rmSync('./locales/tests', { recursive: true, force: true }); + }); + + test('cmd extract-translation:impress', () => { + // To be sure the file is not here + fs.rmSync('./locales/impress/translations-crowdin.json', { + recursive: true, + force: true, + }); + expect( + fs.existsSync('./locales/impress/translations-crowdin.json'), + ).toBeFalsy(); + + // Generate the file + execSync('yarn extract-translation:impress'); + expect( + fs.existsSync('./locales/impress/translations-crowdin.json'), + ).toBeTruthy(); + }); + + test('cmd format-deploy', () => { + // To be sure the tests folder is not here + fs.rmSync('./locales/tests', { recursive: true, force: true }); + expect(fs.existsSync('./locales/tests')).toBeFalsy(); + + // Generate english json file + fs.mkdirSync('./locales/tests/en/', { recursive: true }); + fs.writeFileSync( + './locales/tests/en/translations.json', + JSON.stringify({ test: { message: 'My test' } }), + 'utf8', + ); + expect(fs.existsSync('./locales/tests/en/translations.json')).toBeTruthy(); + + fs.mkdirSync('./locales/tests/fr/', { recursive: true }); + fs.writeFileSync( + './locales/tests/fr/translations.json', + JSON.stringify({ test: { message: 'Mon test' } }), + 'utf8', + ); + expect(fs.existsSync('./locales/tests/fr/translations.json')).toBeTruthy(); + + // Execute format-deploy command + const output = './locales/tests/translations.json'; + execSync(`node ./format-deploy.mjs --app=tests --output=${output}`); + const json = JSON.parse(fs.readFileSync(output, 'utf8')); + expect(json).toEqual({ + en: { + translation: { test: 'My test' }, + }, + fr: { + translation: { test: 'Mon test' }, + }, + }); + }); + + test('cmd format-deploy throws an error when translation file is not found', () => { + // To be sure the tests folder is not here + fs.rmSync('./locales/tests', { recursive: true, force: true }); + expect(fs.existsSync('./locales/tests')).toBeFalsy(); + + // Generate english json file + fs.mkdirSync('./locales/tests/en/', { recursive: true }); + + // Execute format-deploy command + const output = './locales/tests/translations.json'; + + const cmd = () => { + execSync(`node ./format-deploy.mjs --app=tests --output=${output}`, { + stdio: 'pipe', + }); + }; + + expect(cmd).toThrow( + `Error: File locales${path.sep}tests${path.sep}en${path.sep}translations.json not found!`, + ); + }); + + test('cmd format-deploy throws an error when no translation to deploy', () => { + // To be sure the tests folder is not here + fs.rmSync('./locales/tests', { recursive: true, force: true }); + expect(fs.existsSync('./locales/tests')).toBeFalsy(); + + // Generate english json file + fs.mkdirSync('./locales/tests/', { recursive: true }); + + // Execute format-deploy command + const output = './locales/tests/translations.json'; + + const cmd = () => { + execSync(`node ./format-deploy.mjs --app=tests --output=${output}`, { + stdio: 'pipe', + }); + }; + + expect(cmd).toThrow('Error: No translation to deploy'); + }); +}); diff --git a/src/frontend/packages/i18n/__tests__/translations.test.ts b/src/frontend/packages/i18n/__tests__/translations.test.ts new file mode 100644 index 00000000..63c7ac2d --- /dev/null +++ b/src/frontend/packages/i18n/__tests__/translations.test.ts @@ -0,0 +1,47 @@ +import { execSync } from 'child_process'; +import fs from 'fs'; + +describe('checks all the frontend translation are made', () => { + it('checks missing translation. If this test fails, go to https://crowdin.com/', () => { + // Extract the translations + execSync( + 'yarn extract-translation:impress -c ./i18next-parser.config.jest.mjs', + ); + const outputCrowdin = './locales/impress/translations-crowdin.json'; + const jsonCrowdin = JSON.parse(fs.readFileSync(outputCrowdin, 'utf8')); + const listKeysCrowdin = Object.keys(jsonCrowdin).sort(); + + // Check the translations in the app impress + const outputimpress = '../../apps/impress/src/i18n/translations.json'; + const jsonimpress = JSON.parse(fs.readFileSync(outputimpress, 'utf8')); + + // Our keys are in english, so we don't need to check the english translation + Object.keys(jsonimpress) + .filter((key) => key !== 'en') + .forEach((key) => { + const listKeysimpress = Object.keys(jsonimpress[key].translation).sort(); + const missingKeys = listKeysCrowdin.filter( + (element) => !listKeysimpress.includes(element), + ); + const additionalKeys = listKeysimpress.filter( + (element) => !listKeysCrowdin.includes(element), + ); + + if (missingKeys.length > 0) { + console.log( + `Missing keys in impress translations that should be translated in Crowdin, got to https://crowdin.com/ :`, + missingKeys, + ); + } + + if (additionalKeys.length > 0) { + console.log( + `Additional keys in impress translations that seems not present in this branch:`, + additionalKeys, + ); + } + + expect(missingKeys.length).toBe(0); + }); + }); +}); diff --git a/src/frontend/packages/i18n/format-deploy.mjs b/src/frontend/packages/i18n/format-deploy.mjs new file mode 100644 index 00000000..4110c969 --- /dev/null +++ b/src/frontend/packages/i18n/format-deploy.mjs @@ -0,0 +1,56 @@ +import fs from 'fs'; +import path from 'path'; + +import { hideBin } from 'yargs/helpers'; +import yargs from 'yargs/yargs'; + +// Get our args +const argv = yargs(hideBin(process.argv)).argv; +const { app, output } = argv; + +const folderPath = './locales/' + app; +const namefile = 'translations.json'; +const jsonI18n = {}; + +// Fetch the files in the locales folder +fs.readdirSync(folderPath).map((language) => { + const languagePath = path.join(folderPath, path.sep, language); + // Crowdin output file in folder, we want to treat only these ones + if (!fs.lstatSync(languagePath).isDirectory()) { + return; + } + + jsonI18n[language] = { + translation: {}, + }; + + // Get the json file generated by crowdin + const pathTranslateFile = path.join(languagePath, path.sep, namefile); + + if (!fs.existsSync(pathTranslateFile)) { + throw new Error(`File ${pathTranslateFile} not found!`); + } + + const json = JSON.parse(fs.readFileSync(pathTranslateFile, 'utf8')); + + // Transform the json file to the format expected by i18next + const jsonKeyMessage = {}; + Object.keys(json) + .sort() + .forEach((key) => { + jsonKeyMessage[key] = json[key].message; + }); + + jsonI18n[language] = { + translation: jsonKeyMessage, + }; +}); + +if (!Object.keys(jsonI18n).length) { + throw new Error(`No translation to deploy`); +} + +// Write the file to the output +fs.writeFileSync(output, JSON.stringify(jsonI18n), 'utf8'); + +console.log(`${app} translations deployed!`); diff --git a/src/frontend/packages/i18n/i18next-parser.config.jest.mjs b/src/frontend/packages/i18n/i18next-parser.config.jest.mjs new file mode 100644 index 00000000..ad2126c7 --- /dev/null +++ b/src/frontend/packages/i18n/i18next-parser.config.jest.mjs @@ -0,0 +1,10 @@ +const config = { + customValueTemplate: { + message: '${key}', + description: '${description}', + }, + keepRemoved: false, + keySeparator: false, +}; + +export default config; diff --git a/src/frontend/packages/i18n/i18next-parser.config.mjs b/src/frontend/packages/i18n/i18next-parser.config.mjs new file mode 100644 index 00000000..ad2126c7 --- /dev/null +++ b/src/frontend/packages/i18n/i18next-parser.config.mjs @@ -0,0 +1,10 @@ +const config = { + customValueTemplate: { + message: '${key}', + description: '${description}', + }, + keepRemoved: false, + keySeparator: false, +}; + +export default config; diff --git a/src/frontend/packages/i18n/jest.config.ts b/src/frontend/packages/i18n/jest.config.ts new file mode 100644 index 00000000..c6707130 --- /dev/null +++ b/src/frontend/packages/i18n/jest.config.ts @@ -0,0 +1,7 @@ +export default { + rootDir: './', + testEnvironment: 'node', + transform: { + '^.+\\.(ts)$': 'ts-jest', + }, +}; diff --git a/src/frontend/packages/i18n/package.json b/src/frontend/packages/i18n/package.json new file mode 100644 index 00000000..171d54fe --- /dev/null +++ b/src/frontend/packages/i18n/package.json @@ -0,0 +1,24 @@ +{ + "name": "packages-i18n", + "version": "0.1.0", + "private": true, + "scripts": { + "extract-translation": "yarn extract-translation:impress", + "extract-translation:impress": "yarn i18next ../../apps/impress/**/*.{ts,tsx} -c ./i18next-parser.config.mjs -o ./locales/impress/translations-crowdin.json", + "format-deploy": "yarn format-deploy:impress", + "format-deploy:impress": "node ./format-deploy.mjs --app=impress --output=../../apps/impress/src/i18n/translations.json", + "lint": "eslint --ext .js,.ts,.mjs .", + "test": "jest" + }, + "dependencies": { + "@types/jest": "29.5.12", + "@types/node": "*", + "eslint-config-impress": "*", + "eslint-plugin-import": "2.29.1", + "i18next-parser": "8.8.0", + "jest": "29.7.0", + "ts-jest": "29.1.2", + "typescript": "*", + "yargs": "17.7.2" + } +} diff --git a/src/frontend/packages/i18n/tsconfig.json b/src/frontend/packages/i18n/tsconfig.json new file mode 100644 index 00000000..c4350f5a --- /dev/null +++ b/src/frontend/packages/i18n/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + }, + "include": [ + "**/*.ts", + ], + "exclude": ["node_modules"] +}