From a6d1e545c0d3fb36493eb82430247507178f472c Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Fri, 27 Feb 2026 13:52:08 +0100 Subject: [PATCH] fix: block creation and hot reloading (#66127) --- client/utils/gatsby/challenge-page-creator.js | 4 +- tools/challenge-helper-scripts/commands.ts | 13 ++-- .../create-language-block.ts | 38 +++++------ .../create-project.ts | 50 +++++++++----- tools/challenge-helper-scripts/create-quiz.ts | 53 +++++++-------- .../challenge-helper-scripts/rename-block.ts | 2 +- tools/challenge-helper-scripts/utils.test.ts | 30 ++++----- tools/challenge-helper-scripts/utils.ts | 14 ++-- .../gatsby-source-challenges/gatsby-node.js | 65 ++++++++++++------- 9 files changed, 145 insertions(+), 124 deletions(-) diff --git a/client/utils/gatsby/challenge-page-creator.js b/client/utils/gatsby/challenge-page-creator.js index ba2a89ef3eb..5772a745ef8 100644 --- a/client/utils/gatsby/challenge-page-creator.js +++ b/client/utils/gatsby/challenge-page-creator.js @@ -135,9 +135,7 @@ exports.createChallengePages = function ( }; }; -// TODO: figure out a cleaner way to get the last challenge in a block. Create -// it during the curriculum build process and attach it to the first challenge? -// That would remove the need to analyse allChallengeEdges. +// TODO: figure out a cleaner way to get the last challenge in a block. function getProjectPreviewConfig(challenge, allChallengeNodes) { const { block } = challenge; diff --git a/tools/challenge-helper-scripts/commands.ts b/tools/challenge-helper-scripts/commands.ts index 71634209896..b8e5e9d5a3f 100644 --- a/tools/challenge-helper-scripts/commands.ts +++ b/tools/challenge-helper-scripts/commands.ts @@ -8,6 +8,7 @@ import { insertStepIntoMeta, updateStepTitles } from './utils.js'; +import { ObjectId } from 'bson'; async function deleteStep(stepNum: number): Promise { if (stepNum < 1) { @@ -54,13 +55,16 @@ async function insertStep(stepNum: number): Promise { const challengeType = previousChallenge?.challengeType ?? nextChallenge?.challengeType; - const stepId = createStepFile({ + const challengeId = new ObjectId(); + + createStepFile({ + challengeId, stepNum, challengeType, challengeSeeds }); - await insertStepIntoMeta({ stepNum, stepId }); + await insertStepIntoMeta({ stepNum, stepId: challengeId }); updateStepTitles(); console.log(`Successfully inserted new step #${stepNum}`); } @@ -74,8 +78,9 @@ async function createEmptySteps(num: number): Promise { const nextStepNum = getMetaData().challengeOrder.length + 1; for (let stepNum = nextStepNum; stepNum < nextStepNum + num; stepNum++) { - const stepId = createStepFile({ stepNum }); - await insertStepIntoMeta({ stepNum, stepId }); + const challengeId = new ObjectId(); + createStepFile({ stepNum, challengeId }); + await insertStepIntoMeta({ stepNum, stepId: challengeId }); } console.log(`Successfully added ${num} steps`); } diff --git a/tools/challenge-helper-scripts/create-language-block.ts b/tools/challenge-helper-scripts/create-language-block.ts index 9e19b451d43..7d2f4517bc5 100644 --- a/tools/challenge-helper-scripts/create-language-block.ts +++ b/tools/challenge-helper-scripts/create-language-block.ts @@ -96,23 +96,7 @@ async function createLanguageBlock( }); const challengeLang = getLangFromSuperBlock(superBlock); - let challengeId: ObjectId; - - if (blockLabel === BlockLabel.quiz) { - challengeId = await createQuizChallenge( - block, - title, - questionCount!, - challengeLang - ); - blockLayout = BlockLayouts.Link; - } else { - challengeId = await createDialogueChallenge( - superBlock, - block, - challengeLang - ); - } + const challengeId: ObjectId = new ObjectId(); await createMetaJson( block, @@ -123,6 +107,19 @@ async function createLanguageBlock( blockLayout ); + if (blockLabel === BlockLabel.quiz) { + await createQuizChallenge( + challengeId, + block, + title, + questionCount!, + challengeLang + ); + blockLayout = BlockLayouts.Link; + } else { + await createDialogueChallenge(challengeId, block, challengeLang); + } + const superblockFilename = ( superBlockToFilename as Record )[superBlock]; @@ -242,7 +239,7 @@ async function createMetaJson( } async function createDialogueChallenge( - superBlock: SuperBlocks, + challengeId: ObjectId, block: string, challengeLang: string ): Promise { @@ -254,18 +251,21 @@ async function createDialogueChallenge( await fs.mkdir(newChallengeDir, { recursive: true }); return createDialogueFile({ + challengeId, projectPath: newChallengeDir + '/', challengeLang: challengeLang }); } async function createQuizChallenge( + challengeId: ObjectId, block: string, title: string, questionCount: number, challengeLang: string ): Promise { return createQuizFile({ + challengeId, projectPath: await createBlockFolder(block), title: title, dashedName: block, @@ -602,4 +602,4 @@ void getAllBlocks() ); } ) - .then(() => console.log('All set. Restart the client to see the changes.')); + .then(() => console.log('All set. Refresh the page to see the changes.')); diff --git a/tools/challenge-helper-scripts/create-project.ts b/tools/challenge-helper-scripts/create-project.ts index 2e92488c87e..2d1b7176620 100644 --- a/tools/challenge-helper-scripts/create-project.ts +++ b/tools/challenge-helper-scripts/create-project.ts @@ -97,27 +97,29 @@ async function createProject(projectArgs: CreateProjectArgs) { projectArgs.title ); + const challengeId = new ObjectId(); + if (projectArgs.blockLabel === BlockLabel.quiz) { if (projectArgs.questionCount == null) { throw new Error( 'Property `questionCount` is null when creating new Quiz Challenge' ); } - const challengeId = await createQuizChallenge( - projectArgs.block, - projectArgs.title, - projectArgs.questionCount - ); - void createMetaJson( + await createMetaJson( projectArgs.superBlock, projectArgs.block, projectArgs.title, projectArgs.helpCategory, challengeId ); + await createQuizChallenge({ + challengeId, + block: projectArgs.block, + title: projectArgs.title, + questionCount: projectArgs.questionCount + }); } else { - const challengeId = await createFirstChallenge(projectArgs.block); - void createMetaJson( + await createMetaJson( projectArgs.superBlock, projectArgs.block, projectArgs.title, @@ -127,7 +129,7 @@ async function createProject(projectArgs: CreateProjectArgs) { projectArgs.blockLabel, projectArgs.blockLayout ); - // TODO: remove once we stop relying on markdown in the client. + await createFirstChallenge({ block: projectArgs.block, challengeId }); } if ( @@ -192,7 +194,13 @@ async function createMetaJson( await writeBlockStructure(block, newMeta); } -async function createFirstChallenge(block: string): Promise { +async function createFirstChallenge({ + block, + challengeId +}: { + block: string; + challengeId: ObjectId; +}) { // TODO: would be nice if the extension made sense for the challenge, but, at // least until react I think they're all going to be html anyway. const challengeSeeds = [ @@ -203,7 +211,8 @@ async function createFirstChallenge(block: string): Promise { } ]; // including trailing slash for compatibility with createStepFile - return createStepFile({ + createStepFile({ + challengeId, projectPath: await createBlockFolder(block), stepNum: 1, challengeType: 0, @@ -212,12 +221,19 @@ async function createFirstChallenge(block: string): Promise { }); } -async function createQuizChallenge( - block: string, - title: string, - questionCount: number -): Promise { +async function createQuizChallenge({ + challengeId, + block, + title, + questionCount +}: { + challengeId: ObjectId; + block: string; + title: string; + questionCount: number; +}): Promise { return createQuizFile({ + challengeId, projectPath: await createBlockFolder(block), title: title, dashedName: block, @@ -396,4 +412,4 @@ void getAllBlocks() }) ) ) - .then(() => console.log('All set. Restart the client to see the changes.')); + .then(() => console.log('All set. Refresh the page to see the changes.')); diff --git a/tools/challenge-helper-scripts/create-quiz.ts b/tools/challenge-helper-scripts/create-quiz.ts index 54ddbec3a02..5a1f78e5a3b 100644 --- a/tools/challenge-helper-scripts/create-quiz.ts +++ b/tools/challenge-helper-scripts/create-quiz.ts @@ -40,25 +40,21 @@ interface CreateQuizArgs { questionCount: number; } -async function createQuiz( - superBlock: SuperBlocks, - block: string, - helpCategory: string, - questionCount: number, - title?: string -) { +async function createQuiz({ + superBlock, + block, + helpCategory, + questionCount, + title +}: CreateQuizArgs) { if (!title) { title = block; } await updateIntroJson(superBlock, block, title); - const challengeId = await createQuizChallenge( - superBlock, - block, - title, - questionCount - ); + const challengeId = new ObjectId(); await createMetaJson(block, title, helpCategory, challengeId); + await createQuizChallenge({ challengeId, block, title, questionCount }); const superblockFilename = ( superBlockToFilename as Record )[superBlock]; @@ -102,13 +98,19 @@ async function createMetaJson( await writeBlockStructure(block, newMeta); } -async function createQuizChallenge( - superBlock: SuperBlocks, - block: string, - title: string, - questionCount: number -): Promise { - return createQuizFile({ +async function createQuizChallenge({ + block, + challengeId, + title, + questionCount +}: { + block: string; + challengeId: ObjectId; + title: string; + questionCount: number; +}) { + createQuizFile({ + challengeId, projectPath: await createBlockFolder(block), title: title, dashedName: block, @@ -173,14 +175,5 @@ void getAllBlocks() } ]) ) - .then( - async ({ - superBlock, - block, - title, - helpCategory, - questionCount - }: CreateQuizArgs) => - await createQuiz(superBlock, block, helpCategory, questionCount, title) - ) + .then(async (args: CreateQuizArgs) => await createQuiz(args)) .then(() => console.log('All set. Restart the client to see the changes.')); diff --git a/tools/challenge-helper-scripts/rename-block.ts b/tools/challenge-helper-scripts/rename-block.ts index 14670b6804f..06d8ba30ab7 100644 --- a/tools/challenge-helper-scripts/rename-block.ts +++ b/tools/challenge-helper-scripts/rename-block.ts @@ -108,4 +108,4 @@ void getAllBlocks() async ({ newBlock, newName, oldBlock }: RenameBlockArgs) => await renameBlock({ newBlock, newName, oldBlock }) ) - .then(() => console.log('All set. Restart the client to see the changes.')); + .then(() => console.log('All set. Refresh the page to see the changes.')); diff --git a/tools/challenge-helper-scripts/utils.test.ts b/tools/challenge-helper-scripts/utils.test.ts index 22b56cc065c..40724f8e297 100644 --- a/tools/challenge-helper-scripts/utils.test.ts +++ b/tools/challenge-helper-scripts/utils.test.ts @@ -23,16 +23,6 @@ vi.mock('gray-matter', () => { }; }); -vi.mock('bson', () => { - return { - ObjectId: vi.fn().mockImplementation(function () { - return { - toString: () => mockChallengeId - }; - }) - }; -}); - vi.mock('./helpers/get-step-template', () => { return { getStepTemplate: vi.fn() @@ -50,7 +40,6 @@ vi.mock('./helpers/project-metadata', () => { }; }); -const mockChallengeId = '60d35cf3fe32df2ce8e31b03'; import { getStepTemplate } from './helpers/get-step-template.js'; import { createChallengeFile, @@ -75,25 +64,26 @@ describe('Challenge utils helper scripts', () => { vi.clearAllMocks(); }); describe('createStepFile util', () => { - it('should create next step and return its identifier', () => { + it('should create next step', () => { process.env.INIT_CWD = projectPath; const mockTemplate = 'Mock template...'; (getStepTemplate as ReturnType).mockReturnValue( mockTemplate ); - const step = createStepFile({ + + const challengeId = new ObjectId(); + + createStepFile({ + challengeId, stepNum: 3, challengeType: 0 }); - expect(step.toString()).toEqual(mockChallengeId); - expect(ObjectId).toHaveBeenCalledTimes(1); - // Internal tasks // - Should generate a template for the step that is being created expect(getStepTemplate).toHaveBeenCalledTimes(1); expect(fs.writeFileSync).toHaveBeenCalledWith( - `${projectPath}/${mockChallengeId}.md`, + `${projectPath}/${challengeId.toString()}.md`, mockTemplate ); }); @@ -146,15 +136,17 @@ describe('Challenge utils helper scripts', () => { it('should call updateMetaData with a new file id and name', async () => { process.env.INIT_CWD = projectPath; + const stepId = new ObjectId(); + await insertStepIntoMeta({ stepNum: 3, - stepId: new ObjectId(mockChallengeId) + stepId }); expect(updateMetaData).toHaveBeenCalledWith({ challengeOrder: [ { id: 'abc', title: 'Step 1' }, // title gets overwritten - { id: mockChallengeId, title: 'Step 2' } + { id: stepId.toString(), title: 'Step 2' } ] }); }); diff --git a/tools/challenge-helper-scripts/utils.ts b/tools/challenge-helper-scripts/utils.ts index c0b9cdc9a3b..c8248efa8d9 100644 --- a/tools/challenge-helper-scripts/utils.ts +++ b/tools/challenge-helper-scripts/utils.ts @@ -17,6 +17,7 @@ import { import { getTemplate } from './helpers/get-challenge-template.js'; interface Options { + challengeId: ObjectId; stepNum: number; challengeType?: number; projectPath?: string; @@ -26,6 +27,7 @@ interface Options { } interface QuizOptions { + challengeId: ObjectId; projectPath?: string; title: string; dashedName: string; @@ -49,13 +51,12 @@ export async function getAllBlocks() { const createStepFile = ({ stepNum, challengeType, + challengeId, projectPath = getProjectPath(), challengeSeeds = [], isFirstChallenge = false, challengeLang -}: Options): ObjectId => { - const challengeId = new ObjectId(); - +}: Options) => { const template = getStepTemplate({ challengeId, challengeSeeds, @@ -66,8 +67,6 @@ const createStepFile = ({ }); fs.writeFileSync(`${projectPath}${challengeId.toString()}.md`, template); - - return challengeId; }; const createChallengeFile = ( @@ -79,13 +78,13 @@ const createChallengeFile = ( }; const createQuizFile = ({ + challengeId, projectPath = getProjectPath(), title, dashedName, questionCount, challengeLang }: QuizOptions): ObjectId => { - const challengeId = new ObjectId(); const challengeType = challengeTypes.quiz.toString(); const template = getTemplate(challengeType); @@ -103,13 +102,14 @@ const createQuizFile = ({ }; const createDialogueFile = ({ + challengeId, projectPath, challengeLang }: { + challengeId: ObjectId; projectPath: string; challengeLang: string; }): ObjectId => { - const challengeId = new ObjectId(); const challengeType = challengeTypes.dialogue.toString(); const template = getTemplate(challengeType); diff --git a/tools/client-plugins/gatsby-source-challenges/gatsby-node.js b/tools/client-plugins/gatsby-source-challenges/gatsby-node.js index dc72bc6aef3..672df17fc8f 100644 --- a/tools/client-plugins/gatsby-source-challenges/gatsby-node.js +++ b/tools/client-plugins/gatsby-source-challenges/gatsby-node.js @@ -1,5 +1,7 @@ const chokidar = require('chokidar'); +const { sortBy } = require('lodash'); + const { getSuperblockStructure } = require('@freecodecamp/curriculum/file-handler'); @@ -16,8 +18,6 @@ const { // createPagesStatefully only runs once, but we need the following when // updating challenges, so they have to be stored in memory. let allChallengeNodes; -let idToNextPathCurrentCurriculum; -let idToPrevPathCurrentCurriculum; const filepathToStatefullyCreatedNodes = new Map(); const filePathToCreatedNodes = new Map(); // reverse lookup, to detect if an updated file has "overwritten" another file @@ -120,6 +120,10 @@ exports.sourceNodes = function sourceChallengesSourceNodes( } function handleChallengeUpdate(filePath, action = 'changed') { + // This has to be a blunt instrument, since we're not watching the structure + // files. If a .md file changes, we have to assume the structure may have + // changed too and update the structure nodes accordingly. + createSuperBlockStructureNodes(); if (action === 'deleted') { // We have to return before calling onSourceChange, since the file is // gone. @@ -254,6 +258,22 @@ exports.sourceNodes = function sourceChallengesSourceNodes( }); }; +const createIdToNextPathMap = nodes => + nodes.reduce((map, node, index) => { + const nextNode = nodes[index + 1]; + const nextPath = nextNode ? nextNode.challenge.fields.slug : null; + if (nextPath) map[node.id] = nextPath; + return map; + }, {}); + +const createIdToPrevPathMap = nodes => + nodes.reduce((map, node, index) => { + const prevNode = nodes[index - 1]; + const prevPath = prevNode ? prevNode.challenge.fields.slug : null; + if (prevPath) map[node.id] = prevPath; + return map; + }, {}); + exports.createPagesStatefully = async function ({ graphql, actions }) { const result = await graphql(` { @@ -322,25 +342,10 @@ exports.createPagesStatefully = async function ({ graphql, actions }) { ({ node }) => node ); - const createIdToNextPathMap = nodes => - nodes.reduce((map, node, index) => { - const nextNode = nodes[index + 1]; - const nextPath = nextNode ? nextNode.challenge.fields.slug : null; - if (nextPath) map[node.id] = nextPath; - return map; - }, {}); - - const createIdToPrevPathMap = nodes => - nodes.reduce((map, node, index) => { - const prevNode = nodes[index - 1]; - const prevPath = prevNode ? prevNode.challenge.fields.slug : null; - if (prevPath) map[node.id] = prevPath; - return map; - }, {}); - - idToNextPathCurrentCurriculum = createIdToNextPathMap(allChallengeNodes); - - idToPrevPathCurrentCurriculum = createIdToPrevPathMap(allChallengeNodes); + const idToNextPathCurrentCurriculum = + createIdToNextPathMap(allChallengeNodes); + const idToPrevPathCurrentCurriculum = + createIdToPrevPathMap(allChallengeNodes); const nodeToPage = createChallengePages(actions.createPage, { idToNextPathCurrentCurriculum, @@ -352,15 +357,27 @@ exports.createPagesStatefully = async function ({ graphql, actions }) { }; exports.createPages = function ({ actions }) { + if (!allChallengeNodes) return; + // actions.createPage has to be called in the createPages hook - const nodes = [...filePathToCreatedNodes.values()].flat(); - for (const node of nodes) { + const newNodes = [...filePathToCreatedNodes.values()].flat(); + // Nodes need sorting so createChallengePages can find the first and last + // challenges in a block. + const sortedNodes = sortBy( + [...allChallengeNodes, ...newNodes], + ['challenge.superOrder', 'challenge.order', 'challenge.challengeOrder'] + ); + + const idToNextPathCurrentCurriculum = createIdToNextPathMap(sortedNodes); + const idToPrevPathCurrentCurriculum = createIdToPrevPathMap(sortedNodes); + + for (const node of newNodes) { const nodeToPage = createChallengePages(actions.createPage, { idToNextPathCurrentCurriculum, idToPrevPathCurrentCurriculum }); - nodeToPage(node, 0, allChallengeNodes); + nodeToPage(node, 0, sortedNodes); } // It's important NOT to clear the createdNodes, since Gatsby deletes any