mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-03-16 19:01:02 -04:00
feat(challenge-editor): add english/task helper scripts (#54111)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -23,12 +23,16 @@
|
||||
"create-next-challenge": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/create-next-challenge",
|
||||
"create-this-challenge": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/create-this-challenge",
|
||||
"create-next-step": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/create-next-step",
|
||||
"create-next-task": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/create-next-task",
|
||||
"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",
|
||||
"insert-task": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/insert-task",
|
||||
"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",
|
||||
"delete-task": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/delete-task",
|
||||
"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",
|
||||
"reorder-tasks": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/reorder-tasks",
|
||||
"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",
|
||||
|
||||
@@ -11,6 +11,8 @@ const stepBasedSuperblocks = [
|
||||
'20-upcoming-python'
|
||||
];
|
||||
|
||||
const taskBasedSuperblocks = ['21-a2-english-for-developers'];
|
||||
|
||||
const Block = () => {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -49,6 +51,10 @@ const Block = () => {
|
||||
params.superblock
|
||||
);
|
||||
|
||||
const isTaskBasedSuperblock = taskBasedSuperblocks.includes(
|
||||
params.superblock
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{params.block}</h1>
|
||||
@@ -77,6 +83,44 @@ const Block = () => {
|
||||
Use the step tools.
|
||||
</Link>
|
||||
</p>
|
||||
) : isTaskBasedSuperblock ? (
|
||||
<>
|
||||
<p>
|
||||
Looking to add or remove challenges? Navigate to <br />
|
||||
<code>
|
||||
freeCodeCamp/curriculum/challenges/english
|
||||
{`/${params.superblock}/${params.block}/`}
|
||||
</code>
|
||||
<br />
|
||||
in your terminal and run the following commands:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>pnpm create-next-task</code>: Create the next task style
|
||||
challenge in this block
|
||||
</li>
|
||||
<li>
|
||||
<code>pnpm create-next-challenge</code>: Create the next challenge
|
||||
of a different style in this block
|
||||
</li>
|
||||
<li>
|
||||
<code>pnpm insert-task</code>: Create a new task style challenge
|
||||
in the middle of this block.
|
||||
</li>
|
||||
<li>
|
||||
<code>pnpm delete-task</code>: Delete a task style challenge in
|
||||
this block.
|
||||
</li>
|
||||
<li>
|
||||
<code>pnpm reorder-tasks</code>: Rename the tasks to the correct
|
||||
order.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Refresh the page after running a command to see the changes
|
||||
reflected.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
|
||||
53
tools/challenge-helper-scripts/create-next-task.ts
Normal file
53
tools/challenge-helper-scripts/create-next-task.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import ObjectID from 'bson-objectid';
|
||||
import { getTemplate } from './helpers/get-challenge-template';
|
||||
import { newTaskPrompts } from './helpers/new-task-prompts';
|
||||
import { getProjectPath } from './helpers/get-project-info';
|
||||
import {
|
||||
getMetaData,
|
||||
updateMetaData,
|
||||
validateMetaData
|
||||
} from './helpers/project-metadata';
|
||||
import {
|
||||
createChallengeFile,
|
||||
updateTaskMeta,
|
||||
updateTaskMarkdownFiles
|
||||
} from './utils';
|
||||
|
||||
const createNextTask = async () => {
|
||||
validateMetaData();
|
||||
|
||||
const { challengeType } = await newTaskPrompts();
|
||||
|
||||
// Placeholder title, to be replaced by updateTaskMarkdownFiles
|
||||
const options = {
|
||||
title: `Task 0`,
|
||||
dashedName: 'task-0',
|
||||
challengeType
|
||||
};
|
||||
|
||||
const path = getProjectPath();
|
||||
const template = getTemplate(options.challengeType);
|
||||
const challengeId = new ObjectID();
|
||||
const challengeText = template({ ...options, challengeId });
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
const challengeIdString = challengeId.toString();
|
||||
|
||||
createChallengeFile(challengeIdString, challengeText, path);
|
||||
console.log('Finished creating new task markdown file.');
|
||||
|
||||
const meta = getMetaData();
|
||||
meta.challengeOrder.push({
|
||||
id: challengeIdString,
|
||||
title: options.title
|
||||
});
|
||||
updateMetaData(meta);
|
||||
console.log(`Finished inserting task into 'meta.json' file.`);
|
||||
|
||||
updateTaskMeta();
|
||||
console.log("Finished updating tasks in 'meta.json'.");
|
||||
|
||||
updateTaskMarkdownFiles();
|
||||
console.log('Finished updating task markdown files.');
|
||||
};
|
||||
|
||||
void createNextTask();
|
||||
54
tools/challenge-helper-scripts/delete-task.ts
Normal file
54
tools/challenge-helper-scripts/delete-task.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { unlink } from 'fs/promises';
|
||||
import { prompt } from 'inquirer';
|
||||
import { getProjectPath } from './helpers/get-project-info';
|
||||
import { getChallengeOrderFromMeta } from './helpers/get-challenge-order';
|
||||
import { getFileName } from './helpers/get-file-name';
|
||||
import { validateMetaData } from './helpers/project-metadata';
|
||||
import {
|
||||
deleteChallengeFromMeta,
|
||||
updateTaskMarkdownFiles,
|
||||
updateTaskMeta
|
||||
} from './utils';
|
||||
import { isTaskChallenge } from './helpers/task-helpers';
|
||||
|
||||
const deleteTask = async () => {
|
||||
validateMetaData();
|
||||
|
||||
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}`);
|
||||
console.log(`Finished deleting file: '${fileToDelete}'.`);
|
||||
|
||||
deleteChallengeFromMeta(indexToDelete);
|
||||
console.log(`Finished removing challenge from 'meta.json'.`);
|
||||
|
||||
if (isTaskChallenge(challenges[indexToDelete].title)) {
|
||||
updateTaskMeta();
|
||||
console.log("Finished updating tasks in 'meta.json'.");
|
||||
|
||||
updateTaskMarkdownFiles();
|
||||
console.log(`Finished updating task markdown files.`);
|
||||
}
|
||||
};
|
||||
|
||||
void deleteTask();
|
||||
@@ -37,19 +37,6 @@ challengeType: ${challengeType}
|
||||
dashedName: ${dashedName}
|
||||
---`;
|
||||
|
||||
const buildFrontMatterWithAudio = ({
|
||||
challengeId,
|
||||
title,
|
||||
dashedName,
|
||||
challengeType
|
||||
}: ChallengeOptions) => `---
|
||||
id: ${challengeId.toString()}
|
||||
title: ${sanitizeTitle(title)}
|
||||
challengeType: ${challengeType}
|
||||
dashedName: ${dashedName}
|
||||
audioPath: Add the path to the audio file here. Or, delete this if you don't have audio.
|
||||
---`;
|
||||
|
||||
export const getLegacyChallengeTemplate = (
|
||||
options: ChallengeOptions
|
||||
): string => `${buildFrontMatter(options)}
|
||||
@@ -182,7 +169,7 @@ Answer 3
|
||||
|
||||
export const getMultipleChoiceChallengeTemplate = (
|
||||
options: ChallengeOptions
|
||||
): string => `${buildFrontMatterWithAudio(options)}
|
||||
): string => `${buildFrontMatter(options)}
|
||||
|
||||
# --description--
|
||||
|
||||
@@ -213,7 +200,7 @@ Answer 3
|
||||
|
||||
export const getFillInTheBlankChallengeTemplate = (
|
||||
options: ChallengeOptions
|
||||
): string => `${buildFrontMatterWithAudio(options)}
|
||||
): string => `${buildFrontMatter(options)}
|
||||
|
||||
# --description--
|
||||
|
||||
@@ -240,15 +227,15 @@ It's \`in\`
|
||||
|
||||
export const getDialogueChallengeTemplate = (
|
||||
options: ChallengeOptions
|
||||
): string => `${buildFrontMatterWithVideo(options)}
|
||||
): string => `${buildFrontMatter(options)}
|
||||
|
||||
# --description--
|
||||
|
||||
${options.title} description.
|
||||
Watch the video below to understand the context of the upcoming lessons.
|
||||
|
||||
## --assignment--
|
||||
|
||||
${options.title} assignment!
|
||||
Watch the video.
|
||||
`;
|
||||
|
||||
type Template = (opts: ChallengeOptions) => string;
|
||||
|
||||
@@ -18,17 +18,8 @@ export const newChallengePrompts = async (): Promise<{
|
||||
});
|
||||
|
||||
const lastStep = getLastStep().stepNum;
|
||||
const challengeTypeNum = parseInt(challengeType.value, 10);
|
||||
const isTaskStep =
|
||||
challengeTypeNum === challengeTypes.fillInTheBlank ||
|
||||
challengeTypeNum === challengeTypes.dialogue;
|
||||
|
||||
const defaultTitle = isTaskStep
|
||||
? `Task ${lastStep + 1}`
|
||||
: `Step ${lastStep + 1}`;
|
||||
const defaultDashedName = isTaskStep
|
||||
? `task-${lastStep + 1}`
|
||||
: `step-${lastStep + 1}`;
|
||||
const defaultTitle = `Step ${lastStep + 1}`;
|
||||
const defaultDashedName = `step-${lastStep + 1}`;
|
||||
|
||||
const dashedName = await prompt<{ value: string }>({
|
||||
name: 'value',
|
||||
|
||||
27
tools/challenge-helper-scripts/helpers/new-task-prompts.ts
Normal file
27
tools/challenge-helper-scripts/helpers/new-task-prompts.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { prompt } from 'inquirer';
|
||||
import { challengeTypes } from '../../../shared/config/challenge-types';
|
||||
|
||||
const taskChallenges = [
|
||||
challengeTypes.multipleChoice,
|
||||
challengeTypes.fillInTheBlank
|
||||
];
|
||||
|
||||
export const newTaskPrompts = async (): Promise<{
|
||||
challengeType: string;
|
||||
}> => {
|
||||
const challengeType = await prompt<{ value: string }>({
|
||||
name: 'value',
|
||||
message: 'What type of task challenge is this?',
|
||||
type: 'list',
|
||||
choices: Object.entries(challengeTypes)
|
||||
.filter(entry => taskChallenges.includes(entry[1]))
|
||||
.map(([key, value]) => ({
|
||||
name: key,
|
||||
value
|
||||
}))
|
||||
});
|
||||
|
||||
return {
|
||||
challengeType: challengeType.value
|
||||
};
|
||||
};
|
||||
9
tools/challenge-helper-scripts/helpers/task-helpers.ts
Normal file
9
tools/challenge-helper-scripts/helpers/task-helpers.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
function isTaskChallenge(title: string): boolean {
|
||||
return /^\s*task\s\d+$/gi.test(title);
|
||||
}
|
||||
|
||||
function getTaskNumberFromTitle(title: string): number {
|
||||
return parseInt(title.replace(/\D/g, ''), 10);
|
||||
}
|
||||
|
||||
export { getTaskNumberFromTitle, isTaskChallenge };
|
||||
67
tools/challenge-helper-scripts/insert-task.ts
Normal file
67
tools/challenge-helper-scripts/insert-task.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import ObjectID from 'bson-objectid';
|
||||
import { prompt } from 'inquirer';
|
||||
import { getTemplate } from './helpers/get-challenge-template';
|
||||
import { newTaskPrompts } from './helpers/new-task-prompts';
|
||||
import { getProjectPath } from './helpers/get-project-info';
|
||||
import { validateMetaData } from './helpers/project-metadata';
|
||||
import {
|
||||
createChallengeFile,
|
||||
insertChallengeIntoMeta,
|
||||
updateTaskMeta,
|
||||
updateTaskMarkdownFiles
|
||||
} from './utils';
|
||||
import { getChallengeOrderFromMeta } from './helpers/get-challenge-order';
|
||||
|
||||
const insertChallenge = async () => {
|
||||
validateMetaData();
|
||||
|
||||
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 newTaskTitle = 'Task 0';
|
||||
|
||||
const { challengeType } = await newTaskPrompts();
|
||||
|
||||
const options = {
|
||||
title: newTaskTitle,
|
||||
dashedName: 'task-0',
|
||||
challengeType
|
||||
};
|
||||
|
||||
const path = getProjectPath();
|
||||
const template = getTemplate(challengeType);
|
||||
const challengeId = new ObjectID();
|
||||
const challengeText = template({ ...options, challengeId });
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
const challengeIdString = challengeId.toString();
|
||||
|
||||
createChallengeFile(challengeIdString, challengeText, path);
|
||||
console.log('Finished creating new task markdown file.');
|
||||
|
||||
insertChallengeIntoMeta({
|
||||
index: indexToInsert,
|
||||
id: challengeId,
|
||||
title: newTaskTitle
|
||||
});
|
||||
console.log(`Finished inserting task into 'meta.json' file.`);
|
||||
|
||||
updateTaskMeta();
|
||||
console.log("Finished updating tasks in 'meta.json'.");
|
||||
|
||||
updateTaskMarkdownFiles();
|
||||
console.log('Finished updating task markdown files.');
|
||||
};
|
||||
|
||||
void insertChallenge();
|
||||
14
tools/challenge-helper-scripts/reorder-tasks.ts
Normal file
14
tools/challenge-helper-scripts/reorder-tasks.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { validateMetaData } from './helpers/project-metadata';
|
||||
import { updateTaskMeta, updateTaskMarkdownFiles } from './utils';
|
||||
|
||||
const reorderTasks = () => {
|
||||
validateMetaData();
|
||||
|
||||
updateTaskMeta();
|
||||
console.log("Finished updating tasks in 'meta.json'.");
|
||||
|
||||
updateTaskMarkdownFiles();
|
||||
console.log('Finished updating task markdown files.');
|
||||
};
|
||||
|
||||
void reorderTasks();
|
||||
@@ -6,6 +6,10 @@ import { parseMDSync } from '../challenge-parser/parser';
|
||||
import { getMetaData, updateMetaData } from './helpers/project-metadata';
|
||||
import { getProjectPath } from './helpers/get-project-info';
|
||||
import { ChallengeSeed, getStepTemplate } from './helpers/get-step-template';
|
||||
import {
|
||||
isTaskChallenge,
|
||||
getTaskNumberFromTitle
|
||||
} from './helpers/task-helpers';
|
||||
|
||||
interface Options {
|
||||
stepNum: number;
|
||||
@@ -33,11 +37,11 @@ const createStepFile = ({
|
||||
};
|
||||
|
||||
const createChallengeFile = (
|
||||
title: string,
|
||||
filename: string,
|
||||
template: string,
|
||||
path = getProjectPath()
|
||||
): void => {
|
||||
fs.writeFileSync(`${path}${title}.md`, template);
|
||||
fs.writeFileSync(`${path}${filename}.md`, template);
|
||||
};
|
||||
|
||||
interface InsertOptions {
|
||||
@@ -45,6 +49,25 @@ interface InsertOptions {
|
||||
stepId: ObjectID;
|
||||
}
|
||||
|
||||
interface InsertChallengeOptions {
|
||||
index: number;
|
||||
id: ObjectID;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function insertChallengeIntoMeta({
|
||||
index,
|
||||
id,
|
||||
title
|
||||
}: InsertChallengeOptions): void {
|
||||
const existingMeta = getMetaData();
|
||||
const challengeOrder = [...existingMeta.challengeOrder];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
challengeOrder.splice(index, 0, { id: id.toString(), title });
|
||||
updateMetaData({ ...existingMeta, challengeOrder });
|
||||
}
|
||||
|
||||
function insertStepIntoMeta({ stepNum, stepId }: InsertOptions): void {
|
||||
const existingMeta = getMetaData();
|
||||
const oldOrder = [...existingMeta.challengeOrder];
|
||||
@@ -72,6 +95,33 @@ function deleteStepFromMeta({ stepNum }: { stepNum: number }): void {
|
||||
updateMetaData({ ...existingMeta, challengeOrder });
|
||||
}
|
||||
|
||||
function deleteChallengeFromMeta(challengeIndex: number): void {
|
||||
const existingMeta = getMetaData();
|
||||
const challengeOrder = [...existingMeta.challengeOrder];
|
||||
challengeOrder.splice(challengeIndex, 1);
|
||||
updateMetaData({ ...existingMeta, challengeOrder });
|
||||
}
|
||||
|
||||
function updateTaskMeta() {
|
||||
const existingMeta = getMetaData();
|
||||
const oldOrder = [...existingMeta.challengeOrder];
|
||||
|
||||
let currentTaskNumber = 1;
|
||||
|
||||
const challengeOrder = oldOrder.map(challenge => {
|
||||
if (isTaskChallenge(challenge.title)) {
|
||||
return {
|
||||
id: challenge.id,
|
||||
title: `Task ${currentTaskNumber++}`
|
||||
};
|
||||
} else {
|
||||
return challenge;
|
||||
}
|
||||
});
|
||||
|
||||
updateMetaData({ ...existingMeta, challengeOrder });
|
||||
}
|
||||
|
||||
const updateStepTitles = (): void => {
|
||||
const meta = getMetaData();
|
||||
|
||||
@@ -98,6 +148,49 @@ const updateStepTitles = (): void => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateTaskMarkdownFiles = (): void => {
|
||||
const meta = getMetaData();
|
||||
|
||||
const fileNames: string[] = [];
|
||||
fs.readdirSync(getProjectPath()).forEach(fileName => {
|
||||
if (path.extname(fileName).toLowerCase() === '.md') {
|
||||
fileNames.push(fileName);
|
||||
}
|
||||
});
|
||||
|
||||
fileNames.forEach(fileName => {
|
||||
const filePath = `${getProjectPath()}${fileName}`;
|
||||
const frontMatter = matter.read(filePath);
|
||||
|
||||
const challenge = meta.challengeOrder.find(
|
||||
({ id }) => id === frontMatter.data.id
|
||||
);
|
||||
|
||||
if (!challenge || !challenge.title) {
|
||||
throw new Error(
|
||||
`Challenge id from ${fileName} not found in meta.json file.`
|
||||
);
|
||||
}
|
||||
|
||||
// only update task challenges, dialogue challenges shouldn't change
|
||||
if (isTaskChallenge(challenge.title)) {
|
||||
const newTaskNumber = getTaskNumberFromTitle(challenge.title);
|
||||
|
||||
const title = `Task ${newTaskNumber}`;
|
||||
const dashedName = `task-${newTaskNumber}`;
|
||||
const newData = {
|
||||
...frontMatter.data,
|
||||
title,
|
||||
dashedName
|
||||
};
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
matter.stringify(frontMatter.content, newData)
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getChallengeSeeds = (
|
||||
challengeFilePath: string
|
||||
): Record<string, ChallengeSeed> => {
|
||||
@@ -109,7 +202,11 @@ export {
|
||||
createStepFile,
|
||||
createChallengeFile,
|
||||
updateStepTitles,
|
||||
updateTaskMeta,
|
||||
updateTaskMarkdownFiles,
|
||||
getChallengeSeeds,
|
||||
insertChallengeIntoMeta,
|
||||
insertStepIntoMeta,
|
||||
deleteChallengeFromMeta,
|
||||
deleteStepFromMeta
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user