fix(tools): validate and lint i18n schema (#40597)
* tools: Move schema validation to linter Migrates the schema validation process for translation files out of the `test` step and in to a `lint` step. Signed-off-by: nhcarrigan <nhcarrigan@gmail.com> * fix: typo Signed-off-by: nhcarrigan <nhcarrigan@gmail.com> * tools: Lint motivation object Verifies that the motivation.json objects are correct, and that the quote objects are all structured properly. Signed-off-by: nhcarrigan <nhcarrigan@gmail.com> * tools: add object value validation Adds a function that validates each translation object does not have any empty keys. Signed-off-by: nhcarrigan <nhcarrigan@gmail.com> * tools: Log missing values with property chain Modifies the value validation to log property names as chains, for easier identification of misisng key values. Signed-off-by: nhcarrigan <nhcarrigan@gmail.com> * fix(tools): Correct typo Corrects the typo in the motivation-schema.js comments Co-authored-by: Tom <20648924+moT01@users.noreply.github.com> Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
committed by
Mrugesh Mohapatra
parent
41c4b7f49a
commit
49b1c29f6b
@ -1,31 +1,25 @@
|
||||
/* global expect */
|
||||
import { translationsSchema } from './translations-schema';
|
||||
import { motivationSchema } from './motivation-schema';
|
||||
import {
|
||||
availableLangs,
|
||||
i18nextCodes,
|
||||
langDisplayNames,
|
||||
langCodes
|
||||
} from './allLangs';
|
||||
import { trendingSchema } from './trending-schema';
|
||||
|
||||
const fs = require('fs');
|
||||
const { expectToMatchSchema, setup } = require('jest-json-schema-extended');
|
||||
const { setup } = require('jest-json-schema-extended');
|
||||
|
||||
setup();
|
||||
|
||||
const filesThatShouldExist = [
|
||||
{
|
||||
name: 'translations.json',
|
||||
schema: translationsSchema
|
||||
name: 'translations.json'
|
||||
},
|
||||
{
|
||||
name: 'motivation.json',
|
||||
schema: motivationSchema
|
||||
name: 'motivation.json'
|
||||
},
|
||||
{
|
||||
name: 'trending.json',
|
||||
schema: trendingSchema
|
||||
name: 'trending.json'
|
||||
}
|
||||
];
|
||||
|
||||
@ -40,13 +34,6 @@ describe('Locale tests:', () => {
|
||||
const exists = fs.existsSync(`${path}/${lang}/${file.name}`);
|
||||
expect(exists).toBeTruthy();
|
||||
});
|
||||
|
||||
// check that each of the json files match the schema
|
||||
test(`${file.name} has correct schema`, async () => {
|
||||
const jsonFile = fs.readFileSync(`${path}/${lang}/${file.name}`);
|
||||
let json = await JSON.parse(jsonFile);
|
||||
expectToMatchSchema(json, file.schema);
|
||||
});
|
||||
});
|
||||
|
||||
test(`has a two character entry in the i18nextCodes variable`, () => {
|
||||
|
@ -1,22 +1,10 @@
|
||||
/* eslint-disable camelcase */
|
||||
/* This is used for testing to make sure the motivation.json files
|
||||
* in each language have the correct structure
|
||||
/* This is used for testing. If a motivation.json file doesn't match the
|
||||
* structure here exactly, the tests will fail.
|
||||
*/
|
||||
const {
|
||||
arrayOfItems,
|
||||
strictObject,
|
||||
stringType
|
||||
} = require('jest-json-schema-extended');
|
||||
|
||||
const motivationSchema = strictObject({
|
||||
compliments: arrayOfItems(stringType, { minItems: 1 }),
|
||||
motivationalQuotes: arrayOfItems(
|
||||
strictObject({
|
||||
quote: stringType,
|
||||
author: stringType
|
||||
}),
|
||||
{ minItems: 1 }
|
||||
)
|
||||
});
|
||||
const motivationSchema = {
|
||||
compliments: ['yes'],
|
||||
motivationalQuotes: ['woohoo']
|
||||
};
|
||||
|
||||
exports.motivationSchema = motivationSchema;
|
||||
|
216
client/i18n/schema-validation.js
Normal file
216
client/i18n/schema-validation.js
Normal file
@ -0,0 +1,216 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { translationsSchema } = require('./translations-schema');
|
||||
const { availableLangs } = require('./allLangs');
|
||||
const { trendingSchema } = require('./trending-schema');
|
||||
const { motivationSchema } = require('./motivation-schema');
|
||||
|
||||
/**
|
||||
* 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, namespace = '') => {
|
||||
const flattened = {};
|
||||
Object.keys(obj).forEach(key => {
|
||||
if (Array.isArray(obj[key])) {
|
||||
flattened[namespace ? `${namespace}.${key}` : key] = obj[key];
|
||||
} else if (typeof obj[key] === 'object') {
|
||||
Object.assign(
|
||||
flattened,
|
||||
flattenAnObject(obj[key], namespace ? `${namespace}.${key}` : key)
|
||||
);
|
||||
} else {
|
||||
flattened[namespace ? `${namespace}.${key}` : key] = obj[key];
|
||||
}
|
||||
});
|
||||
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, schema, path) => {
|
||||
const missingKeys = [];
|
||||
for (const key of schema) {
|
||||
if (!file.includes(key)) {
|
||||
missingKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (missingKeys.length) {
|
||||
throw new Error(
|
||||
`${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, schema, path) => {
|
||||
const extraKeys = [];
|
||||
for (const key of file) {
|
||||
if (!schema.includes(key)) {
|
||||
extraKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (extraKeys.length) {
|
||||
throw new Error(
|
||||
`${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, namespace = '') => {
|
||||
const emptyKeys = [];
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (Array.isArray(obj[key])) {
|
||||
if (!obj[key].length) {
|
||||
emptyKeys.push(namespace ? `${namespace}.${key}` : key);
|
||||
}
|
||||
} else if (typeof obj[key] === 'object') {
|
||||
emptyKeys.push(
|
||||
noEmptyObjectValues(obj[key], namespace ? `${namespace}.${key}` : key)
|
||||
);
|
||||
} else if (!obj[key]) {
|
||||
emptyKeys.push(namespace ? `${namespace}.${key}` : key);
|
||||
}
|
||||
}
|
||||
return emptyKeys.flat();
|
||||
};
|
||||
|
||||
/**
|
||||
* Grab the schema keys once, to avoid overhead of
|
||||
* fetching within iterative function.
|
||||
*/
|
||||
const translationSchemaKeys = Object.keys(flattenAnObject(translationsSchema));
|
||||
const trendingSchemaKeys = Object.keys(flattenAnObject(trendingSchema));
|
||||
const motivationSchemaKeys = Object.keys(flattenAnObject(motivationSchema));
|
||||
|
||||
/**
|
||||
* Function that checks the translations.json file
|
||||
* for each available client language.
|
||||
* @param {String[]} languages List of languages to test
|
||||
*/
|
||||
const translationSchemaValidation = languages => {
|
||||
languages.forEach(language => {
|
||||
const filePath = path.join(
|
||||
__dirname,
|
||||
`/locales/${language}/translations.json`
|
||||
);
|
||||
const fileData = fs.readFileSync(filePath);
|
||||
const fileJson = JSON.parse(fileData);
|
||||
const fileKeys = Object.keys(flattenAnObject(fileJson));
|
||||
findMissingKeys(
|
||||
fileKeys,
|
||||
translationSchemaKeys,
|
||||
`${language}/translations.json`
|
||||
);
|
||||
findExtraneousKeys(
|
||||
fileKeys,
|
||||
translationSchemaKeys,
|
||||
`${language}/translations.json`
|
||||
);
|
||||
const emptyKeys = noEmptyObjectValues(fileJson);
|
||||
if (emptyKeys.length) {
|
||||
throw new Error(
|
||||
`${language}/translation.json has these empty keys: ${emptyKeys.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
console.info(`${language} translation.json is correct!`);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Function that checks the trending.json file
|
||||
* for each available client language.
|
||||
* @param {String[]} languages List of languages to test
|
||||
*/
|
||||
const trendingSchemaValidation = languages => {
|
||||
languages.forEach(language => {
|
||||
const filePath = path.join(__dirname, `/locales/${language}/trending.json`);
|
||||
const fileData = fs.readFileSync(filePath);
|
||||
const fileJson = JSON.parse(fileData);
|
||||
const fileKeys = Object.keys(flattenAnObject(fileJson));
|
||||
findMissingKeys(fileKeys, trendingSchemaKeys, `${language}/trending.json`);
|
||||
findExtraneousKeys(
|
||||
fileKeys,
|
||||
trendingSchemaKeys,
|
||||
`${language}/trending.json`
|
||||
);
|
||||
const emptyKeys = noEmptyObjectValues(fileJson);
|
||||
if (emptyKeys.length) {
|
||||
throw new Error(
|
||||
`${language}/trending.json has these empty keys: ${emptyKeys.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
console.info(`${language} trending.json is correct!`);
|
||||
});
|
||||
};
|
||||
|
||||
const motivationSchemaValidation = languages => {
|
||||
languages.forEach(language => {
|
||||
const filePath = path.join(
|
||||
__dirname,
|
||||
`/locales/${language}/motivation.json`
|
||||
);
|
||||
const fileData = fs.readFileSync(filePath);
|
||||
const fileJson = JSON.parse(fileData);
|
||||
const fileKeys = Object.keys(flattenAnObject(fileJson));
|
||||
findMissingKeys(
|
||||
fileKeys,
|
||||
motivationSchemaKeys,
|
||||
`${language}/motivation.json`
|
||||
);
|
||||
findExtraneousKeys(
|
||||
fileKeys,
|
||||
motivationSchemaKeys,
|
||||
`${language}/motivation.json`
|
||||
);
|
||||
const emptyKeys = noEmptyObjectValues(fileJson);
|
||||
if (emptyKeys.length) {
|
||||
throw new Error(
|
||||
`${language}/motivation.json has these empty keys: ${emptyKeys.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
// Special line to assert that objects in motivational quote are correct
|
||||
if (
|
||||
!fileJson.motivationalQuotes.every(
|
||||
object =>
|
||||
object.hasOwnProperty('quote') && object.hasOwnProperty('author')
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`${language}/motivation.json has malformed quote objects.`
|
||||
);
|
||||
}
|
||||
console.info(`${language} motivation.json is correct!`);
|
||||
});
|
||||
};
|
||||
|
||||
translationSchemaValidation(availableLangs.client);
|
||||
trendingSchemaValidation(availableLangs.client);
|
||||
motivationSchemaValidation(availableLangs.client);
|
File diff suppressed because it is too large
Load Diff
@ -2,69 +2,98 @@
|
||||
/* This is used for testing. If a trending.json file doesn't match the
|
||||
* structure here exactly, the tests will fail.
|
||||
*/
|
||||
const { strictObject, stringType } = require('jest-json-schema-extended');
|
||||
|
||||
const trendingSchema = strictObject({
|
||||
article1title: stringType,
|
||||
article1link: stringType,
|
||||
article2title: stringType,
|
||||
article2link: stringType,
|
||||
article3title: stringType,
|
||||
article3link: stringType,
|
||||
article4title: stringType,
|
||||
article4link: stringType,
|
||||
article5title: stringType,
|
||||
article5link: stringType,
|
||||
article6title: stringType,
|
||||
article6link: stringType,
|
||||
article7title: stringType,
|
||||
article7link: stringType,
|
||||
article8title: stringType,
|
||||
article8link: stringType,
|
||||
article9title: stringType,
|
||||
article9link: stringType,
|
||||
article10title: stringType,
|
||||
article10link: stringType,
|
||||
article11title: stringType,
|
||||
article11link: stringType,
|
||||
article12title: stringType,
|
||||
article12link: stringType,
|
||||
article13title: stringType,
|
||||
article13link: stringType,
|
||||
article14title: stringType,
|
||||
article14link: stringType,
|
||||
article15title: stringType,
|
||||
article15link: stringType,
|
||||
article16title: stringType,
|
||||
article16link: stringType,
|
||||
article17title: stringType,
|
||||
article17link: stringType,
|
||||
article18title: stringType,
|
||||
article18link: stringType,
|
||||
article19title: stringType,
|
||||
article19link: stringType,
|
||||
article20title: stringType,
|
||||
article20link: stringType,
|
||||
article21title: stringType,
|
||||
article21link: stringType,
|
||||
article22title: stringType,
|
||||
article22link: stringType,
|
||||
article23title: stringType,
|
||||
article23link: stringType,
|
||||
article24title: stringType,
|
||||
article24link: stringType,
|
||||
article25title: stringType,
|
||||
article25link: stringType,
|
||||
article26title: stringType,
|
||||
article26link: stringType,
|
||||
article27title: stringType,
|
||||
article27link: stringType,
|
||||
article28title: stringType,
|
||||
article28link: stringType,
|
||||
article29title: stringType,
|
||||
article29link: stringType,
|
||||
article30title: stringType,
|
||||
article30link: stringType
|
||||
});
|
||||
const trendingSchema = {
|
||||
article1title: 'Git Clone',
|
||||
article1link:
|
||||
'https://www.freecodecamp.org/news/git-clone-branch-how-to-clone-a-specific-branch/',
|
||||
article2title: 'Agile Methods',
|
||||
article2link:
|
||||
'https://www.freecodecamp.org/news/agile-methods-and-methodology-for-beginners/',
|
||||
article3title: 'Python Main',
|
||||
article3link:
|
||||
'https://www.freecodecamp.org/news/if-name-main-python-example/',
|
||||
article4title: 'Callback',
|
||||
article4link:
|
||||
'https://www.freecodecamp.org/news/javascript-callback-functions-what-are-callbacks-in-js-and-how-to-use-them/',
|
||||
article5title: 'Debounce',
|
||||
article5link:
|
||||
'https://www.freecodecamp.org/news/debounce-javascript-tutorial-how-to-make-your-js-wait-up/',
|
||||
article6title: 'URL Encode',
|
||||
article6link:
|
||||
'https://www.freecodecamp.org/news/javascript-url-encode-example-how-to-use-encodeuricomponent-and-encodeuri/',
|
||||
article7title: 'Blink HTML',
|
||||
article7link:
|
||||
'https://www.freecodecamp.org/news/make-it-blink-html-tutorial-how-to-use-the-blink-tag-with-code-examples/',
|
||||
article8title: 'Python Tuple',
|
||||
article8link:
|
||||
'https://www.freecodecamp.org/news/python-returns-multiple-values-how-to-return-a-tuple-list-dictionary/',
|
||||
article9title: 'JavaScript Push',
|
||||
article9link:
|
||||
'https://www.freecodecamp.org/news/javascript-array-insert-how-to-add-to-an-array-with-the-push-unshift-and-concat-functions/',
|
||||
article10title: 'Java List',
|
||||
article10link:
|
||||
'https://www.freecodecamp.org/news/java-list-tutorial-util-list-api-example/',
|
||||
article11title: 'UX',
|
||||
article11link:
|
||||
'https://www.freecodecamp.org/news/learn-ux-design-self-taught-user-experience-designer/',
|
||||
article12title: 'Design Thinking',
|
||||
article12link:
|
||||
'https://www.freecodecamp.org/news/what-is-design-thinking-an-introduction-to-the-design-process-for-entrepreneurs-and-developers/',
|
||||
article13title: 'Prime Number List',
|
||||
article13link:
|
||||
'https://www.freecodecamp.org/news/prime-numbers-list-chart-of-primes/',
|
||||
article14title: 'Product Design',
|
||||
article14link:
|
||||
'https://www.freecodecamp.org/news/product-design-explained-in-plain-english/',
|
||||
article15title: 'Digital Design',
|
||||
article15link:
|
||||
'https://www.freecodecamp.org/news/what-is-digital-design-and-why-does-it-matter/',
|
||||
article16title: 'Coding Games',
|
||||
article16link:
|
||||
'https://www.freecodecamp.org/news/best-coding-games-online-adults-learn-to-code/',
|
||||
article17title: 'SVM',
|
||||
article17link:
|
||||
'https://www.freecodecamp.org/news/svm-machine-learning-tutorial-what-is-the-support-vector-machine-algorithm-explained-with-code-examples/',
|
||||
article18title: 'JavaScript forEach',
|
||||
article18link:
|
||||
'https://www.freecodecamp.org/news/javascript-foreach-how-to-loop-through-an-array-in-js/',
|
||||
article19title: 'Google BERT',
|
||||
article19link:
|
||||
'https://www.freecodecamp.org/news/google-bert-nlp-machine-learning-tutorial/',
|
||||
article20title: 'Create Table SQL',
|
||||
article20link:
|
||||
'https://www.freecodecamp.org/news/sql-create-table-statement-with-example-syntax/',
|
||||
article21title: 'Responsive Web Design',
|
||||
article21link:
|
||||
'https://www.freecodecamp.org/news/responsive-web-design-how-to-make-a-website-look-good-on-phones-and-tablets/',
|
||||
article22title: 'What Is an SVG File?',
|
||||
article22link:
|
||||
'https://www.freecodecamp.org/news/svg-basics-what-are-scalable-vector-graphics-and-how-do-you-use-them/',
|
||||
article23title: 'PDF Password Remover',
|
||||
article23link:
|
||||
'https://www.freecodecamp.org/news/pdf-password-remover-guide-how-to-remove-password-protection-from-a-pdf/',
|
||||
article24title: 'What Is a PDF?',
|
||||
article24link:
|
||||
'https://www.freecodecamp.org/news/what-is-a-pdf-file-and-how-do-you-open-it-solved/',
|
||||
article25title: 'What Is Python?',
|
||||
article25link:
|
||||
'https://www.freecodecamp.org/news/what-is-python-used-for-10-coding-uses-for-the-python-programming-language/',
|
||||
article26title: 'What Is TLS?',
|
||||
article26link:
|
||||
'https://www.freecodecamp.org/news/what-is-tls-transport-layer-security-encryption-explained-in-plain-english/',
|
||||
article27title: 'What Is a LAN?',
|
||||
article27link:
|
||||
'https://www.freecodecamp.org/news/what-is-a-lan-local-area-network-explained-in-plain-english/',
|
||||
article28title: 'What Is npm?',
|
||||
article28link:
|
||||
'https://www.freecodecamp.org/news/what-is-npm-a-node-package-manager-tutorial-for-beginners/',
|
||||
article29title: 'RSync Examples',
|
||||
article29link:
|
||||
'https://www.freecodecamp.org/news/rsync-examples-rsync-options-and-how-to-copy-files-over-ssh/',
|
||||
article30title: 'Random Forest',
|
||||
article30link:
|
||||
'https://www.freecodecamp.org/news/how-to-use-the-tree-based-algorithm-for-machine-learning/'
|
||||
};
|
||||
|
||||
exports.trendingSchema = trendingSchema;
|
||||
|
@ -95,6 +95,7 @@
|
||||
"format:src": "prettier-eslint --write --trailing-comma none --single-quote './src/**/*.js'",
|
||||
"format:utils": "prettier-eslint --write --trailing-comma none --single-quote './utils/**/*.js'",
|
||||
"format": "npm run format:gatsby && npm run format:src && npm run format:utils",
|
||||
"lint": "node ./i18n/schema-validation.js",
|
||||
"prestand-alone": "npm run prebuild",
|
||||
"stand-alone": "gatsby develop",
|
||||
"serve": "gatsby serve -p 8000",
|
||||
|
@ -41,6 +41,7 @@
|
||||
"lint:challenges": "cd ./curriculum && npm run lint",
|
||||
"lint:js": "eslint .",
|
||||
"lint:css": "npm run prettier -- --check",
|
||||
"lint:translations": "cd ./client && npm run lint",
|
||||
"prettier": "prettier \"**/*.css\"",
|
||||
"postinstall": "npm run bootstrap",
|
||||
"seed": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seedAuthUser",
|
||||
|
Reference in New Issue
Block a user