const chokidar = require('chokidar'); const { sortBy } = require('lodash'); const { getSuperblockStructure } = require('@freecodecamp/curriculum/file-handler'); const { superBlockToFilename } = require('@freecodecamp/curriculum/build-curriculum'); const { createChallengeNode } = require('./create-challenge-nodes'); const { createChallengePages, getTemplateComponent } = require('../../../client/utils/gatsby'); // createPagesStatefully only runs once, but we need the following when // updating challenges, so they have to be stored in memory. let allChallengeNodes; const filepathToStatefullyCreatedNodes = new Map(); const filePathToCreatedNodes = new Map(); // reverse lookup, to detect if an updated file has "overwritten" another file // (i.e. the updated file now has the same node id as another file). const idToFilepath = new Map(); // recently overwritten files const idToOverwrittenFile = new Map(); exports.sourceNodes = function sourceChallengesSourceNodes( { actions, reporter, createNodeId, createContentDigest }, pluginOptions ) { const { source, onSourceChange, curriculumPath } = pluginOptions; if (typeof source !== 'function') { reporter.panic(` "source" is a required option for fcc-source-challenges. It must be a function that delivers challenge objects to the plugin `); } if (typeof onSourceChange !== 'function') { reporter.panic(` "onSourceChange" is a required option for fcc-source-challenges. It must be a function that delivers a new challenge object to the plugin `); } if (typeof curriculumPath !== 'string') { reporter.panic(` "curriculumPath" is a required option for fcc-source-challenges. It must be a path to a curriculum directory `); } const { createNode, deleteNode, deletePage } = actions; const watcher = chokidar.watch(curriculumPath, { ignored: /(^|[/\\])\../, ignoreInitial: true, persistent: true, cwd: curriculumPath }); function deletePages(filePath) { const statefulNodes = filepathToStatefullyCreatedNodes.get(filePath) || []; statefulNodes.forEach(node => { deleteNode(node); deletePage({ path: node.challenge.fields.slug, component: getTemplateComponent(node.challenge.challengeType) }); idToFilepath.delete(node.id); }); const createdNodes = filePathToCreatedNodes.get(filePath) || []; createdNodes.forEach(node => { deleteNode(node); idToFilepath.delete(node.id); }); filepathToStatefullyCreatedNodes.delete(filePath); filePathToCreatedNodes.delete(filePath); } function tryToDeletePages(filePath) { const oldCreatedNodeIds = (filePathToCreatedNodes.get(filePath) ?? []).map( node => node.id ); const oldStatefullyCreatedNodeIds = ( filepathToStatefullyCreatedNodes.get(filePath) ?? [] ).map(node => node.id); const oldNodeIds = [...oldCreatedNodeIds, ...oldStatefullyCreatedNodeIds]; const overwrittenFiles = new Set( oldNodeIds.map(id => idToOverwrittenFile.get(id)) ); if (overwrittenFiles.has(filePath)) { // since this has already been overwritten, it doesn't need // deleting, but there's no longer any need to track that it was // overwritten. oldNodeIds.forEach(id => { idToOverwrittenFile.delete(id); }); } else { deletePages(filePath); } } 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. return tryToDeletePages(filePath); } return onSourceChange(filePath) .then(challenges => { const actionText = action === 'added' ? 'creating' : 'replacing'; reporter.info( `Challenge file ${action}: ${filePath}, ${actionText} challengeNodes with ids ${challenges.map(({ id }) => id).join(', ')}` ); if (action === 'changed') { tryToDeletePages(filePath); } const challengeNodes = challenges.map(challenge => reportNodeCreationToGatsby(challenge, { isReloading: true }) ); // Track if file has been overwritten. challengeNodes.forEach(({ id }) => { const maybeFilepath = idToFilepath.get(id); if (maybeFilepath) { idToOverwrittenFile.set(id, maybeFilepath); } }); challengeNodes.forEach(node => { idToFilepath.set(node.id, filePath); }); // we always need to track the created nodes to ensure the pages get // recreated. filePathToCreatedNodes.set(filePath, challengeNodes); }) .catch(e => reporter.error( `fcc-replace-challenge\nattempting to replace ${filePath}\n\n${e.message}\n${e.stack ? ` ${e.stack}` : ''}` ) ); } // On file change, replace only the changed challenge. The key is ensuring // onSourceChange returns a challenge with complete metadata. watcher.on('change', filePath => /\.md?$/.test(filePath) ? handleChallengeUpdate(filePath, 'changed') : null ); // On file add, replace just the new challenge. watcher.on('add', filePath => { if (!/\.md?$/.test(filePath)) return; handleChallengeUpdate(filePath, 'added'); }); watcher.on('unlink', filePath => { if (!/\.md?$/.test(filePath)) return; handleChallengeUpdate(filePath, 'deleted'); }); function sourceAndCreateNodes() { return source() .then(challenges => Promise.all(challenges)) .then(challenges => { // create challenge nodes challenges.forEach(challenge => { const newNode = reportNodeCreationToGatsby(challenge); const existingNodes = filepathToStatefullyCreatedNodes.get(challenge.sourceLocation) || []; filepathToStatefullyCreatedNodes.set(challenge.sourceLocation, [ ...existingNodes, newNode ]); idToFilepath.set(newNode.id, challenge.sourceLocation); }); // create superblock structure nodes createSuperBlockStructureNodes(); return Promise.resolve(); }) .catch(e => { console.log(e); reporter.panic(`fcc-source-challenges ${e.message} `); }); } function reportNodeCreationToGatsby(challenge, options) { const challengeNode = createChallengeNode(challenge, reporter, options); createNode(challengeNode); return challengeNode; } function createSuperBlockStructureNodes() { Object.keys(superBlockToFilename).forEach(superBlock => { const filename = superBlockToFilename[superBlock] || superBlock; try { const structure = getSuperblockStructure(filename); const nodeId = createNodeId(`SuperBlockStructure-${superBlock}`); const nodeContent = JSON.stringify(structure); createNode({ ...structure, superBlock, id: nodeId, parent: null, children: [], internal: { type: 'SuperBlockStructure', content: nodeContent, contentDigest: createContentDigest(structure) } }); } catch (err) { reporter.warn( `Could not load structure for ${superBlock} (${filename}): ${err.message}` ); } }); } return new Promise((resolve, reject) => { watcher.on('ready', () => sourceAndCreateNodes().then(resolve, reject)); }); }; 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(` { allChallengeNode( sort: [ { challenge: { superOrder: ASC } } { challenge: { order: ASC } } { challenge: { challengeOrder: ASC } } ] ) { edges { node { id challenge { block blockLabel blockLayout certification challengeType dashedName demoType disableLoopProtectTests disableLoopProtectPreview fields { slug blockHashSlug } id isLastChallengeInBlock order required { link src } challengeOrder challengeFiles { name ext contents history fileKey } saveSubmissionToDB solutions { contents ext history fileKey } superBlock superOrder template usesMultifileEditor chapter module } } } } } `); allChallengeNodes = result.data.allChallengeNode.edges.map( ({ node }) => node ); const idToNextPathCurrentCurriculum = createIdToNextPathMap(allChallengeNodes); const idToPrevPathCurrentCurriculum = createIdToPrevPathMap(allChallengeNodes); const nodeToPage = createChallengePages(actions.createPage, { idToNextPathCurrentCurriculum, idToPrevPathCurrentCurriculum }); // Create challenge pages. allChallengeNodes.forEach(nodeToPage); }; exports.createPages = function ({ actions }) { if (!allChallengeNodes) return; // actions.createPage has to be called in the createPages hook 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, sortedNodes); } // It's important NOT to clear the createdNodes, since Gatsby deletes any // pages that are not recreated each time createPages is called. };