feat: challenge helpers for non-step-based challenges (#50769)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Naomi Carrigan
2023-07-11 10:32:25 -07:00
committed by GitHub
parent ac48d14f8d
commit a374c2fade
15 changed files with 734 additions and 17 deletions

View File

@@ -20,11 +20,15 @@
"scripts": {
"build": "ts-node --project ../tsconfig.json ../tools/scripts/build/build-curriculum",
"create-empty-steps": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/create-empty-steps",
"create-next-challenge": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/create-next-challenge",
"create-next-step": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/create-next-step",
"insert-challenge": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/insert-challenge",
"insert-step": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/insert-step",
"delete-step": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/delete-step",
"delete-challenge": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/delete-challenge",
"lint": "ts-node --project ../tsconfig.json lint-localized",
"repair-meta": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/repair-meta",
"update-challenge-order": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/update-challenge-order",
"update-step-titles": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/update-step-titles",
"test": "ts-node ./node_modules/mocha/bin/mocha.js --delay --exit --reporter progress --bail",
"test:full-output": "cross-env FULL_OUTPUT=true ts-node ./node_modules/mocha/bin/mocha.js --delay --reporter progress"

View File

@@ -500,3 +500,69 @@ Creating and Editing Challenges:
1. [Challenge types](https://github.com/freeCodeCamp/freeCodeCamp/blob/main/client/utils/challenge-types.js#L1-L13) - what the numeric challenge type values mean (enum).
2. [Contributing to FreeCodeCamp - Writing ES6 Challenge Tests](https://www.youtube.com/watch?v=iOdD84OSfAE#t=2h49m55s) - a video following [Ethan Arrowood](https://twitter.com/ArrowoodTech) as he contributes to the old version of the curriculum.
## Helper Scripts
> [!NOTE]
> If you are working with the step-based challenges, refer to the [Work on Practice Projects](how-to-work-on-practice-projects.md) section.
There are a few helper scripts that can be used to manage the challenges in a block. Note that these commands should all be run in the block directory. For example:
```bash
cd curriculum/challenges/english/02-javascript-algorithms-and-data-structures/basic-algorithm-scripting
```
### Add New Challenge
To add a new challenge at the end of a block, call the script:
```bash
pnpm run create-next-challenge
```
This will prompt you for the challenge information and create the challenge file, updating the `meta.json` file with the new challenge information.
### Delete a Challenge
To delete a challenge, call the script:
```bash
pnpm run delete-challenge
```
This will prompt you to select which challenge should be deleted, then delete the file and update the `meta.json` file to remove the challenge from the order.
### Insert a Challenge
To insert a challenge before an existing challenge, call the script:
```bash
pnpm run insert-challenge
```
This will prompt you for the challenge information, then for the challenge to insert before. For example, if your choices are:
```bash
a
b
c
```
And you choose `b`, your new order will be:
```bash
a
new challenge
b
c
```
### Update Challenge Order
If you need to manually re-order the challenges, call the script:
```bash
pnpm run update-challenge-order
```
This will take you through an interactive process to select the order of the challenges.

View File

@@ -0,0 +1,25 @@
import ObjectID from 'bson-objectid';
import { challengeTypeToTemplate } from './helpers/get-challenge-template';
import { newChallengePrompts } from './helpers/new-challenge-prompts';
import { getProjectPath } from './helpers/get-project-info';
import { getMetaData, updateMetaData } from './helpers/project-metadata';
import { createChallengeFile } from './utils';
const createNextChallenge = async () => {
const path = getProjectPath();
const options = await newChallengePrompts();
const templateGenerator = challengeTypeToTemplate[options.challengeType];
if (!templateGenerator) {
return;
}
const challengeId = new ObjectID();
const template = templateGenerator({ ...options, challengeId });
createChallengeFile(options.dashedName, template, path);
const meta = getMetaData();
meta.challengeOrder.push([challengeId.toString(), options.title]);
updateMetaData(meta);
};
void createNextChallenge();

View File

@@ -0,0 +1,38 @@
import { unlink } from 'fs/promises';
import { prompt } from 'inquirer';
import { getProjectPath } from './helpers/get-project-info';
import { getMetaData, updateMetaData } from './helpers/project-metadata';
import { getChallengeOrderFromMeta } from './helpers/get-challenge-order';
import { getFileName } from './helpers/get-file-name';
const deleteChallenge = async () => {
const path = getProjectPath();
const challenges = getChallengeOrderFromMeta();
const challengeToDelete = (await prompt({
name: 'id',
message: 'Which challenge should be deleted?',
type: 'list',
choices: challenges.map(([id, title]) => ({
name: title,
value: id
}))
})) as { id: string };
const indexToDelete = challenges.findIndex(
([id]) => id === challengeToDelete.id
);
const fileToDelete = await getFileName(challengeToDelete.id);
if (!fileToDelete) {
throw new Error(`File not found for challenge ${challengeToDelete.id}`);
}
await unlink(`${path}${fileToDelete}`);
const meta = getMetaData();
meta.challengeOrder.splice(indexToDelete, 1);
updateMetaData(meta);
};
void deleteChallenge();

View File

@@ -0,0 +1,110 @@
import { join } from 'path';
import mock from 'mock-fs';
import {
getChallengeOrderFromFileTree,
getChallengeOrderFromMeta
} from './get-challenge-order';
describe('getChallengeOrderFromMeta helper', () => {
beforeEach(() => {
mock({
curriculum: {
challenges: {
english: {
superblock: {
'mock-project': {
'this-is-a-challenge.md':
'---\nid: 1\ntitle: This is a Challenge\n---',
'what-a-cool-thing.md':
'---\nid: 100\ntitle: What a Cool Thing\n---',
'i-dunno.md': '---\nid: 2\ntitle: I Dunno\n---'
}
}
},
_meta: {
'mock-project': {
'meta.json': `{
"id": "mock-id",
"challengeOrder": [["1","This title is wrong"], ["2","I Dunno"], ["100","What a Cool Thing"]]}
`
}
}
}
}
});
});
it('should load the file order', () => {
process.env.CALLING_DIR = join(
process.cwd(),
'curriculum',
'challenges',
'english',
'superblock',
'mock-project'
);
const challengeOrder = getChallengeOrderFromMeta();
expect(challengeOrder).toEqual([
['1', 'This title is wrong'],
['2', 'I Dunno'],
['100', 'What a Cool Thing']
]);
});
afterEach(() => {
mock.restore();
delete process.env.CALLING_DIR;
});
});
describe('getChallengeOrderFromFileTree helper', () => {
beforeEach(() => {
mock({
curriculum: {
challenges: {
english: {
superblock: {
'mock-project': {
'step-001.md': '---\nid: 1\ntitle: Step 1\n---',
'step-002.md': '---\nid: 100\ntitle: Step 2\n---',
'step-003.md': '---\nid: 2\ntitle: Step 3\n---'
}
}
},
_meta: {
'mock-project': {
'meta.json': `{
"id": "mock-id",
"challengeOrder": [["1","step1"], ["100","step2"], ["2","step3"]]}
`
}
}
}
}
});
});
it('should load the file order', async () => {
expect.assertions(1);
process.env.CALLING_DIR = join(
process.cwd(),
'curriculum',
'challenges',
'english',
'superblock',
'mock-project'
);
const challengeOrder = await getChallengeOrderFromFileTree();
expect(challengeOrder).toEqual([
[1, 'Step 1'],
[100, 'Step 2'],
[2, 'Step 3']
]);
});
afterEach(() => {
mock.restore();
delete process.env.CALLING_DIR;
});
});

View File

@@ -0,0 +1,23 @@
import { readdir } from 'fs/promises';
import { join } from 'path';
import matter from 'gray-matter';
import { getProjectPath } from './get-project-info';
import { getMetaData } from './project-metadata';
export const getChallengeOrderFromFileTree = async (): Promise<
[string, string][]
> => {
const path = getProjectPath();
const fileList = await readdir(path);
const challengeOrder = fileList
.map(file => matter.read(join(path, file)))
.map(({ data }) => [data.id, data.title] as [string, string]);
return challengeOrder;
};
export const getChallengeOrderFromMeta = (): [string, string][] => {
const meta = getMetaData();
return meta.challengeOrder.map(el => [el[0], el[1]]);
};

View File

@@ -0,0 +1,195 @@
import ObjectID from 'bson-objectid';
export interface ChallengeOptions {
challengeId: ObjectID;
title: string;
dashedName: string;
challengeType: string;
}
const buildFrontMatter = ({
challengeId,
title,
dashedName,
challengeType
}: ChallengeOptions) => `---
id: ${challengeId.toString()}
title: ${title}
challengeType: ${challengeType}
dashedName: ${dashedName}
---`;
const buildFrontMatterWithVideo = ({
challengeId,
title,
dashedName,
challengeType
}: ChallengeOptions) => `---
id: ${challengeId.toString()}
videoId: ADD YOUR VIDEO ID HERE!!!
title: ${title}
challengeType: ${challengeType}
dashedName: ${dashedName}
---`;
export const getLegacyChallengeTemplate = (
options: ChallengeOptions
): string => `${buildFrontMatter(options)}
# --description--
${options.title} description.
# --instructions--
${options.title} instructions.
# --hints--
Test 1
\`\`\`js
\`\`\`
# --seed--
\`\`\`js
\`\`\`
# --solutions--
\`\`\`js
\`\`\`
`;
export const getQuizChallengeTemplate = (
options: ChallengeOptions
): string => `${buildFrontMatter(options)}
# --description--
${options.title} description.
# --question--
## --text--
${options.title} question?
## --answers--
Answer 1
---
Answer 2
---
Answer 3
## --video-solution--
1
`;
export const getVideoChallengeTemplate = (
options: ChallengeOptions
): string => `${buildFrontMatterWithVideo(options)}
# --description--
${options.title} description.
# --question--
## --text--
${options.title} question?
## --answers--
Answer 1
---
Answer 2
---
Answer 3
## --video-solution--
1
`;
export const getAssignmentChallengeTemplate = (
options: ChallengeOptions
): string => `${buildFrontMatter(options)}
# --description--
${options.title} description.
# --question--
## --assignment--
${options.title} assignment!
## --text--
${options.title} question?
## --answers--
Answer 1
---
Answer 2
---
Answer 3
## --video-solution--
1
`;
/**
* This should be kept in parity with the challengeTypes in the
* client.
*
* Keys are explicitly marked null so we know the challenge type itself
* exists, and can expand this to use the correct template later on.
*/
export const challengeTypeToTemplate: {
[key: string]: null | ((opts: ChallengeOptions) => string);
} = {
0: getLegacyChallengeTemplate,
1: getLegacyChallengeTemplate,
2: null,
3: getLegacyChallengeTemplate,
4: getLegacyChallengeTemplate,
5: getLegacyChallengeTemplate,
6: getLegacyChallengeTemplate,
7: null,
8: getQuizChallengeTemplate,
9: null,
10: null,
11: getVideoChallengeTemplate,
12: null,
13: null,
14: null,
15: getAssignmentChallengeTemplate,
16: null,
17: null,
18: null,
19: null
};

View File

@@ -0,0 +1,67 @@
import { join } from 'path';
import mock from 'mock-fs';
import { getFileName } from './get-file-name';
describe('getFileName helper', () => {
beforeEach(() => {
mock({
curriculum: {
challenges: {
english: {
superblock: {
'mock-project': {
'this-is-a-challenge.md':
'---\nid: 1\ntitle: This is a Challenge\n---',
'what-a-cool-thing.md':
'---\nid: 100\ntitle: What a Cool Thing\n---',
'i-dunno.md': '---\nid: 2\ntitle: I Dunno\n---'
}
}
},
_meta: {
'mock-project': {
'meta.json': `{
"id": "mock-id",
"challengeOrder": [["1","This title is wrong"], ["2","I Dunno"], ["100","What a Cool Thing"]]}
`
}
}
}
}
});
});
it('should return the file name if found', async () => {
expect.assertions(1);
process.env.CALLING_DIR = join(
process.cwd(),
'curriculum',
'challenges',
'english',
'superblock',
'mock-project'
);
const fileName = await getFileName('1');
expect(fileName).toEqual('this-is-a-challenge.md');
});
it('should return null if not found', async () => {
expect.assertions(1);
process.env.CALLING_DIR = join(
process.cwd(),
'curriculum',
'challenges',
'english',
'superblock',
'mock-project'
);
const fileName = await getFileName('42');
expect(fileName).toBeNull();
});
afterEach(() => {
mock.restore();
delete process.env.CALLING_DIR;
});
});

View File

@@ -0,0 +1,18 @@
import { readdir } from 'fs/promises';
import matter from 'gray-matter';
import { getProjectPath } from './get-project-info';
export const getFileName = async (id: string): Promise<string | null> => {
const path = getProjectPath();
const files = await readdir(path);
for (const file of files) {
if (!file.endsWith('.md')) {
continue;
}
const frontMatter = matter.read(`${path}${file}`);
if (String(frontMatter.data.id) === id) {
return file;
}
}
return null;
};

View File

@@ -0,0 +1,45 @@
import { prompt } from 'inquirer';
import { challengeTypes } from '../../../client/utils/challenge-types';
export const newChallengePrompts = async (): Promise<{
title: string;
dashedName: string;
challengeType: string;
}> => {
const dashedName = await prompt<{ value: string }>({
name: 'value',
message: 'What is the short name (in kebab-case) for this challenge?',
validate: (block: string) => {
if (!block.length) {
return 'please enter a short name';
}
if (/[^a-z0-9-]/.test(block)) {
return 'please use alphanumerical characters and kebab case';
}
return true;
},
filter: (block: string) => {
return block.toLowerCase();
}
});
const title = await prompt<{ value: string }>({
name: 'value',
message: 'What is the title of this challenge?',
default: (title: { value: string }) => title.value
});
const challengeType = await prompt<{ value: string }>({
name: 'value',
message: 'What type of challenge is this?',
type: 'list',
choices: Object.entries(challengeTypes).map(([key, value]) => ({
name: key,
value
}))
});
return {
title: title.value,
dashedName: dashedName.value,
challengeType: challengeType.value
};
};

View File

@@ -0,0 +1,46 @@
import ObjectID from 'bson-objectid';
import { prompt } from 'inquirer';
import { challengeTypeToTemplate } from './helpers/get-challenge-template';
import { newChallengePrompts } from './helpers/new-challenge-prompts';
import { getProjectPath } from './helpers/get-project-info';
import { getMetaData, updateMetaData } from './helpers/project-metadata';
import { createChallengeFile } from './utils';
import { getChallengeOrderFromMeta } from './helpers/get-challenge-order';
const insertChallenge = async () => {
const path = getProjectPath();
const options = await newChallengePrompts();
const challenges = getChallengeOrderFromMeta();
const challengeAfter = await prompt<{ id: string }>({
name: 'id',
message: 'Which challenge should come AFTER this new one?',
type: 'list',
choices: challenges.map(([id, title]) => ({
name: title,
value: id
}))
});
const indexToInsert = challenges.findIndex(
([id]) => id === challengeAfter.id
);
const templateGenerator = challengeTypeToTemplate[options.challengeType];
if (!templateGenerator) {
return;
}
const challengeId = new ObjectID();
const template = templateGenerator({ ...options, challengeId });
createChallengeFile(options.dashedName, template, path);
const meta = getMetaData();
meta.challengeOrder.splice(indexToInsert, 0, [
challengeId.toString(),
options.title
]);
updateMetaData(meta);
};
void insertChallenge();

View File

@@ -1,31 +1,21 @@
import { readdir } from 'fs/promises';
import { join } from 'path';
import * as matter from 'gray-matter';
import { getProjectPath } from './helpers/get-project-info';
import { getMetaData, updateMetaData } from './helpers/project-metadata';
import { getChallengeOrderFromFileTree } from './helpers/get-challenge-order';
const sortByStepNum = (a: string, b: string) =>
parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]);
const repairMeta = async () => {
const path = getProjectPath();
const fileList = await readdir(path);
// [id, title]
const challengeOrder = fileList
.map(file => matter.read(join(path, file)))
.sort((a, b) =>
sortByStepNum(a.data.dashedName as string, b.data.dashedName as string)
)
.map(({ data }) => [data.id, data.title] as [string, string]);
const challengeOrder = await getChallengeOrderFromFileTree();
if (!challengeOrder.every(([, step]) => /Step \d+/.test(step))) {
throw new Error(
'You can only run this command on project-based blocks with step files.'
);
}
const sortedChallengeOrder = challengeOrder.sort((a, b) =>
sortByStepNum(a[1], b[1])
);
const meta = getMetaData();
meta.challengeOrder = challengeOrder;
meta.challengeOrder = sortedChallengeOrder;
updateMetaData(meta);
};

View File

@@ -0,0 +1,55 @@
import { prompt } from 'inquirer';
import { getMetaData, updateMetaData } from './helpers/project-metadata';
import { getChallengeOrderFromMeta } from './helpers/get-challenge-order';
const updateChallengeOrder = async () => {
const oldChallengeOrder = getChallengeOrderFromMeta();
console.log('Current challenge order is: ');
console.table(oldChallengeOrder.map(([_id, title]) => ({ title })));
const newChallengeOrder: [string, string][] = [];
while (oldChallengeOrder.length) {
const nextChallenge = (await prompt({
name: 'id',
message: newChallengeOrder.length
? `What challenge comes after ${
newChallengeOrder[newChallengeOrder.length - 1][1]
}?`
: 'What is the first challenge?',
type: 'list',
choices: oldChallengeOrder.map(([id, title]) => ({
name: title,
value: id
}))
})) as { id: string };
const nextChallengeIndex = oldChallengeOrder.findIndex(
([id]) => id === nextChallenge.id
);
const targetChallenge = oldChallengeOrder[nextChallengeIndex];
oldChallengeOrder.splice(nextChallengeIndex, 1);
newChallengeOrder.push(targetChallenge);
}
console.log('New challenge order is: ');
console.table(newChallengeOrder.map(([_id, title]) => ({ title })));
const confirm = await prompt({
name: 'correct',
message: 'Is this correct?',
type: 'confirm',
default: false
});
if (!confirm.correct) {
console.error('Aborting.');
return;
}
const meta = getMetaData();
meta.challengeOrder = newChallengeOrder;
updateMetaData(meta);
};
void (async () => await updateChallengeOrder())();

View File

@@ -22,7 +22,12 @@ jest.mock('./helpers/get-step-template', () => {
const mockChallengeId = '60d35cf3fe32df2ce8e31b03';
import { getStepTemplate } from './helpers/get-step-template';
import { createStepFile, insertStepIntoMeta, updateStepTitles } from './utils';
import {
createChallengeFile,
createStepFile,
insertStepIntoMeta,
updateStepTitles
} from './utils';
describe('Challenge utils helper scripts', () => {
describe('createStepFile util', () => {
@@ -57,6 +62,27 @@ describe('Challenge utils helper scripts', () => {
});
});
describe('createChallengeFile util', () => {
it('should create the challenge', () => {
mock({
'project/': {
'fake-challenge.md': 'Lorem ipsum...',
'so-many-fakes.md': 'Lorem ipsum...'
}
});
createChallengeFile('hi', 'pretend this is a template', 'project/');
// - Should write a file with a given name and template
const files = glob.sync(`project/*.md`);
expect(files).toEqual([
`project/fake-challenge.md`,
`project/hi.md`,
`project/so-many-fakes.md`
]);
});
});
describe('insertStepIntoMeta util', () => {
it('should update the meta with a new file id and name', () => {
mock({

View File

@@ -31,6 +31,14 @@ const createStepFile = ({
return challengeId;
};
const createChallengeFile = (
title: string,
template: string,
path = getProjectPath()
): void => {
fs.writeFileSync(`${path}${title}.md`, template);
};
interface InsertOptions {
stepNum: number;
stepId: ObjectID;
@@ -98,6 +106,7 @@ const getChallengeSeeds = (
export {
createStepFile,
createChallengeFile,
updateStepTitles,
getChallengeSeeds,
insertStepIntoMeta,