From b3fb38acc4fe23dd35a446648abef28aefbaea8e Mon Sep 17 00:00:00 2001 From: Tom <20648924+moT01@users.noreply.github.com> Date: Tue, 19 Mar 2024 10:06:22 -0500 Subject: [PATCH] feat(challenge-editor): add english/task helper scripts (#54111) Co-authored-by: Oliver Eyton-Williams --- curriculum/package.json | 4 + .../client/src/components/block/block.tsx | 44 ++++++++ .../create-next-task.ts | 53 +++++++++ tools/challenge-helper-scripts/delete-task.ts | 54 ++++++++++ .../helpers/get-challenge-template.ts | 23 +--- .../helpers/new-challenge-prompts.ts | 13 +-- .../helpers/new-task-prompts.ts | 27 +++++ .../helpers/task-helpers.ts | 9 ++ tools/challenge-helper-scripts/insert-task.ts | 67 ++++++++++++ .../challenge-helper-scripts/reorder-tasks.ts | 14 +++ tools/challenge-helper-scripts/utils.ts | 101 +++++++++++++++++- 11 files changed, 378 insertions(+), 31 deletions(-) create mode 100644 tools/challenge-helper-scripts/create-next-task.ts create mode 100644 tools/challenge-helper-scripts/delete-task.ts create mode 100644 tools/challenge-helper-scripts/helpers/new-task-prompts.ts create mode 100644 tools/challenge-helper-scripts/helpers/task-helpers.ts create mode 100644 tools/challenge-helper-scripts/insert-task.ts create mode 100644 tools/challenge-helper-scripts/reorder-tasks.ts diff --git a/curriculum/package.json b/curriculum/package.json index 594de8093f3..b4380222f77 100644 --- a/curriculum/package.json +++ b/curriculum/package.json @@ -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", diff --git a/tools/challenge-editor/client/src/components/block/block.tsx b/tools/challenge-editor/client/src/components/block/block.tsx index 2eed7770757..13d6e182200 100644 --- a/tools/challenge-editor/client/src/components/block/block.tsx +++ b/tools/challenge-editor/client/src/components/block/block.tsx @@ -11,6 +11,8 @@ const stepBasedSuperblocks = [ '20-upcoming-python' ]; +const taskBasedSuperblocks = ['21-a2-english-for-developers']; + const Block = () => { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -49,6 +51,10 @@ const Block = () => { params.superblock ); + const isTaskBasedSuperblock = taskBasedSuperblocks.includes( + params.superblock + ); + return (

{params.block}

@@ -77,6 +83,44 @@ const Block = () => { Use the step tools.

+ ) : isTaskBasedSuperblock ? ( + <> +

+ Looking to add or remove challenges? Navigate to
+ + freeCodeCamp/curriculum/challenges/english + {`/${params.superblock}/${params.block}/`} + +
+ in your terminal and run the following commands: +

+
    +
  • + pnpm create-next-task: Create the next task style + challenge in this block +
  • +
  • + pnpm create-next-challenge: Create the next challenge + of a different style in this block +
  • +
  • + pnpm insert-task: Create a new task style challenge + in the middle of this block. +
  • +
  • + pnpm delete-task: Delete a task style challenge in + this block. +
  • +
  • + pnpm reorder-tasks: Rename the tasks to the correct + order. +
  • +
+

+ Refresh the page after running a command to see the changes + reflected. +

+ ) : ( <>

diff --git a/tools/challenge-helper-scripts/create-next-task.ts b/tools/challenge-helper-scripts/create-next-task.ts new file mode 100644 index 00000000000..422034c6740 --- /dev/null +++ b/tools/challenge-helper-scripts/create-next-task.ts @@ -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(); diff --git a/tools/challenge-helper-scripts/delete-task.ts b/tools/challenge-helper-scripts/delete-task.ts new file mode 100644 index 00000000000..b761882263b --- /dev/null +++ b/tools/challenge-helper-scripts/delete-task.ts @@ -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(); diff --git a/tools/challenge-helper-scripts/helpers/get-challenge-template.ts b/tools/challenge-helper-scripts/helpers/get-challenge-template.ts index df4fdcefc3e..fe832ca97e1 100644 --- a/tools/challenge-helper-scripts/helpers/get-challenge-template.ts +++ b/tools/challenge-helper-scripts/helpers/get-challenge-template.ts @@ -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; diff --git a/tools/challenge-helper-scripts/helpers/new-challenge-prompts.ts b/tools/challenge-helper-scripts/helpers/new-challenge-prompts.ts index 265cb475d8d..7532cf473c8 100644 --- a/tools/challenge-helper-scripts/helpers/new-challenge-prompts.ts +++ b/tools/challenge-helper-scripts/helpers/new-challenge-prompts.ts @@ -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', diff --git a/tools/challenge-helper-scripts/helpers/new-task-prompts.ts b/tools/challenge-helper-scripts/helpers/new-task-prompts.ts new file mode 100644 index 00000000000..e021ee70577 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/new-task-prompts.ts @@ -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 + }; +}; diff --git a/tools/challenge-helper-scripts/helpers/task-helpers.ts b/tools/challenge-helper-scripts/helpers/task-helpers.ts new file mode 100644 index 00000000000..37025856a3a --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/task-helpers.ts @@ -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 }; diff --git a/tools/challenge-helper-scripts/insert-task.ts b/tools/challenge-helper-scripts/insert-task.ts new file mode 100644 index 00000000000..5944910b0dd --- /dev/null +++ b/tools/challenge-helper-scripts/insert-task.ts @@ -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(); diff --git a/tools/challenge-helper-scripts/reorder-tasks.ts b/tools/challenge-helper-scripts/reorder-tasks.ts new file mode 100644 index 00000000000..2d25e4165d7 --- /dev/null +++ b/tools/challenge-helper-scripts/reorder-tasks.ts @@ -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(); diff --git a/tools/challenge-helper-scripts/utils.ts b/tools/challenge-helper-scripts/utils.ts index db19c0de5e9..3d9081aafa1 100644 --- a/tools/challenge-helper-scripts/utils.ts +++ b/tools/challenge-helper-scripts/utils.ts @@ -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 => { @@ -109,7 +202,11 @@ export { createStepFile, createChallengeFile, updateStepTitles, + updateTaskMeta, + updateTaskMarkdownFiles, getChallengeSeeds, + insertChallengeIntoMeta, insertStepIntoMeta, + deleteChallengeFromMeta, deleteStepFromMeta };