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:
Nicholas Carrigan (he/him)
2021-01-05 06:50:59 -08:00
committed by Mrugesh Mohapatra
parent 41c4b7f49a
commit 49b1c29f6b
7 changed files with 887 additions and 560 deletions

View File

@ -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`, () => {

View File

@ -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;

View 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

View File

@ -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;

View File

@ -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",

View File

@ -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",