chore: import upstream snapshot with attribution
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/* eslint-disable filenames-simple/naming-convention */
|
||||
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
|
||||
|
||||
export default createLintStagedConfig(import.meta.dirname);
|
||||
@@ -0,0 +1,61 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
const createdIds = new Set();
|
||||
|
||||
function createChallengeNode(
|
||||
challenge,
|
||||
reporter,
|
||||
{ isReloading } = { isReloading: false }
|
||||
) {
|
||||
const contentDigest = crypto
|
||||
.createHash('md5')
|
||||
.update(JSON.stringify(challenge))
|
||||
.digest('hex');
|
||||
const internal = {
|
||||
contentDigest,
|
||||
type: challenge.challengeType === 7 ? 'CertificateNode' : 'ChallengeNode'
|
||||
};
|
||||
|
||||
if (internal.type === 'ChallengeNode') {
|
||||
const { block, dashedName, superBlock } = challenge;
|
||||
const slug = `/learn/${superBlock}/${block}/${dashedName}`;
|
||||
const blockHashSlug = `/learn/${superBlock}/#${block}`;
|
||||
|
||||
challenge.fields = {
|
||||
slug,
|
||||
blockHashSlug
|
||||
};
|
||||
}
|
||||
|
||||
// Challenge id should be unique for CertificateNodes, but not for
|
||||
// ChallengeNodes
|
||||
const id =
|
||||
internal.type === 'ChallengeNode' ? challenge.fields.slug : challenge.id;
|
||||
|
||||
if (createdIds.has(id) && !isReloading) {
|
||||
throw Error(`
|
||||
Challenge slugs must be unique, but ${id} already exists.
|
||||
`);
|
||||
}
|
||||
createdIds.add(id);
|
||||
|
||||
return JSON.parse(
|
||||
JSON.stringify(
|
||||
Object.assign(
|
||||
{},
|
||||
{
|
||||
children: [],
|
||||
parent: null,
|
||||
internal,
|
||||
sourceInstanceName: 'challenge'
|
||||
},
|
||||
{ challenge },
|
||||
{
|
||||
id
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
exports.createChallengeNode = createChallengeNode;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
...configTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node // TODO: migrate to ESM and remove globals
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,369 @@
|
||||
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.
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@freecodecamp/gatsby-source-challenges",
|
||||
"version": "0.0.1",
|
||||
"description": "The freeCodeCamp.org open-source codebase and curriculum",
|
||||
"license": "BSD-3-Clause",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"pnpm": ">=10"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint --max-warnings 0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
|
||||
},
|
||||
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
|
||||
"author": "freeCodeCamp <team@freecodecamp.org>",
|
||||
"main": "gatsby-node.js",
|
||||
"peerDependencies": {
|
||||
"gatsby": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@freecodecamp/eslint-config": "workspace:*",
|
||||
"@freecodecamp/curriculum": "workspace:*",
|
||||
"chokidar": "3.6.0",
|
||||
"eslint": "^9.39.1"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user