diff --git a/curriculum/package-lock.json b/curriculum/package-lock.json index 8a0ecbc3b5..5863762d02 100644 --- a/curriculum/package-lock.json +++ b/curriculum/package-lock.json @@ -3954,6 +3954,12 @@ } } }, + "bson-objectid": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/bson-objectid/-/bson-objectid-1.3.1.tgz", + "integrity": "sha512-eQBNQXsisEAXlwiSy8zRNZdW2xDBJaEVkTPbodYR9hGxxtE548Qq7ilYOd8WAQ86xF7NRUdiWSQ1pa/TkKiE2A==", + "dev": true + }, "btoa-lite": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/btoa-lite/-/btoa-lite-1.0.0.tgz", @@ -7420,6 +7426,18 @@ "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", "dev": true }, + "gray-matter": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.2.tgz", + "integrity": "sha512-7hB/+LxrOjq/dd8APlK0r24uL/67w7SkYnfwhNFwg/VDIGWGmduTDYf3WNstLW2fbbmRwrDGCVSJ2isuf2+4Hw==", + "dev": true, + "requires": { + "js-yaml": "^3.11.0", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + } + }, "growl": { "version": "1.10.5", "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", @@ -14214,6 +14232,27 @@ "xmlchars": "^2.1.1" } }, + "section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, "semantic-release": { "version": "15.14.0", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-15.14.0.tgz", @@ -15291,6 +15330,12 @@ "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", "dev": true }, + "strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI=", + "dev": true + }, "strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", diff --git a/curriculum/package.json b/curriculum/package.json index 85c1d7de5b..faf7320617 100644 --- a/curriculum/package.json +++ b/curriculum/package.json @@ -14,6 +14,9 @@ "version": "0.0.0-next.4", "main": "lib.js", "scripts": { + "create-empty-steps": "cross-env CALLING_DIR=$INIT_CWD node ./tools/create-empty-steps", + "create-next-step": "cross-env CALLING_DIR=$INIT_CWD node ./tools/create-next-step", + "reorder-steps": "cross-env CALLING_DIR=$INIT_CWD node ./tools/reorder-steps", "develop": "gulp", "lint": "gulp lint", "semantic-release": "semantic-release", @@ -45,9 +48,11 @@ "babel-preset-stage-0": "^6.3.13", "babel-preset-stage-3": "^6.24.1", "babel-standalone": "^6.26.0", + "bson-objectid": "^1.3.1", "chai": "4.2.0", "cross-env": "^7.0.2", "fs-extra": "^6.0.1", + "gray-matter": "^4.0.2", "gulp": "^4.0.2", "joi": "^13.3.0", "joi-objectid": "^2.0.0", diff --git a/curriculum/tools/README.md b/curriculum/tools/README.md new file mode 100644 index 0000000000..6a689c7b27 --- /dev/null +++ b/curriculum/tools/README.md @@ -0,0 +1,74 @@ +# Free Code Camp Project-based Curriculum Tools + +This repo is for tools to help facilitate the creation and maintenance of the Free Code Camp project-based curriculum. + +## [create-next-step.js](create-next-step.js) +A one-off script that will automatically add the next step based on the last step numbered as `part-xxx.md` where `xxx` represents the 3-digit step number of the last step. The challenge seed code will use the previous step's challenge seed code with the editable region markers (ERMs) removed. + +**Note:** This script also runs [reorder-steps.js](reorder-steps.js). + +### How to run script: +1. Change to the directory of the project. +2. Run the following npm command: + ```bash + npm run create-next-step + ``` + +## [create-next-step.js](create-next-step.js) +A one-off script that will automatically add a specified numbers of steps at a specific starting step number. The challenge seed code will be empty. + +**Note:** This script also runs [reorder-steps.js](reorder-steps.js). + +### How to run script: +1. Change to the directory of the project. +2. Run the following npm command: + ```bash + npm run create-empty-steps start=X num=Y # where X is the starting step number and Y is the number of steps to create. + ``` + +## [reorder-steps.js](reorder-steps.js) +A one-off script that automatically reorders the step files in a project's markdown files based on the filename. It also updates the `challengeOrder` property array in the project's `meta.json` with the new order of the steps. + +### Working Example +Let's say you start with the following project structure: + +```bash +part-1.md +part-2.md +part-3.md +part-4.md +part-5.md +part-6.md +``` +At some point you decide you need to delete `part-2.md`, because that step is no longer needed. Also, you decide to break down `part-4.md` into three steps instead of just one. + +To accomplish the this restructure, you would need to delete `part-2.md` and then add a `part-4a.md` and a `part=5b.md`. The new folder structure would look like the following: +```bash +part-001.md +part-003.md +part-004.md +part-004a.md +part-004b.md +part-005.md +part-006.md +``` +You now need the file names to be `part-1.md` through `part-7.md`, because you removed one but gained two more for a net difference of one file. Also, the frontmatter of each file below a deleted step or added step will need to be modified by making the `title` key value match the new step number. For example, after renaming `part-3.md` to `part-2.md`, you would need to change `part-2.md`'s title from `Part 03` to `Part 02`. + +See below for the actual project folder changes needed: +```bash +part-001.md +part-003.md renamed to part-002.md and title changes to "Part 2" +part-004.md renames to part-003.md and title changes to "Part 3" +part-004a.md renames to part-004.md and title changes to "Part 4" +part-004b.md renames to part-005.md and title changes to "Part 5" +part-005.md renames to part-006.md and title changes to "Part 6" +part-006.md renames to part-007.md and title changes to "Part 7" +``` +Along with the above changes, the `challengeOrder` key in the project's `meta.json` file needs to reflect the new step order. This is needed because each step below a step deletion and/or step addtion changes the `title` assoiciated with each of the affected step's challenge `id`. + +### Solution Steps +1. Change to the directory of the project. +2. Run the following npm command: + ```bash + npm run reorder-steps + ``` \ No newline at end of file diff --git a/curriculum/tools/create-empty-steps.js b/curriculum/tools/create-empty-steps.js new file mode 100644 index 0000000000..68c2cad1ab --- /dev/null +++ b/curriculum/tools/create-empty-steps.js @@ -0,0 +1,30 @@ +const path = require('path'); + +const { reorderSteps, createStepFile } = require('./utils'); + +const projectPath = (process.env.CALLING_DIR || process.cwd()) + path.sep; +const argValuePairs = process.argv.slice(2); + +const args = argValuePairs.reduce((argsObj, arg) => { + const [argument, value] = arg.replace(/\s/g, '').split('='); + if (!argument || !value) { + throw `Invalid argument/value specified: ${arg}`; + } + return { ...argsObj, [argument]: value }; +}, {}); + +let { num, start } = args; +num = parseInt(num, 10); +const stepStart = parseInt(start, 10); + +if (num > 20) { + throw 'No steps created. num arg val must be less than or equal to 20.'; +} + +const maxStepNum = stepStart + num - 1; + +for (let stepNum = stepStart; stepNum <= maxStepNum; stepNum++) { + createStepFile({ stepNum, projectPath }); +} +console.log(`Sucessfully added ${num} steps`); +reorderSteps(); diff --git a/curriculum/tools/create-next-step.js b/curriculum/tools/create-next-step.js new file mode 100644 index 0000000000..07c32a0b4e --- /dev/null +++ b/curriculum/tools/create-next-step.js @@ -0,0 +1,46 @@ +const fs = require('fs'); +const path = require('path'); + +const { reorderSteps, createStepFile } = require('./utils'); + +const getLastStepFileContent = () => { + const filesArr = []; + fs.readdirSync(projectPath).forEach(fileName => { + if ( + path.extname(fileName).toLowerCase() === '.md' && + !fileName.endsWith('final.md') + ) { + filesArr.push(fileName); + } + }); + + const fileName = filesArr[filesArr.length - 1]; + let lastStepFileNum = fileName.split('.')[0].split('-')[1]; + lastStepFileNum = parseInt(lastStepFileNum, 10); + if (filesArr.length !== lastStepFileNum) { + throw `Error: The last file step is ${lastStepFileNum} and there are ${filesArr.length} files.`; + } + const fileContent = fs.readFileSync(projectPath + fileName, 'utf8'); + const matchedSection = fileContent + .toString() + .match(/
(?[\s\S]+)<\/section>/); + let finalChallengeSeed; + if (matchedSection) { + let { + groups: { challengeSeed } + } = matchedSection; + finalChallengeSeed = challengeSeed ? challengeSeed : ''; + } + return { + nextStepNum: lastStepFileNum + 1, + challengeSeed: finalChallengeSeed + }; +}; + +const projectPath = (process.env.CALLING_DIR || process.cwd()) + path.sep; + +const { nextStepNum, challengeSeed } = getLastStepFileContent(); + +createStepFile({ stepNum: nextStepNum, projectPath, challengeSeed }); +console.log(`Sucessfully added step #${nextStepNum}`); +reorderSteps(); diff --git a/curriculum/tools/reorder-steps.js b/curriculum/tools/reorder-steps.js new file mode 100644 index 0000000000..e8e8ae0df3 --- /dev/null +++ b/curriculum/tools/reorder-steps.js @@ -0,0 +1,3 @@ +const { reorderSteps } = require('./utils'); + +reorderSteps(); diff --git a/curriculum/tools/utils.js b/curriculum/tools/utils.js new file mode 100644 index 0000000000..0e8a29c354 --- /dev/null +++ b/curriculum/tools/utils.js @@ -0,0 +1,168 @@ +const fs = require('fs'); +const path = require('path'); +const matter = require('gray-matter'); +const ObjectID = require('bson-objectid'); + +const padWithLeadingZeros = originalNum => { + /* always want file step numbers 3 digits */ + const maxDigits = 3; + let paddedNum = '' + originalNum; + while (paddedNum.length < maxDigits) { + paddedNum = '0' + paddedNum; + } + return paddedNum; +}; + +const removeErms = seedCode => { + const lines = seedCode.split('\n'); + return lines + .filter(line => !line.includes('--fcc-editable-region--')) + .join('\n'); +}; + +const createStepFile = ({ projectPath, stepNum, challengeSeed = '' }) => { + if (challengeSeed) { + challengeSeed = removeErms(challengeSeed); + } + + const challengeSeedSection = `
+ +${challengeSeed.trim()} + +
`; + + const template = `--- +id: ${ObjectID.generate()} +title: Part ${stepNum} +challengeType: 0 +isHidden: true +--- + +## Description +
+ +step ${stepNum} instructions + +
+ +## Tests +
+ +\`\`\`yml +tests: + - text: Test 1 + testString: '' + +\`\`\` + +
+ +## Challenge Seed +${challengeSeedSection} +`; + + fs.writeFileSync( + `${projectPath}part-${padWithLeadingZeros(stepNum)}.md`, + template + ); +}; + +const reorderSteps = () => { + const projectPath = (process.env.CALLING_DIR || process.cwd()) + path.sep; + + const projectName = process.env.CALLING_DIR + ? process.env.CALLING_DIR.split(path.sep) + .slice(-1) + .toString() + : process + .cwd() + .split(path.sep) + .slice(-1) + .toString(); + + const curriculumPath = process.env.CALLING_DIR + ? '' + : '../../../../../curriculum'; + + const projectMetaPath = path.resolve( + curriculumPath, + 'challenges', + '_meta', + projectName, + 'meta.json' + ); + + let metaData; + try { + metaData = fs.readFileSync(projectMetaPath); + } catch (err) { + throw `No _meta.json file exists at ${projectMetaPath}`; + } + + let foundFinal = false; + const filesArr = []; + fs.readdirSync(projectPath).forEach(fileName => { + if (path.extname(fileName).toLowerCase() === '.md') { + if (!fileName.endsWith('final.md')) { + filesArr.push(fileName); + } else { + foundFinal = true; + } + } + }); + if (foundFinal) { + filesArr.push('final.md'); + } + + const filesToReorder = filesArr.map((fileName, i) => { + const newStepNum = i + 1; + const newFileName = + fileName !== 'final.md' + ? `part-${padWithLeadingZeros(newStepNum)}.md` + : 'final.md'; + return { + oldFileName: fileName, + newFileName, + newStepNum + }; + }); + + const challengeOrder = []; + const parsedData = JSON.parse(metaData); + + filesToReorder.forEach(({ oldFileName, newFileName, newStepNum }) => { + fs.renameSync( + `${projectPath}${oldFileName}`, + `${projectPath}${newFileName}.tmp` + ); + const filePath = `${projectPath}${newFileName}.tmp`; + const frontMatter = matter.read(filePath); + const challengeID = frontMatter.data.id || ObjectID.generate(); + const title = + newFileName === 'final.md' ? 'Final Prototype' : `Part ${newStepNum}`; + challengeOrder.push(['' + challengeID, title]); + const newData = { + ...frontMatter.data, + id: challengeID, + title + }; + fs.writeFileSync(filePath, frontMatter.stringify(newData)); + }); + + filesToReorder.forEach(({ newFileName }) => { + fs.renameSync( + `${projectPath}${newFileName}.tmp`, + `${projectPath}${newFileName}` + ); + }); + + const newMeta = { ...parsedData, challengeOrder }; + fs.writeFileSync(projectMetaPath, JSON.stringify(newMeta, null, 2)); + console.log('Reordered steps'); +}; + +module.exports = { + padWithLeadingZeros, + createStepFile, + reorderSteps +};