feat(curriculum): add project creation tool (#42410)
* refactor: extract help category map as JSON * refactor: extract block names as json * feat: add create-project script * fix: correct case for tsconfig target Co-authored-by: Shaun Hamilton <shauhami020@gmail.com> Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
committed by
GitHub
parent
bd88d9f7ce
commit
bd4d46915a
288
tools/challenge-helper-scripts/create-project.ts
Normal file
288
tools/challenge-helper-scripts/create-project.ts
Normal file
@ -0,0 +1,288 @@
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { format } from 'prettier';
|
||||
import { prompt } from 'inquirer';
|
||||
|
||||
import { createStepFile } from './utils.js';
|
||||
import { blockNameify } from '../../utils/block-nameify';
|
||||
|
||||
const superBlocks = [
|
||||
'responsive-web-design',
|
||||
'javascript-algorithms-and-data-structures',
|
||||
'front-end-libraries',
|
||||
'data-visualization',
|
||||
'apis-and-microservices',
|
||||
'quality-assurance',
|
||||
'scientific-computing-with-python',
|
||||
'data-analysis-with-python',
|
||||
'information-security',
|
||||
'machine-learning-with-python',
|
||||
'coding-interview-prep'
|
||||
] as const;
|
||||
|
||||
type SuperBlock = typeof superBlocks[number];
|
||||
|
||||
const helpCategories = ['HTML-CSS', 'JavaScript', 'Python'] as const;
|
||||
|
||||
type BlockInfo = {
|
||||
title: string;
|
||||
intro: string[];
|
||||
};
|
||||
|
||||
type SuperBlockInfo = {
|
||||
blocks: Record<string, BlockInfo>;
|
||||
};
|
||||
|
||||
type IntroJson = Record<SuperBlock, SuperBlockInfo>;
|
||||
|
||||
type Meta = {
|
||||
name: string;
|
||||
isUpcomingChange: boolean;
|
||||
dashedName: string;
|
||||
order: number;
|
||||
time: string;
|
||||
template: string;
|
||||
required: string[];
|
||||
superBlock: string;
|
||||
superOrder: number;
|
||||
isBeta: boolean;
|
||||
challengeOrder: string[][];
|
||||
};
|
||||
|
||||
async function createProject(
|
||||
superBlock: SuperBlock,
|
||||
block: string,
|
||||
helpCategory: string,
|
||||
order: number,
|
||||
title?: string
|
||||
) {
|
||||
if (!title) {
|
||||
title = blockNameify(block);
|
||||
} else if (title !== blockNameify(block)) {
|
||||
updateBlockNames(block, title).catch(reason => {
|
||||
throw reason;
|
||||
});
|
||||
}
|
||||
updateIntroJson(superBlock, block, title).catch(reason => {
|
||||
throw reason;
|
||||
});
|
||||
updateHelpCategoryMap(block, helpCategory).catch(reason => {
|
||||
throw reason;
|
||||
});
|
||||
|
||||
const challengeId = await createFirstChallenge(superBlock, block).catch(
|
||||
reason => {
|
||||
throw reason;
|
||||
}
|
||||
);
|
||||
createMetaJson(superBlock, block, title, order, challengeId).catch(reason => {
|
||||
throw reason;
|
||||
});
|
||||
// TODO: remove once we stop relying on markdown in the client.
|
||||
createIntroMD(superBlock, block, title).catch(reason => {
|
||||
throw reason;
|
||||
});
|
||||
}
|
||||
|
||||
async function updateIntroJson(
|
||||
superBlock: SuperBlock,
|
||||
block: string,
|
||||
title: string
|
||||
) {
|
||||
const introJsonPath = path.resolve(
|
||||
__dirname,
|
||||
'../../client/i18n/locales/english/intro.json'
|
||||
);
|
||||
const newIntro = await parseJson<IntroJson>(introJsonPath);
|
||||
newIntro[superBlock].blocks[block] = {
|
||||
title,
|
||||
intro: ['', '']
|
||||
};
|
||||
fs.writeFile(
|
||||
introJsonPath,
|
||||
format(JSON.stringify(newIntro), { parser: 'json' })
|
||||
).catch(reason => {
|
||||
throw reason;
|
||||
});
|
||||
}
|
||||
|
||||
async function updateHelpCategoryMap(block: string, helpCategory: string) {
|
||||
const helpCategoryPath = path.resolve(
|
||||
__dirname,
|
||||
'../../client/utils/help-category-map.json'
|
||||
);
|
||||
const helpMap = await parseJson<Record<string, string>>(helpCategoryPath);
|
||||
helpMap[block] = helpCategory;
|
||||
fs.writeFile(
|
||||
helpCategoryPath,
|
||||
format(JSON.stringify(helpMap), { parser: 'json' })
|
||||
).catch(reason => {
|
||||
throw reason;
|
||||
});
|
||||
}
|
||||
|
||||
async function updateBlockNames(block: string, title: string) {
|
||||
const blockNamesPath = path.resolve(
|
||||
__dirname,
|
||||
'../../utils/preformatted-block-names.json'
|
||||
);
|
||||
const blockNames = await parseJson<Record<string, string>>(blockNamesPath);
|
||||
blockNames[block] = title;
|
||||
fs.writeFile(
|
||||
blockNamesPath,
|
||||
format(JSON.stringify(blockNames), { parser: 'json' })
|
||||
).catch(reason => {
|
||||
throw reason;
|
||||
});
|
||||
}
|
||||
|
||||
async function createMetaJson(
|
||||
superBlock: SuperBlock,
|
||||
block: string,
|
||||
title: string,
|
||||
order: number,
|
||||
challengeId: string
|
||||
) {
|
||||
const metaDir = path.resolve(__dirname, '../../curriculum/challenges/_meta');
|
||||
const newMeta = await parseJson<Meta>('./base-meta.json');
|
||||
newMeta.name = title;
|
||||
newMeta.dashedName = block;
|
||||
newMeta.order = order;
|
||||
newMeta.superOrder = superBlocks.indexOf(superBlock) + 1;
|
||||
newMeta.superBlock = superBlock;
|
||||
newMeta.challengeOrder = [[challengeId, 'Part 1']];
|
||||
const newMetaDir = path.resolve(metaDir, block);
|
||||
if (!existsSync(newMetaDir)) {
|
||||
await fs.mkdir(newMetaDir);
|
||||
}
|
||||
fs.writeFile(
|
||||
path.resolve(metaDir, `${block}/meta.json`),
|
||||
format(JSON.stringify(newMeta), { parser: 'json' })
|
||||
).catch(reason => {
|
||||
throw reason;
|
||||
});
|
||||
}
|
||||
|
||||
async function createIntroMD(superBlock: string, block: string, title: string) {
|
||||
const introMD = `---
|
||||
title: Introduction to the ${title}
|
||||
block: ${block}
|
||||
superBlock: Responsive Web Design
|
||||
isBeta: true
|
||||
---
|
||||
## Introduction to the ${title}
|
||||
|
||||
This is a test for the new project-based curriculum.`;
|
||||
const dirPath = path.resolve(
|
||||
__dirname,
|
||||
`../../client/src/pages/learn/${superBlock}/${block}/`
|
||||
);
|
||||
const filePath = path.resolve(dirPath, 'index.md');
|
||||
if (!existsSync(dirPath)) {
|
||||
await fs.mkdir(dirPath);
|
||||
}
|
||||
fs.writeFile(filePath, introMD, { encoding: 'utf8' }).catch(reason => {
|
||||
throw reason;
|
||||
});
|
||||
}
|
||||
|
||||
async function createFirstChallenge(
|
||||
superBlock: SuperBlock,
|
||||
block: string
|
||||
): Promise<string> {
|
||||
const superBlockId = (superBlocks.indexOf(superBlock) + 1)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const newChallengeDir = path.resolve(
|
||||
__dirname,
|
||||
`../../curriculum/challenges/english/${superBlockId}-${superBlock}/${block}`
|
||||
);
|
||||
if (!existsSync(newChallengeDir)) {
|
||||
await fs.mkdir(newChallengeDir);
|
||||
}
|
||||
// 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 = {
|
||||
indexhtml: {
|
||||
contents: '',
|
||||
ext: 'html',
|
||||
editableRegionBoundaries: [0, 2]
|
||||
}
|
||||
};
|
||||
// including trailing slash for compatibility with createStepFile
|
||||
return createStepFile({
|
||||
projectPath: newChallengeDir + '/',
|
||||
stepNum: 1,
|
||||
challengeSeeds,
|
||||
stepBetween: false
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson<JsonSchema>(filePath: string) {
|
||||
return fs
|
||||
.readFile(filePath, { encoding: 'utf8' })
|
||||
.then(result => JSON.parse(result) as JsonSchema)
|
||||
.catch(reason => {
|
||||
throw reason;
|
||||
});
|
||||
}
|
||||
|
||||
prompt([
|
||||
{
|
||||
name: 'superBlock',
|
||||
message: 'Which certification does this belong to?',
|
||||
default: 'responsive-web-design',
|
||||
type: 'list',
|
||||
choices: superBlocks
|
||||
},
|
||||
{
|
||||
name: 'block',
|
||||
message: 'What is the short name (in kebab-case) for this project?',
|
||||
validate: (block: string) => {
|
||||
if (!block.length) {
|
||||
return 'please enter a short name';
|
||||
}
|
||||
if (/[^a-z0-9\-]/.test(block)) {
|
||||
return 'please use alphanumerical characters and kebab case';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
filter: (block: string) => {
|
||||
return block.toLowerCase();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
default: ({ block }: { block: string }) => blockNameify(block)
|
||||
},
|
||||
{
|
||||
name: 'helpCategory',
|
||||
message: 'Choose a help category',
|
||||
default: 'HTML-CSS',
|
||||
type: 'list',
|
||||
choices: helpCategories
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
message: 'Which position does this appear in the certificate?',
|
||||
default: 42,
|
||||
validate: (order: string) => {
|
||||
return parseInt(order, 10) > 0
|
||||
? true
|
||||
: 'Order must be an number greater than zero.';
|
||||
},
|
||||
filter: (order: string) => {
|
||||
return parseInt(order, 10);
|
||||
}
|
||||
}
|
||||
])
|
||||
.then(({ superBlock, block, title, helpCategory, order }) =>
|
||||
createProject(superBlock, block, helpCategory, order, title)
|
||||
)
|
||||
.then(() =>
|
||||
console.log(
|
||||
'All set. Now use npm run clean:client in the root and it should be good to go.'
|
||||
)
|
||||
)
|
||||
.catch(console.error);
|
Reference in New Issue
Block a user