fix: block creation and hot reloading (#66127)

This commit is contained in:
Oliver Eyton-Williams
2026-02-27 13:52:08 +01:00
committed by GitHub
parent ba61d33698
commit a6d1e545c0
9 changed files with 145 additions and 124 deletions
@@ -135,9 +135,7 @@ exports.createChallengePages = function (
}; };
}; };
// TODO: figure out a cleaner way to get the last challenge in a block. Create // TODO: figure out a cleaner way to get the last challenge in a block.
// it during the curriculum build process and attach it to the first challenge?
// That would remove the need to analyse allChallengeEdges.
function getProjectPreviewConfig(challenge, allChallengeNodes) { function getProjectPreviewConfig(challenge, allChallengeNodes) {
const { block } = challenge; const { block } = challenge;
+9 -4
View File
@@ -8,6 +8,7 @@ import {
insertStepIntoMeta, insertStepIntoMeta,
updateStepTitles updateStepTitles
} from './utils.js'; } from './utils.js';
import { ObjectId } from 'bson';
async function deleteStep(stepNum: number): Promise<void> { async function deleteStep(stepNum: number): Promise<void> {
if (stepNum < 1) { if (stepNum < 1) {
@@ -54,13 +55,16 @@ async function insertStep(stepNum: number): Promise<void> {
const challengeType = const challengeType =
previousChallenge?.challengeType ?? nextChallenge?.challengeType; previousChallenge?.challengeType ?? nextChallenge?.challengeType;
const stepId = createStepFile({ const challengeId = new ObjectId();
createStepFile({
challengeId,
stepNum, stepNum,
challengeType, challengeType,
challengeSeeds challengeSeeds
}); });
await insertStepIntoMeta({ stepNum, stepId }); await insertStepIntoMeta({ stepNum, stepId: challengeId });
updateStepTitles(); updateStepTitles();
console.log(`Successfully inserted new step #${stepNum}`); console.log(`Successfully inserted new step #${stepNum}`);
} }
@@ -74,8 +78,9 @@ async function createEmptySteps(num: number): Promise<void> {
const nextStepNum = getMetaData().challengeOrder.length + 1; const nextStepNum = getMetaData().challengeOrder.length + 1;
for (let stepNum = nextStepNum; stepNum < nextStepNum + num; stepNum++) { for (let stepNum = nextStepNum; stepNum < nextStepNum + num; stepNum++) {
const stepId = createStepFile({ stepNum }); const challengeId = new ObjectId();
await insertStepIntoMeta({ stepNum, stepId }); createStepFile({ stepNum, challengeId });
await insertStepIntoMeta({ stepNum, stepId: challengeId });
} }
console.log(`Successfully added ${num} steps`); console.log(`Successfully added ${num} steps`);
} }
@@ -96,23 +96,7 @@ async function createLanguageBlock(
}); });
const challengeLang = getLangFromSuperBlock(superBlock); const challengeLang = getLangFromSuperBlock(superBlock);
let challengeId: ObjectId; const challengeId: ObjectId = new ObjectId();
if (blockLabel === BlockLabel.quiz) {
challengeId = await createQuizChallenge(
block,
title,
questionCount!,
challengeLang
);
blockLayout = BlockLayouts.Link;
} else {
challengeId = await createDialogueChallenge(
superBlock,
block,
challengeLang
);
}
await createMetaJson( await createMetaJson(
block, block,
@@ -123,6 +107,19 @@ async function createLanguageBlock(
blockLayout blockLayout
); );
if (blockLabel === BlockLabel.quiz) {
await createQuizChallenge(
challengeId,
block,
title,
questionCount!,
challengeLang
);
blockLayout = BlockLayouts.Link;
} else {
await createDialogueChallenge(challengeId, block, challengeLang);
}
const superblockFilename = ( const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string> superBlockToFilename as Record<SuperBlocks, string>
)[superBlock]; )[superBlock];
@@ -242,7 +239,7 @@ async function createMetaJson(
} }
async function createDialogueChallenge( async function createDialogueChallenge(
superBlock: SuperBlocks, challengeId: ObjectId,
block: string, block: string,
challengeLang: string challengeLang: string
): Promise<ObjectId> { ): Promise<ObjectId> {
@@ -254,18 +251,21 @@ async function createDialogueChallenge(
await fs.mkdir(newChallengeDir, { recursive: true }); await fs.mkdir(newChallengeDir, { recursive: true });
return createDialogueFile({ return createDialogueFile({
challengeId,
projectPath: newChallengeDir + '/', projectPath: newChallengeDir + '/',
challengeLang: challengeLang challengeLang: challengeLang
}); });
} }
async function createQuizChallenge( async function createQuizChallenge(
challengeId: ObjectId,
block: string, block: string,
title: string, title: string,
questionCount: number, questionCount: number,
challengeLang: string challengeLang: string
): Promise<ObjectId> { ): Promise<ObjectId> {
return createQuizFile({ return createQuizFile({
challengeId,
projectPath: await createBlockFolder(block), projectPath: await createBlockFolder(block),
title: title, title: title,
dashedName: block, 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.'));
@@ -97,27 +97,29 @@ async function createProject(projectArgs: CreateProjectArgs) {
projectArgs.title projectArgs.title
); );
const challengeId = new ObjectId();
if (projectArgs.blockLabel === BlockLabel.quiz) { if (projectArgs.blockLabel === BlockLabel.quiz) {
if (projectArgs.questionCount == null) { if (projectArgs.questionCount == null) {
throw new Error( throw new Error(
'Property `questionCount` is null when creating new Quiz Challenge' 'Property `questionCount` is null when creating new Quiz Challenge'
); );
} }
const challengeId = await createQuizChallenge( await createMetaJson(
projectArgs.block,
projectArgs.title,
projectArgs.questionCount
);
void createMetaJson(
projectArgs.superBlock, projectArgs.superBlock,
projectArgs.block, projectArgs.block,
projectArgs.title, projectArgs.title,
projectArgs.helpCategory, projectArgs.helpCategory,
challengeId challengeId
); );
await createQuizChallenge({
challengeId,
block: projectArgs.block,
title: projectArgs.title,
questionCount: projectArgs.questionCount
});
} else { } else {
const challengeId = await createFirstChallenge(projectArgs.block); await createMetaJson(
void createMetaJson(
projectArgs.superBlock, projectArgs.superBlock,
projectArgs.block, projectArgs.block,
projectArgs.title, projectArgs.title,
@@ -127,7 +129,7 @@ async function createProject(projectArgs: CreateProjectArgs) {
projectArgs.blockLabel, projectArgs.blockLabel,
projectArgs.blockLayout projectArgs.blockLayout
); );
// TODO: remove once we stop relying on markdown in the client. await createFirstChallenge({ block: projectArgs.block, challengeId });
} }
if ( if (
@@ -192,7 +194,13 @@ async function createMetaJson(
await writeBlockStructure(block, newMeta); await writeBlockStructure(block, newMeta);
} }
async function createFirstChallenge(block: string): Promise<ObjectId> { async function createFirstChallenge({
block,
challengeId
}: {
block: string;
challengeId: ObjectId;
}) {
// TODO: would be nice if the extension made sense for the challenge, but, at // 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. // least until react I think they're all going to be html anyway.
const challengeSeeds = [ const challengeSeeds = [
@@ -203,7 +211,8 @@ async function createFirstChallenge(block: string): Promise<ObjectId> {
} }
]; ];
// including trailing slash for compatibility with createStepFile // including trailing slash for compatibility with createStepFile
return createStepFile({ createStepFile({
challengeId,
projectPath: await createBlockFolder(block), projectPath: await createBlockFolder(block),
stepNum: 1, stepNum: 1,
challengeType: 0, challengeType: 0,
@@ -212,12 +221,19 @@ async function createFirstChallenge(block: string): Promise<ObjectId> {
}); });
} }
async function createQuizChallenge( async function createQuizChallenge({
block: string, challengeId,
title: string, block,
questionCount: number title,
): Promise<ObjectId> { questionCount
}: {
challengeId: ObjectId;
block: string;
title: string;
questionCount: number;
}): Promise<ObjectId> {
return createQuizFile({ return createQuizFile({
challengeId,
projectPath: await createBlockFolder(block), projectPath: await createBlockFolder(block),
title: title, title: title,
dashedName: block, 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.'));
+23 -30
View File
@@ -40,25 +40,21 @@ interface CreateQuizArgs {
questionCount: number; questionCount: number;
} }
async function createQuiz( async function createQuiz({
superBlock: SuperBlocks, superBlock,
block: string, block,
helpCategory: string, helpCategory,
questionCount: number, questionCount,
title?: string title
) { }: CreateQuizArgs) {
if (!title) { if (!title) {
title = block; title = block;
} }
await updateIntroJson(superBlock, block, title); await updateIntroJson(superBlock, block, title);
const challengeId = await createQuizChallenge( const challengeId = new ObjectId();
superBlock,
block,
title,
questionCount
);
await createMetaJson(block, title, helpCategory, challengeId); await createMetaJson(block, title, helpCategory, challengeId);
await createQuizChallenge({ challengeId, block, title, questionCount });
const superblockFilename = ( const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string> superBlockToFilename as Record<SuperBlocks, string>
)[superBlock]; )[superBlock];
@@ -102,13 +98,19 @@ async function createMetaJson(
await writeBlockStructure(block, newMeta); await writeBlockStructure(block, newMeta);
} }
async function createQuizChallenge( async function createQuizChallenge({
superBlock: SuperBlocks, block,
block: string, challengeId,
title: string, title,
questionCount: number questionCount
): Promise<ObjectId> { }: {
return createQuizFile({ block: string;
challengeId: ObjectId;
title: string;
questionCount: number;
}) {
createQuizFile({
challengeId,
projectPath: await createBlockFolder(block), projectPath: await createBlockFolder(block),
title: title, title: title,
dashedName: block, dashedName: block,
@@ -173,14 +175,5 @@ void getAllBlocks()
} }
]) ])
) )
.then( .then(async (args: CreateQuizArgs) => await createQuiz(args))
async ({
superBlock,
block,
title,
helpCategory,
questionCount
}: CreateQuizArgs) =>
await createQuiz(superBlock, block, helpCategory, questionCount, title)
)
.then(() => console.log('All set. Restart the client to see the changes.')); .then(() => console.log('All set. Restart the client to see the changes.'));
@@ -108,4 +108,4 @@ void getAllBlocks()
async ({ newBlock, newName, oldBlock }: RenameBlockArgs) => async ({ newBlock, newName, oldBlock }: RenameBlockArgs) =>
await renameBlock({ newBlock, newName, oldBlock }) 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.'));
+11 -19
View File
@@ -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', () => { vi.mock('./helpers/get-step-template', () => {
return { return {
getStepTemplate: vi.fn() getStepTemplate: vi.fn()
@@ -50,7 +40,6 @@ vi.mock('./helpers/project-metadata', () => {
}; };
}); });
const mockChallengeId = '60d35cf3fe32df2ce8e31b03';
import { getStepTemplate } from './helpers/get-step-template.js'; import { getStepTemplate } from './helpers/get-step-template.js';
import { import {
createChallengeFile, createChallengeFile,
@@ -75,25 +64,26 @@ describe('Challenge utils helper scripts', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('createStepFile util', () => { describe('createStepFile util', () => {
it('should create next step and return its identifier', () => { it('should create next step', () => {
process.env.INIT_CWD = projectPath; process.env.INIT_CWD = projectPath;
const mockTemplate = 'Mock template...'; const mockTemplate = 'Mock template...';
(getStepTemplate as ReturnType<typeof vi.fn>).mockReturnValue( (getStepTemplate as ReturnType<typeof vi.fn>).mockReturnValue(
mockTemplate mockTemplate
); );
const step = createStepFile({
const challengeId = new ObjectId();
createStepFile({
challengeId,
stepNum: 3, stepNum: 3,
challengeType: 0 challengeType: 0
}); });
expect(step.toString()).toEqual(mockChallengeId);
expect(ObjectId).toHaveBeenCalledTimes(1);
// Internal tasks // Internal tasks
// - Should generate a template for the step that is being created // - Should generate a template for the step that is being created
expect(getStepTemplate).toHaveBeenCalledTimes(1); expect(getStepTemplate).toHaveBeenCalledTimes(1);
expect(fs.writeFileSync).toHaveBeenCalledWith( expect(fs.writeFileSync).toHaveBeenCalledWith(
`${projectPath}/${mockChallengeId}.md`, `${projectPath}/${challengeId.toString()}.md`,
mockTemplate mockTemplate
); );
}); });
@@ -146,15 +136,17 @@ describe('Challenge utils helper scripts', () => {
it('should call updateMetaData with a new file id and name', async () => { it('should call updateMetaData with a new file id and name', async () => {
process.env.INIT_CWD = projectPath; process.env.INIT_CWD = projectPath;
const stepId = new ObjectId();
await insertStepIntoMeta({ await insertStepIntoMeta({
stepNum: 3, stepNum: 3,
stepId: new ObjectId(mockChallengeId) stepId
}); });
expect(updateMetaData).toHaveBeenCalledWith({ expect(updateMetaData).toHaveBeenCalledWith({
challengeOrder: [ challengeOrder: [
{ id: 'abc', title: 'Step 1' }, // title gets overwritten { id: 'abc', title: 'Step 1' }, // title gets overwritten
{ id: mockChallengeId, title: 'Step 2' } { id: stepId.toString(), title: 'Step 2' }
] ]
}); });
}); });
+7 -7
View File
@@ -17,6 +17,7 @@ import {
import { getTemplate } from './helpers/get-challenge-template.js'; import { getTemplate } from './helpers/get-challenge-template.js';
interface Options { interface Options {
challengeId: ObjectId;
stepNum: number; stepNum: number;
challengeType?: number; challengeType?: number;
projectPath?: string; projectPath?: string;
@@ -26,6 +27,7 @@ interface Options {
} }
interface QuizOptions { interface QuizOptions {
challengeId: ObjectId;
projectPath?: string; projectPath?: string;
title: string; title: string;
dashedName: string; dashedName: string;
@@ -49,13 +51,12 @@ export async function getAllBlocks() {
const createStepFile = ({ const createStepFile = ({
stepNum, stepNum,
challengeType, challengeType,
challengeId,
projectPath = getProjectPath(), projectPath = getProjectPath(),
challengeSeeds = [], challengeSeeds = [],
isFirstChallenge = false, isFirstChallenge = false,
challengeLang challengeLang
}: Options): ObjectId => { }: Options) => {
const challengeId = new ObjectId();
const template = getStepTemplate({ const template = getStepTemplate({
challengeId, challengeId,
challengeSeeds, challengeSeeds,
@@ -66,8 +67,6 @@ const createStepFile = ({
}); });
fs.writeFileSync(`${projectPath}${challengeId.toString()}.md`, template); fs.writeFileSync(`${projectPath}${challengeId.toString()}.md`, template);
return challengeId;
}; };
const createChallengeFile = ( const createChallengeFile = (
@@ -79,13 +78,13 @@ const createChallengeFile = (
}; };
const createQuizFile = ({ const createQuizFile = ({
challengeId,
projectPath = getProjectPath(), projectPath = getProjectPath(),
title, title,
dashedName, dashedName,
questionCount, questionCount,
challengeLang challengeLang
}: QuizOptions): ObjectId => { }: QuizOptions): ObjectId => {
const challengeId = new ObjectId();
const challengeType = challengeTypes.quiz.toString(); const challengeType = challengeTypes.quiz.toString();
const template = getTemplate(challengeType); const template = getTemplate(challengeType);
@@ -103,13 +102,14 @@ const createQuizFile = ({
}; };
const createDialogueFile = ({ const createDialogueFile = ({
challengeId,
projectPath, projectPath,
challengeLang challengeLang
}: { }: {
challengeId: ObjectId;
projectPath: string; projectPath: string;
challengeLang: string; challengeLang: string;
}): ObjectId => { }): ObjectId => {
const challengeId = new ObjectId();
const challengeType = challengeTypes.dialogue.toString(); const challengeType = challengeTypes.dialogue.toString();
const template = getTemplate(challengeType); const template = getTemplate(challengeType);
@@ -1,5 +1,7 @@
const chokidar = require('chokidar'); const chokidar = require('chokidar');
const { sortBy } = require('lodash');
const { const {
getSuperblockStructure getSuperblockStructure
} = require('@freecodecamp/curriculum/file-handler'); } = require('@freecodecamp/curriculum/file-handler');
@@ -16,8 +18,6 @@ const {
// createPagesStatefully only runs once, but we need the following when // createPagesStatefully only runs once, but we need the following when
// updating challenges, so they have to be stored in memory. // updating challenges, so they have to be stored in memory.
let allChallengeNodes; let allChallengeNodes;
let idToNextPathCurrentCurriculum;
let idToPrevPathCurrentCurriculum;
const filepathToStatefullyCreatedNodes = new Map(); const filepathToStatefullyCreatedNodes = new Map();
const filePathToCreatedNodes = new Map(); const filePathToCreatedNodes = new Map();
// reverse lookup, to detect if an updated file has "overwritten" another file // 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') { 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') { if (action === 'deleted') {
// We have to return before calling onSourceChange, since the file is // We have to return before calling onSourceChange, since the file is
// gone. // 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 }) { exports.createPagesStatefully = async function ({ graphql, actions }) {
const result = await graphql(` const result = await graphql(`
{ {
@@ -322,25 +342,10 @@ exports.createPagesStatefully = async function ({ graphql, actions }) {
({ node }) => node ({ node }) => node
); );
const createIdToNextPathMap = nodes => const idToNextPathCurrentCurriculum =
nodes.reduce((map, node, index) => { createIdToNextPathMap(allChallengeNodes);
const nextNode = nodes[index + 1]; const idToPrevPathCurrentCurriculum =
const nextPath = nextNode ? nextNode.challenge.fields.slug : null; createIdToPrevPathMap(allChallengeNodes);
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 nodeToPage = createChallengePages(actions.createPage, { const nodeToPage = createChallengePages(actions.createPage, {
idToNextPathCurrentCurriculum, idToNextPathCurrentCurriculum,
@@ -352,15 +357,27 @@ exports.createPagesStatefully = async function ({ graphql, actions }) {
}; };
exports.createPages = function ({ actions }) { exports.createPages = function ({ actions }) {
if (!allChallengeNodes) return;
// actions.createPage has to be called in the createPages hook // actions.createPage has to be called in the createPages hook
const nodes = [...filePathToCreatedNodes.values()].flat(); const newNodes = [...filePathToCreatedNodes.values()].flat();
for (const node of nodes) { // 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, { const nodeToPage = createChallengePages(actions.createPage, {
idToNextPathCurrentCurriculum, idToNextPathCurrentCurriculum,
idToPrevPathCurrentCurriculum idToPrevPathCurrentCurriculum
}); });
nodeToPage(node, 0, allChallengeNodes); nodeToPage(node, 0, sortedNodes);
} }
// It's important NOT to clear the createdNodes, since Gatsby deletes any // It's important NOT to clear the createdNodes, since Gatsby deletes any