Files
freeCodeCamp/curriculum/getChallenges.js
Oliver Eyton-Williams e118dda13a fix: order imports and remove circular dependencies (#41824)
* fix: remove circular dependency

redux depended on templates/Challenges/redux and vice versa.  This
meant that import order mattered and confusing bugs could arise.

(cherry picked from commit 7d67a4e70922bbb3051f2f9982dcc69e240d43dc)

* feat: require imports to be in alphabetical order

Import order generally does not matter, but there are edge cases
(circular  imports and css imports, for example) where changing order
changes behaviour

(cherry picked from commit b8d1393a91ec6e068caf8e8498a5c95df68c2b2c)

* chore: order imports

* fix: lift up challenge description + title comps

This brings the classic Show closer to the others as they
now all create the description and title components

* fix: remove donation-saga/index circular import

(cherry picked from commit 51a44ca668a700786d2744feffeae4fdba5fd207)

* refactor: extract action-types from settings

(cherry picked from commit 25e26124d691c84a0d0827d41dafb761c686fadd)

* fix: lint errors

* feat: prevent useless renames
2021-08-02 08:39:40 -05:00

391 lines
11 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const util = require('util');
const yaml = require('js-yaml');
const { findIndex, reduce, toString } = require('lodash');
const readDirP = require('readdirp');
const { helpCategoryMap } = require('../client/utils/challengeTypes');
const { showUpcomingChanges } = require('../config/env.json');
const { curriculum: curriculumLangs } =
require('../config/i18n/all-langs').availableLangs;
const { parseMD } = require('../tools/challenge-parser/parser');
/* eslint-disable max-len */
const {
translateCommentsInChallenge
} = require('../tools/challenge-parser/translation-parser');
/* eslint-enable max-len*/
const { isAuditedCert } = require('../utils/is-audited');
const { createPoly } = require('../utils/polyvinyl');
const { dasherize } = require('../utils/slugs');
const access = util.promisify(fs.access);
const challengesDir = path.resolve(__dirname, './challenges');
const metaDir = path.resolve(challengesDir, '_meta');
exports.challengesDir = challengesDir;
exports.metaDir = metaDir;
const COMMENT_TRANSLATIONS = createCommentMap(
path.resolve(__dirname, './dictionaries')
);
function getTranslatableComments(dictionariesDir) {
const COMMENTS_TO_TRANSLATE = require(path.resolve(
dictionariesDir,
'english',
'comments.json'
));
return Object.values(COMMENTS_TO_TRANSLATE);
}
exports.getTranslatableComments = getTranslatableComments;
function createCommentMap(dictionariesDir) {
// get all the languages for which there are dictionaries.
const languages = fs
.readdirSync(dictionariesDir)
.filter(x => x !== 'english');
// get all their dictionaries
const dictionaries = languages.reduce(
(acc, lang) => ({
...acc,
[lang]: require(path.resolve(dictionariesDir, lang, 'comments.json'))
}),
{}
);
// get the english dicts
const COMMENTS_TO_TRANSLATE = require(path.resolve(
dictionariesDir,
'english',
'comments.json'
));
const COMMENTS_TO_NOT_TRANSLATE = require(path.resolve(
dictionariesDir,
'english',
'comments-to-not-translate'
));
// map from english comment text to translations
const translatedCommentMap = Object.entries(COMMENTS_TO_TRANSLATE).reduce(
(acc, [id, text]) => {
return {
...acc,
[text]: getTranslationEntry(dictionaries, { engId: id, text })
};
},
{}
);
// map from english comment text to itself
const untranslatableCommentMap = Object.values(
COMMENTS_TO_NOT_TRANSLATE
).reduce((acc, text) => {
const englishEntry = languages.reduce(
(acc, lang) => ({
...acc,
[lang]: text
}),
{}
);
return {
...acc,
[text]: englishEntry
};
}, {});
return { ...translatedCommentMap, ...untranslatableCommentMap };
}
exports.createCommentMap = createCommentMap;
function getTranslationEntry(dicts, { engId, text }) {
return Object.keys(dicts).reduce((acc, lang) => {
const entry = dicts[lang][engId];
if (entry) {
return { ...acc, [lang]: entry };
} else {
throw Error(`Missing translation for comment
'${text}'
with id of ${engId}`);
}
}, {});
}
function getChallengesDirForLang(lang) {
return path.resolve(challengesDir, `./${lang}`);
}
function getMetaForBlock(block) {
return JSON.parse(
fs.readFileSync(path.resolve(metaDir, `./${block}/meta.json`), 'utf8')
);
}
function parseCert(filePath) {
return yaml.load(fs.readFileSync(filePath, 'utf8'));
}
exports.getChallengesDirForLang = getChallengesDirForLang;
exports.getMetaForBlock = getMetaForBlock;
// This recursively walks the directories starting at root, and calls cb for
// each file/directory and only resolves once all the callbacks do.
const walk = (root, target, options, cb) => {
return new Promise(resolve => {
let running = 1;
function done() {
if (--running === 0) {
resolve(target);
}
}
readDirP(root, options)
.on('data', file => {
running++;
cb(file, target).then(done);
})
.on('end', done);
});
};
exports.getChallengesForLang = async function getChallengesForLang(lang) {
const root = getChallengesDirForLang(lang);
// scaffold the curriculum, first set up the superblocks, then recurse into
// the blocks
const curriculum = await walk(
root,
{},
{ type: 'directories', depth: 1 },
buildSuperBlocks
);
const cb = (file, curriculum) => buildChallenges(file, curriculum, lang);
// fill the scaffold with the challenges
return walk(
root,
curriculum,
{ type: 'files', fileFilter: ['*.md', '*.yml'] },
cb
);
};
async function buildBlocks({ basename: blockName }, curriculum, superBlock) {
const metaPath = path.resolve(
__dirname,
`./challenges/_meta/${blockName}/meta.json`
);
const blockMeta = require(metaPath);
const { isUpcomingChange } = blockMeta;
if (typeof isUpcomingChange !== 'boolean') {
throw Error(
`meta file at ${metaPath} is missing 'isUpcomingChange', it must be 'true' or 'false'`
);
}
if (!isUpcomingChange || showUpcomingChanges) {
// add the block to the superBlock
const blockInfo = { meta: blockMeta, challenges: [] };
curriculum[superBlock].blocks[blockName] = blockInfo;
}
}
async function buildSuperBlocks({ path, fullPath }, curriculum) {
const { order, name: superBlock } = superBlockInfo(path);
curriculum[superBlock] = { superBlock, order, blocks: {} };
const cb = (file, curriculum) => buildBlocks(file, curriculum, superBlock);
return walk(fullPath, curriculum, { depth: 1, type: 'directories' }, cb);
}
async function buildChallenges({ path }, curriculum, lang) {
// path is relative to getChallengesDirForLang(lang)
const block = getBlockNameFromPath(path);
const { name: superBlock } = superBlockInfoFromPath(path);
let challengeBlock;
// TODO: this try block and process exit can all go once errors terminate the
// tests correctly.
try {
challengeBlock = curriculum[superBlock].blocks[block];
if (!challengeBlock) {
// this should only happen when a isUpcomingChange block is skipped
return;
}
} catch (e) {
console.log(`failed to create superBlock ${superBlock}`);
// eslint-disable-next-line no-process-exit
process.exit(1);
}
const { meta } = challengeBlock;
const challenge = await createChallenge(challengesDir, path, lang, meta);
challengeBlock.challenges = [...challengeBlock.challenges, challenge];
}
async function parseTranslation(transPath, dict, lang, parse = parseMD) {
const translatedChal = await parse(transPath);
const { challengeType } = translatedChal;
// challengeType 11 is for video challenges and 3 is for front-end projects
// neither of which have seeds.
return challengeType !== 11 && challengeType !== 3
? translateCommentsInChallenge(translatedChal, lang, dict)
: translatedChal;
}
async function createChallenge(basePath, filePath, lang, maybeMeta) {
function getFullPath(pathLang) {
return path.resolve(__dirname, basePath, pathLang, filePath);
}
let meta;
if (maybeMeta) {
meta = maybeMeta;
} else {
const metaPath = path.resolve(
metaDir,
`./${getBlockNameFromPath(filePath)}/meta.json`
);
meta = require(metaPath);
}
const { name: superBlock } = superBlockInfoFromPath(filePath);
if (!curriculumLangs.includes(lang))
throw Error(`${lang} is not a accepted language.
Trying to parse ${filePath}`);
if (lang !== 'english' && !(await hasEnglishSource(basePath, filePath)))
throw Error(`Missing English challenge for
${filePath}
It should be in
${getFullPath('english')}
`);
// assumes superblock names are unique
// while the auditing is ongoing, we default to English for un-audited certs
// once that's complete, we can revert to using isEnglishChallenge(fullPath)
const useEnglish = lang === 'english' || !isAuditedCert(lang, superBlock);
const isCert = path.extname(filePath) === '.yml';
let challenge;
if (isCert) {
challenge = await (useEnglish
? parseCert(getFullPath('english'))
: parseCert(getFullPath(lang)));
} else {
challenge = await (useEnglish
? parseMD(getFullPath('english'))
: parseTranslation(getFullPath(lang), COMMENT_TRANSLATIONS, lang));
}
const challengeOrder = findIndex(
meta.challengeOrder,
([id]) => id === challenge.id
);
const {
name: blockName,
order,
superOrder,
isPrivate,
required = [],
template,
time
} = meta;
challenge.block = dasherize(blockName);
challenge.order = order;
challenge.superOrder = superOrder;
challenge.superBlock = superBlock;
challenge.challengeOrder = challengeOrder;
challenge.isPrivate = challenge.isPrivate || isPrivate;
challenge.required = required.concat(challenge.required || []);
challenge.template = template;
challenge.time = time;
challenge.helpCategory =
challenge.helpCategory || helpCategoryMap[challenge.block];
challenge.translationPending =
lang !== 'english' && !isAuditedCert(lang, superBlock);
return prepareChallenge(challenge);
}
// TODO: tests and more descriptive name.
function filesToObject(files) {
return reduce(
files,
(map, file) => {
map[file.key] = {
...file,
head: arrToString(file.head),
contents: arrToString(file.contents),
tail: arrToString(file.tail)
};
return map;
},
{}
);
}
// gets the challenge ready for sourcing into Gatsby
function prepareChallenge(challenge) {
if (challenge.files) {
challenge.files = filesToObject(challenge.files);
challenge.files = Object.keys(challenge.files)
.filter(key => challenge.files[key])
.map(key => challenge.files[key])
.reduce(
(files, file) => ({
...files,
[file.key]: {
...createPoly(file),
seed: file.contents.slice(0)
}
}),
{}
);
}
if (challenge.solutionFiles) {
challenge.solutionFiles = filesToObject(challenge.solutionFiles);
}
return challenge;
}
async function hasEnglishSource(basePath, translationPath) {
const englishRoot = path.resolve(__dirname, basePath, 'english');
return await access(
path.join(englishRoot, translationPath),
fs.constants.F_OK
)
.then(() => true)
.catch(() => false);
}
function superBlockInfoFromPath(filePath) {
const [maybeSuper] = filePath.split(path.sep);
return superBlockInfo(maybeSuper);
}
function superBlockInfo(fileName) {
const [maybeOrder, ...superBlock] = fileName.split('-');
let order = parseInt(maybeOrder, 10);
if (isNaN(order)) {
return { order: 0, name: fileName };
} else {
return {
order: order,
name: superBlock.join('-')
};
}
}
function getBlockNameFromPath(filePath) {
const [, block] = filePath.split(path.sep);
return block;
}
function arrToString(arr) {
return Array.isArray(arr) ? arr.join('\n') : toString(arr);
}
exports.hasEnglishSource = hasEnglishSource;
exports.parseTranslation = parseTranslation;
exports.createChallenge = createChallenge;