import path from 'path'; import { readFile } from 'fs/promises'; import { availableLangs } from '@freecodecamp/shared/config/i18n'; import introSchema from './locales/english/intro.json'; import linksSchema from './locales/english/links.json'; import metaTagsSchema from './locales/english/meta-tags.json'; import motivationSchema from './locales/english/motivation.json'; import translationsSchema from './locales/english/translations.json'; type MotivationalQuotes = { quote: string; author: string }[]; /** * Flattens a nested object structure into a single * object with property chains as keys. * @param {Object} obj Object to flatten * @param {String} namespace Used for property chaining */ const flattenAnObject = (obj: Record, namespace = '') => { const flattened: Record = {}; Object.keys(obj).forEach(key => { const value = obj[key]; const field = namespace ? `${namespace}.${key}` : key; if (Array.isArray(value)) { flattened[field] = value; } else if (typeof value === 'object') { Object.assign( flattened, flattenAnObject(value as Record, field) ); } else { flattened[field] = value; } }); return flattened; }; /** * Checks if a translation object is missing keys * that are present in the schema. * @param {String[]} file Array of translation object's keys * @param {String[]} schema Array of matching schema's keys * @param {String} path string path to file */ const findMissingKeys = (file: string[], schema: string[], path: string) => { const missingKeys = []; for (const key of schema) { if (!file.includes(key)) { missingKeys.push(key); } } if (missingKeys.length) { console.warn( `${path} is missing these required keys: ${missingKeys.join(', ')}` ); } }; /** * Checks if a translation object has extra * keys which are NOT present in the schema. * @param {String[]} file Array of translation object's keys * @param {String[]} schema Array of matching schema's keys * @param {String} path string path to file */ const findExtraneousKeys = (file: string[], schema: string[], path: string) => { const extraKeys = []; for (const key of file) { if (!schema.includes(key)) { extraKeys.push(key); } } if (extraKeys.length) { console.warn( `${path} has these keys that are not in the schema: ${extraKeys.join( ', ' )}` ); } }; /** * Validates that all values in the object are non-empty. Includes * validation of nested objects. * @param {Object} obj The object to check the values of * @param {String} namespace String for tracking nested properties */ const noEmptyObjectValues = ( obj: Record, namespace = '' ): string[] => { const emptyKeys = []; for (const key of Object.keys(obj)) { const value = obj[key]; const field = namespace ? `${namespace}.${key}` : key; if (Array.isArray(value)) { if (!value.length) { emptyKeys.push(field); } } else if (typeof value === 'object') { emptyKeys.push( noEmptyObjectValues(value as Record, field) ); } else if (!value) { emptyKeys.push(field); } } return emptyKeys.flat(); }; /** * Grab the schema keys once, to avoid overhead of * fetching within iterative function. */ const translationSchemaKeys = Object.keys(flattenAnObject(translationsSchema)); const motivationSchemaKeys = Object.keys(flattenAnObject(motivationSchema)); const introSchemaKeys = Object.keys(flattenAnObject(introSchema)); const metaTagsSchemaKeys = Object.keys(flattenAnObject(metaTagsSchema)); const linksSchemaKeys = Object.keys(flattenAnObject(linksSchema)); /** * Function that checks the translations.json file * for each available client language. * @param {String[]} languages List of languages to test */ const translationSchemaValidation = (languages: string[]) => { languages.forEach(language => { void readJsonFile(language, 'translations').then(fileJson => { schemaValidation( language, 'translations', fileJson, translationSchemaKeys ); }); }); }; /** * Function that checks the motivation.json file * for each available client language. * @param {String[]} languages List of languages to test */ const motivationSchemaValidation = (languages: string[]) => { languages.forEach(language => { void readJsonFile(language, 'motivation').then(fileJson => { schemaValidation(language, 'motivation', fileJson, motivationSchemaKeys); }); }); }; /** * Function that checks the intro.json file * for each available client language. * @param {String[]} languages List of languages to test */ const introSchemaValidation = (languages: string[]) => { languages.forEach(language => { void readJsonFile(language, 'intro').then(fileJson => { schemaValidation(language, 'intro', fileJson, introSchemaKeys); }); }); }; /** * Function that checks the meta-tags.json file * for each available client language. * @param {String[]} languages List of languages to test */ const metaTagsSchemaValidation = (languages: string[]) => { languages.forEach(language => { void readJsonFile(language, 'meta-tags').then(fileJson => { schemaValidation(language, 'meta-tags', fileJson, metaTagsSchemaKeys); }); }); }; /** * Function that checks the links.json file * for each available client language. * @param {String[]} languages List of languages to test */ const linksSchemaValidation = (languages: string[]) => { languages.forEach(language => { void readJsonFile(language, 'links').then(fileJson => { schemaValidation(language, 'links', fileJson, linksSchemaKeys); }); }); }; /** * Common Function that checks the json file * @param {String} language the language to test * @param {String} fileName the fileName of json file to test * @param {Object} fileJson the fileJson got by readJsonFile * @param {String[]} schemaKeys Array of matching schema's keys */ const schemaValidation = ( language: string, fileName: string, fileJson: Record, schemaKeys: string[] ) => { const fileKeys = Object.keys(flattenAnObject(fileJson)); findMissingKeys(fileKeys, schemaKeys, `${language}/${fileName}.json`); findExtraneousKeys(fileKeys, schemaKeys, `${language}/${fileName}.json`); const emptyKeys = noEmptyObjectValues(fileJson); if (emptyKeys.length) { console.warn( `${language}/${fileName}.json has these empty keys: ${emptyKeys.join( ', ' )}` ); } // Special line to assert that objects in motivational quote are correct if ( fileName === 'motivation' && !(fileJson.motivationalQuotes as MotivationalQuotes).every( object => Object.prototype.hasOwnProperty.call(object, 'quote') && Object.prototype.hasOwnProperty.call(object, 'author') ) ) { console.warn(`${language}/${fileName}.json has malformed quote objects.`); } console.info(`${language} ${fileName}.json validation complete`); }; const readJsonFile = async (language: string, fileName: string) => { const filePath = path.join( __dirname, `/locales/${language}/${fileName}.json` ); const file = await readFile(filePath, 'utf8'); const fileJson = JSON.parse(file) as Record; return fileJson; }; const translatedLangs = availableLangs.client.filter( x => String(x) !== 'english' ); translationSchemaValidation(translatedLangs); motivationSchemaValidation(translatedLangs); introSchemaValidation(translatedLangs); metaTagsSchemaValidation(translatedLangs); linksSchemaValidation(translatedLangs);