Skip to content

Commit 4cc2017

Browse files
fix: fallback to english challenges (freeCodeCamp#45635)
* fix: fallback to english challenges All challenges will use the english version if a translated file is not available. SHOW_NEW_CURRICULUM still gates what's shown in the client. * refactor: use closures to simplify createChallenge * refactor: remove messy destructure * refactor: add meta via helper * fix: fallback to [] for meta.required * fix: repair challenge.block * refactor: use CONST_CASE for meta + challenge dirs * fix: catch empty superblocks immediately * fix: clean up path.resolves * fix: invalid syntax in JS project steps * fix: default to english comments and relax tests Instead of always throwing errors when a comment is not translated, the tests now warn while SHOW_UPCOMING_CHANGES is true, so that tests will pass while we're developing and allow translators time to work. They still throw when SHOW_UPCOMING_CHANGES is false to catch issues in production * test: update createCommentMap test * refactor: delete stale comment * refactor: clarify validate with explanatory consts * feat: throw if audited cert falls back to english * fix: stop testing upcoming localized curriculum
1 parent e0a5fcd commit 4cc2017

File tree

10 files changed

+173
-136
lines changed

10 files changed

+173
-136
lines changed

.github/workflows/node.js-tests.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ jobs:
141141
- name: Set Environment variables
142142
run: |
143143
cp sample.env .env
144-
echo 'SHOW_UPCOMING_CHANGES=true' >> .env
145144
cat .env
146145
147146
- name: Install Dependencies

client/utils/build-challenges.js

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ const _ = require('lodash');
55
const envData = require('../../config/env.json');
66
const {
77
getChallengesForLang,
8-
createChallenge,
9-
challengesDir,
8+
generateChallengeCreator,
9+
CHALLENGES_DIR,
10+
META_DIR,
1011
getChallengesDirForLang
1112
} = require('../../curriculum/getChallenges');
1213

@@ -20,22 +21,19 @@ exports.replaceChallengeNode = () => {
2021
const blockNameRe = /\d\d-[-\w]+\/([^/]+)\//;
2122
const posix = path.normalize(filePath).split(path.sep).join(path.posix.sep);
2223
const blockName = posix.match(blockNameRe)[1];
23-
const metaPath = path.resolve(
24-
__dirname,
25-
`../../curriculum/challenges/_meta/${blockName}/meta.json`
26-
);
24+
const metaPath = path.resolve(META_DIR, `/${blockName}/meta.json`);
2725
delete require.cache[require.resolve(metaPath)];
2826
const meta = require(metaPath);
2927
// TODO: reimplement hot-reloading of certifications
30-
return await createChallenge(
31-
challengesDir,
32-
filePath,
33-
curriculumLocale,
34-
meta
35-
);
28+
return await createChallenge(filePath, meta);
3629
};
3730
};
3831

32+
const createChallenge = generateChallengeCreator(
33+
CHALLENGES_DIR,
34+
curriculumLocale
35+
);
36+
3937
exports.buildChallenges = async function buildChallenges() {
4038
const curriculum = await getChallengesForLang(curriculumLocale);
4139
const superBlocks = Object.keys(curriculum);

curriculum/challenges/english/02-javascript-algorithms-and-data-structures/basic-javascript-rpg-game/step-129.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ const locations = [
199199
"button text": ["REPLAY?", "REPLAY?", "REPLAY?"],
200200
"button functions": [restart, restart, restart],
201201
text: "You die. ☠️"
202-
}
202+
},
203203
{
204204
name: "win",
205205
"button text": ["Fight slime", "Fight fanged beast", "Go to town square"],

curriculum/challenges/english/02-javascript-algorithms-and-data-structures/functional-programming-spreadsheet/step-063.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ const charRange = (start, end) =>
111111
const evalFormula = x => {
112112
const rangeRegex = /([A-J])([1-9][0-9]?):([A-J])([1-9][0-9]?)/gi;
113113
const rangeFromString = (n1, n2) => range(parseInt(n1), parseInt(n2));
114-
const elemValue = n => (c => document.getElementById(c + n).value));
114+
const elemValue = n => c => document.getElementById(c + n).value;
115115
const fn = elemValue("1");
116116
return fn("A");
117117
};

curriculum/challenges/english/02-javascript-algorithms-and-data-structures/functional-programming-spreadsheet/step-122.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ const spreadsheetFunctions = {
9090
lasttwo: arr => arr.slice(-2),
9191
even: nums => nums.filter(isEven),
9292
sum: nums => nums.reduce((a, x) => a + x),
93-
has2: arr => arr.includes(2)
93+
has2: arr => arr.includes(2),
9494
nodups: arr => arr.reduce((a, x) => a.includes(x), [])
9595
};
9696

curriculum/dictionaries/english/comments.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,6 @@
106106
"es69h6": "When you join two windows into one window",
107107
"fho5t5": "When you open a new tab at the end",
108108
"00kcrm": "yields true",
109-
"sxpg2a": "Your mailbox, drive, and other work sites"
109+
"sxpg2a": "Your mailbox, drive, and other work sites",
110+
"4143lf": "initialize buttons"
110111
}

curriculum/getChallenges.js

Lines changed: 119 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
const fs = require('fs');
22
const path = require('path');
33
const util = require('util');
4+
const assert = require('assert');
45
const yaml = require('js-yaml');
5-
const { findIndex } = require('lodash');
6+
const { findIndex, isEmpty } = require('lodash');
67
const readDirP = require('readdirp');
78
const { helpCategoryMap } = require('../client/utils/challenge-types');
89
const { showUpcomingChanges } = require('../config/env.json');
@@ -22,13 +23,13 @@ const { getSuperOrder, getSuperBlockFromDir } = require('./utils');
2223

2324
const access = util.promisify(fs.access);
2425

25-
const challengesDir = path.resolve(__dirname, './challenges');
26-
const metaDir = path.resolve(challengesDir, '_meta');
27-
exports.challengesDir = challengesDir;
28-
exports.metaDir = metaDir;
26+
const CHALLENGES_DIR = path.resolve(__dirname, 'challenges');
27+
const META_DIR = path.resolve(CHALLENGES_DIR, '_meta');
28+
exports.CHALLENGES_DIR = CHALLENGES_DIR;
29+
exports.META_DIR = META_DIR;
2930

3031
const COMMENT_TRANSLATIONS = createCommentMap(
31-
path.resolve(__dirname, './dictionaries')
32+
path.resolve(__dirname, 'dictionaries')
3233
);
3334

3435
function getTranslatableComments(dictionariesDir) {
@@ -109,20 +110,19 @@ function getTranslationEntry(dicts, { engId, text }) {
109110
if (entry) {
110111
return { ...acc, [lang]: entry };
111112
} else {
112-
throw Error(`Missing translation for comment
113-
'${text}'
114-
with id of ${engId}`);
113+
// default to english
114+
return { ...acc, [lang]: text };
115115
}
116116
}, {});
117117
}
118118

119119
function getChallengesDirForLang(lang) {
120-
return path.resolve(challengesDir, `./${lang}`);
120+
return path.resolve(CHALLENGES_DIR, `${lang}`);
121121
}
122122

123123
function getMetaForBlock(block) {
124124
return JSON.parse(
125-
fs.readFileSync(path.resolve(metaDir, `./${block}/meta.json`), 'utf8')
125+
fs.readFileSync(path.resolve(META_DIR, `${block}/meta.json`), 'utf8')
126126
);
127127
}
128128

@@ -153,7 +153,9 @@ const walk = (root, target, options, cb) => {
153153
};
154154

155155
exports.getChallengesForLang = async function getChallengesForLang(lang) {
156-
const root = getChallengesDirForLang(lang);
156+
// english determines the shape of the curriculum, all other languages mirror
157+
// it.
158+
const root = getChallengesDirForLang('english');
157159
// scaffold the curriculum, first set up the superblocks, then recurse into
158160
// the blocks
159161
const curriculum = await walk(
@@ -162,6 +164,9 @@ exports.getChallengesForLang = async function getChallengesForLang(lang) {
162164
{ type: 'directories', depth: 0 },
163165
buildSuperBlocks
164166
);
167+
Object.entries(curriculum).forEach(([name, superBlock]) => {
168+
assert(!isEmpty(superBlock.blocks), `superblock ${name} has no blocks`);
169+
});
165170
const cb = (file, curriculum) => buildChallenges(file, curriculum, lang);
166171
// fill the scaffold with the challenges
167172
return walk(
@@ -173,10 +178,7 @@ exports.getChallengesForLang = async function getChallengesForLang(lang) {
173178
};
174179

175180
async function buildBlocks({ basename: blockName }, curriculum, superBlock) {
176-
const metaPath = path.resolve(
177-
__dirname,
178-
`./challenges/_meta/${blockName}/meta.json`
179-
);
181+
const metaPath = path.resolve(META_DIR, `${blockName}/meta.json`);
180182

181183
if (fs.existsSync(metaPath)) {
182184
// try to read the file, if the meta path does not exist it should be a certification.
@@ -240,9 +242,10 @@ async function buildChallenges({ path: filePath }, curriculum, lang) {
240242
) {
241243
return;
242244
}
245+
const createChallenge = generateChallengeCreator(CHALLENGES_DIR, lang);
243246
const challenge = isCert
244-
? await createCertification(challengesDir, filePath, lang)
245-
: await createChallenge(challengesDir, filePath, lang, meta);
247+
? await createCertification(CHALLENGES_DIR, filePath, lang)
248+
: await createChallenge(filePath, meta);
246249

247250
challengeBlock.challenges = [...challengeBlock.challenges, challenge];
248251
}
@@ -258,8 +261,7 @@ async function parseTranslation(transPath, dict, lang, parse = parseMD) {
258261
: translatedChal;
259262
}
260263

261-
// eslint-disable-next-line no-unused-vars
262-
async function createCertification(basePath, filePath, lang) {
264+
async function createCertification(basePath, filePath) {
263265
function getFullPath(pathLang) {
264266
return path.resolve(__dirname, basePath, pathLang, filePath);
265267
}
@@ -270,90 +272,111 @@ async function createCertification(basePath, filePath, lang) {
270272
return parseCert(getFullPath('english'));
271273
}
272274

273-
async function createChallenge(basePath, filePath, lang, maybeMeta) {
274-
function getFullPath(pathLang) {
275+
// This is a slightly weird abstraction, but it lets us define helper functions
276+
// without passing around a ton of arguments.
277+
function generateChallengeCreator(basePath, lang) {
278+
function getFullPath(pathLang, filePath) {
275279
return path.resolve(__dirname, basePath, pathLang, filePath);
276280
}
277-
let meta;
278-
if (maybeMeta) {
279-
meta = maybeMeta;
280-
} else {
281-
const metaPath = path.resolve(
282-
metaDir,
283-
`./${getBlockNameFromPath(filePath)}/meta.json`
284-
);
285-
meta = require(metaPath);
286-
}
287-
const { superBlock } = meta;
288-
if (!curriculumLangs.includes(lang))
289-
throw Error(`${lang} is not a accepted language.
290-
Trying to parse ${filePath}`);
291-
if (lang !== 'english' && !(await hasEnglishSource(basePath, filePath)))
292-
throw Error(`Missing English challenge for
281+
282+
async function validate(filePath, superBlock) {
283+
const invalidLang = !curriculumLangs.includes(lang);
284+
if (invalidLang)
285+
throw Error(`${lang} is not a accepted language.
286+
Trying to parse ${filePath}`);
287+
288+
const missingEnglish =
289+
lang !== 'english' && !(await hasEnglishSource(basePath, filePath));
290+
if (missingEnglish)
291+
throw Error(`Missing English challenge for
293292
${filePath}
294293
It should be in
295-
${getFullPath('english')}
294+
${getFullPath('english', filePath)}
296295
`);
297-
// assumes superblock names are unique
298-
// while the auditing is ongoing, we default to English for un-audited certs
299-
// once that's complete, we can revert to using isEnglishChallenge(fullPath)
300-
const useEnglish = lang === 'english' || !isAuditedCert(lang, superBlock);
301-
302-
const challenge = await (useEnglish
303-
? parseMD(getFullPath('english'))
304-
: parseTranslation(getFullPath(lang), COMMENT_TRANSLATIONS, lang));
305-
306-
const challengeOrder = findIndex(
307-
meta.challengeOrder,
308-
([id]) => id === challenge.id
309-
);
310-
const {
311-
name: blockName,
312-
hasEditableBoundaries,
313-
order,
314-
isPrivate,
315-
required = [],
316-
template,
317-
time,
318-
usesMultifileEditor
319-
} = meta;
320-
challenge.block = dasherize(blockName);
321-
challenge.hasEditableBoundaries = !!hasEditableBoundaries;
322-
challenge.order = order;
323-
const superOrder = getSuperOrder(superBlock, {
324-
showNewCurriculum: process.env.SHOW_NEW_CURRICULUM === 'true'
325-
});
326-
if (superOrder !== null) challenge.superOrder = superOrder;
327-
/* Since there can be more than one way to complete a certification (using the
296+
297+
const missingAuditedChallenge =
298+
isAuditedCert(lang, superBlock) &&
299+
!fs.existsSync(getFullPath(lang, filePath));
300+
if (missingAuditedChallenge)
301+
throw Error(`Missing ${lang} audited challenge for
302+
${filePath}
303+
No audited challenges should fallback to English.
304+
`);
305+
}
306+
307+
function addMetaToChallenge(challenge, meta) {
308+
const challengeOrder = findIndex(
309+
meta.challengeOrder,
310+
([id]) => id === challenge.id
311+
);
312+
313+
challenge.block = meta.name ? dasherize(meta.name) : null;
314+
challenge.hasEditableBoundaries = !!meta.hasEditableBoundaries;
315+
challenge.order = meta.order;
316+
const superOrder = getSuperOrder(meta.superBlock, {
317+
showNewCurriculum: process.env.SHOW_NEW_CURRICULUM === 'true'
318+
});
319+
if (superOrder !== null) challenge.superOrder = superOrder;
320+
/* Since there can be more than one way to complete a certification (using the
328321
legacy curriculum or the new one, for instance), we need a certification
329322
field to track which certification this belongs to. */
330-
// TODO: generalize this to all superBlocks
331-
challenge.certification =
332-
superBlock === '2022/responsive-web-design'
333-
? 'responsive-web-design'
334-
: superBlock;
335-
challenge.superBlock = superBlock;
336-
challenge.challengeOrder = challengeOrder;
337-
challenge.isPrivate = challenge.isPrivate || isPrivate;
338-
challenge.required = required.concat(challenge.required || []);
339-
challenge.template = template;
340-
challenge.time = time;
341-
challenge.helpCategory =
342-
challenge.helpCategory || helpCategoryMap[challenge.block];
343-
challenge.translationPending =
344-
lang !== 'english' && !isAuditedCert(lang, superBlock);
345-
challenge.usesMultifileEditor = !!usesMultifileEditor;
346-
if (challenge.challengeFiles) {
347-
// The client expects the challengeFiles to be an array of polyvinyls
348-
challenge.challengeFiles = challengeFilesToPolys(challenge.challengeFiles);
349-
}
350-
if (challenge.solutions?.length) {
351-
// The test runner needs the solutions to be arrays of polyvinyls so it
352-
// can sort them correctly.
353-
challenge.solutions = challenge.solutions.map(challengeFilesToPolys);
323+
// TODO: generalize this to all superBlocks
324+
challenge.certification =
325+
meta.superBlock === '2022/responsive-web-design'
326+
? 'responsive-web-design'
327+
: meta.superBlock;
328+
challenge.superBlock = meta.superBlock;
329+
challenge.challengeOrder = challengeOrder;
330+
challenge.isPrivate = challenge.isPrivate || meta.isPrivate;
331+
challenge.required = (meta.required || []).concat(challenge.required || []);
332+
challenge.template = meta.template;
333+
challenge.time = meta.time;
334+
challenge.helpCategory =
335+
challenge.helpCategory || helpCategoryMap[challenge.block];
336+
challenge.translationPending =
337+
lang !== 'english' && !isAuditedCert(lang, meta.superBlock);
338+
challenge.usesMultifileEditor = !!meta.usesMultifileEditor;
339+
if (challenge.challengeFiles) {
340+
// The client expects the challengeFiles to be an array of polyvinyls
341+
challenge.challengeFiles = challengeFilesToPolys(
342+
challenge.challengeFiles
343+
);
344+
}
345+
if (challenge.solutions?.length) {
346+
// The test runner needs the solutions to be arrays of polyvinyls so it
347+
// can sort them correctly.
348+
challenge.solutions = challenge.solutions.map(challengeFilesToPolys);
349+
}
354350
}
355351

356-
return challenge;
352+
async function createChallenge(filePath, maybeMeta) {
353+
const meta = maybeMeta
354+
? maybeMeta
355+
: require(path.resolve(
356+
META_DIR,
357+
`${getBlockNameFromPath(filePath)}/meta.json`
358+
));
359+
360+
await validate(filePath, meta.superBlock);
361+
362+
const useEnglish =
363+
lang === 'english' ||
364+
!isAuditedCert(lang, meta.superBlock) ||
365+
!fs.existsSync(getFullPath(lang, filePath));
366+
367+
const challenge = await (useEnglish
368+
? parseMD(getFullPath('english', filePath))
369+
: parseTranslation(
370+
getFullPath(lang, filePath),
371+
COMMENT_TRANSLATIONS,
372+
lang
373+
));
374+
375+
addMetaToChallenge(challenge, meta);
376+
377+
return challenge;
378+
}
379+
return createChallenge;
357380
}
358381

359382
function challengeFilesToPolys(files) {
@@ -390,4 +413,4 @@ function getBlockNameFromPath(filePath) {
390413

391414
exports.hasEnglishSource = hasEnglishSource;
392415
exports.parseTranslation = parseTranslation;
393-
exports.createChallenge = createChallenge;
416+
exports.generateChallengeCreator = generateChallengeCreator;

0 commit comments

Comments
 (0)