mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-03-17 04:01:49 -04:00
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:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
25
tools/challenge-helper-scripts/create-next-challenge.ts
Normal file
25
tools/challenge-helper-scripts/create-next-challenge.ts
Normal 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();
|
||||
38
tools/challenge-helper-scripts/delete-challenge.ts
Normal file
38
tools/challenge-helper-scripts/delete-challenge.ts
Normal 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();
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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]]);
|
||||
};
|
||||
195
tools/challenge-helper-scripts/helpers/get-challenge-template.ts
Normal file
195
tools/challenge-helper-scripts/helpers/get-challenge-template.ts
Normal 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
|
||||
};
|
||||
67
tools/challenge-helper-scripts/helpers/get-file-name.test.ts
Normal file
67
tools/challenge-helper-scripts/helpers/get-file-name.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
18
tools/challenge-helper-scripts/helpers/get-file-name.ts
Normal file
18
tools/challenge-helper-scripts/helpers/get-file-name.ts
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
46
tools/challenge-helper-scripts/insert-challenge.ts
Normal file
46
tools/challenge-helper-scripts/insert-challenge.ts
Normal 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();
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
55
tools/challenge-helper-scripts/update-challenge-order.ts
Normal file
55
tools/challenge-helper-scripts/update-challenge-order.ts
Normal 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())();
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user