feat(challenge-editor): add english/task helper scripts (#54111)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Tom
2024-03-19 10:06:22 -05:00
committed by GitHub
parent e8e9f40cc5
commit b3fb38acc4
11 changed files with 378 additions and 31 deletions

View File

@@ -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",

View File

@@ -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>

View 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();

View 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();

View File

@@ -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;

View File

@@ -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',

View 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
};
};

View 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 };

View 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();

View 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();

View File

@@ -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
};