2021-06-08 19:27:45 +00:00
|
|
|
import { existsSync } from 'fs';
|
2021-08-02 13:39:40 +00:00
|
|
|
import fs from 'fs/promises';
|
2021-06-08 19:27:45 +00:00
|
|
|
import path from 'path';
|
|
|
|
import { prompt } from 'inquirer';
|
2021-08-02 13:39:40 +00:00
|
|
|
import { format } from 'prettier';
|
2021-06-08 19:27:45 +00:00
|
|
|
|
2022-01-25 10:34:16 +00:00
|
|
|
import ObjectID from 'bson-objectid';
|
2021-11-19 14:19:40 +00:00
|
|
|
import { SuperBlocks } from '../../config/certification-settings';
|
2021-06-08 19:27:45 +00:00
|
|
|
import { blockNameify } from '../../utils/block-nameify';
|
2022-01-25 10:34:16 +00:00
|
|
|
import { createStepFile } from './utils';
|
2022-03-07 05:21:33 +00:00
|
|
|
import { getSuperBlockSubPath } from './fs-utils';
|
2022-10-05 17:08:40 +00:00
|
|
|
import { Meta } from './helpers/project-metadata';
|
2021-06-08 19:27:45 +00:00
|
|
|
|
|
|
|
const helpCategories = ['HTML-CSS', 'JavaScript', 'Python'] as const;
|
|
|
|
|
|
|
|
type BlockInfo = {
|
|
|
|
title: string;
|
|
|
|
intro: string[];
|
|
|
|
};
|
|
|
|
|
|
|
|
type SuperBlockInfo = {
|
|
|
|
blocks: Record<string, BlockInfo>;
|
|
|
|
};
|
|
|
|
|
2021-11-19 14:19:40 +00:00
|
|
|
type IntroJson = Record<SuperBlocks, SuperBlockInfo>;
|
2021-06-08 19:27:45 +00:00
|
|
|
|
2021-11-24 14:09:45 +00:00
|
|
|
interface CreateProjectArgs {
|
|
|
|
superBlock: SuperBlocks;
|
|
|
|
block: string;
|
|
|
|
helpCategory: string;
|
|
|
|
order: number;
|
|
|
|
title?: string;
|
|
|
|
}
|
|
|
|
|
2021-06-08 19:27:45 +00:00
|
|
|
async function createProject(
|
2021-11-19 14:19:40 +00:00
|
|
|
superBlock: SuperBlocks,
|
2021-06-08 19:27:45 +00:00
|
|
|
block: string,
|
|
|
|
helpCategory: string,
|
|
|
|
order: number,
|
|
|
|
title?: string
|
|
|
|
) {
|
|
|
|
if (!title) {
|
|
|
|
title = blockNameify(block);
|
|
|
|
} else if (title !== blockNameify(block)) {
|
2022-03-07 05:21:33 +00:00
|
|
|
void updateBlockNames(block, title);
|
2021-06-08 19:27:45 +00:00
|
|
|
}
|
2022-03-07 05:21:33 +00:00
|
|
|
void updateIntroJson(superBlock, block, title);
|
|
|
|
void updateHelpCategoryMap(block, helpCategory);
|
2021-06-08 19:27:45 +00:00
|
|
|
|
2022-03-07 05:21:33 +00:00
|
|
|
const challengeId = await createFirstChallenge(superBlock, block);
|
|
|
|
void createMetaJson(superBlock, block, title, order, challengeId);
|
2021-06-08 19:27:45 +00:00
|
|
|
// TODO: remove once we stop relying on markdown in the client.
|
2022-03-07 05:21:33 +00:00
|
|
|
void createIntroMD(superBlock, block, title);
|
2021-06-08 19:27:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async function updateIntroJson(
|
2021-11-19 14:19:40 +00:00
|
|
|
superBlock: SuperBlocks,
|
2021-06-08 19:27:45 +00:00
|
|
|
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: ['', '']
|
|
|
|
};
|
2022-03-07 05:21:33 +00:00
|
|
|
void withTrace(
|
|
|
|
fs.writeFile,
|
2021-06-08 19:27:45 +00:00
|
|
|
introJsonPath,
|
|
|
|
format(JSON.stringify(newIntro), { parser: 'json' })
|
2022-03-07 05:21:33 +00:00
|
|
|
);
|
2021-06-08 19:27:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
2022-03-07 05:21:33 +00:00
|
|
|
void withTrace(
|
|
|
|
fs.writeFile,
|
2021-06-08 19:27:45 +00:00
|
|
|
helpCategoryPath,
|
|
|
|
format(JSON.stringify(helpMap), { parser: 'json' })
|
2022-03-07 05:21:33 +00:00
|
|
|
);
|
2021-06-08 19:27:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
2022-03-07 05:21:33 +00:00
|
|
|
void withTrace(
|
|
|
|
fs.writeFile,
|
2021-06-08 19:27:45 +00:00
|
|
|
blockNamesPath,
|
|
|
|
format(JSON.stringify(blockNames), { parser: 'json' })
|
2022-03-07 05:21:33 +00:00
|
|
|
);
|
2021-06-08 19:27:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async function createMetaJson(
|
2021-11-19 14:19:40 +00:00
|
|
|
superBlock: SuperBlocks,
|
2021-06-08 19:27:45 +00:00
|
|
|
block: string,
|
|
|
|
title: string,
|
|
|
|
order: number,
|
2022-01-25 10:34:16 +00:00
|
|
|
challengeId: ObjectID
|
2021-06-08 19:27:45 +00:00
|
|
|
) {
|
|
|
|
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;
|
2021-11-19 14:19:40 +00:00
|
|
|
newMeta.superOrder = Object.values(SuperBlocks).indexOf(superBlock) + 1;
|
2021-06-08 19:27:45 +00:00
|
|
|
newMeta.superBlock = superBlock;
|
2022-01-25 10:34:16 +00:00
|
|
|
newMeta.challengeOrder = [[challengeId.toString(), 'Step 1']];
|
2021-06-08 19:27:45 +00:00
|
|
|
const newMetaDir = path.resolve(metaDir, block);
|
|
|
|
if (!existsSync(newMetaDir)) {
|
2022-03-07 05:21:33 +00:00
|
|
|
await withTrace(fs.mkdir, newMetaDir);
|
2021-06-08 19:27:45 +00:00
|
|
|
}
|
2022-03-07 05:21:33 +00:00
|
|
|
|
|
|
|
void withTrace(
|
|
|
|
fs.writeFile,
|
2021-06-08 19:27:45 +00:00
|
|
|
path.resolve(metaDir, `${block}/meta.json`),
|
|
|
|
format(JSON.stringify(newMeta), { parser: 'json' })
|
2022-03-07 05:21:33 +00:00
|
|
|
);
|
2021-06-08 19:27:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async function createIntroMD(superBlock: string, block: string, title: string) {
|
|
|
|
const introMD = `---
|
|
|
|
title: Introduction to the ${title}
|
|
|
|
block: ${block}
|
|
|
|
superBlock: Responsive Web Design
|
|
|
|
isBeta: true
|
|
|
|
---
|
2021-10-07 16:21:55 +00:00
|
|
|
|
2021-06-08 19:27:45 +00:00
|
|
|
## Introduction to the ${title}
|
|
|
|
|
2021-10-07 16:21:55 +00:00
|
|
|
This is a test for the new project-based curriculum.
|
|
|
|
`;
|
2021-06-08 19:27:45 +00:00
|
|
|
const dirPath = path.resolve(
|
|
|
|
__dirname,
|
|
|
|
`../../client/src/pages/learn/${superBlock}/${block}/`
|
|
|
|
);
|
|
|
|
const filePath = path.resolve(dirPath, 'index.md');
|
|
|
|
if (!existsSync(dirPath)) {
|
2022-03-07 05:21:33 +00:00
|
|
|
await withTrace(fs.mkdir, dirPath);
|
2021-06-08 19:27:45 +00:00
|
|
|
}
|
2022-03-07 05:21:33 +00:00
|
|
|
void withTrace(fs.writeFile, filePath, introMD, { encoding: 'utf8' });
|
2021-06-08 19:27:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async function createFirstChallenge(
|
2021-11-19 14:19:40 +00:00
|
|
|
superBlock: SuperBlocks,
|
2021-06-08 19:27:45 +00:00
|
|
|
block: string
|
2022-01-25 10:34:16 +00:00
|
|
|
): Promise<ObjectID> {
|
2022-03-07 05:21:33 +00:00
|
|
|
const superBlockSubPath = getSuperBlockSubPath(superBlock);
|
2021-06-08 19:27:45 +00:00
|
|
|
const newChallengeDir = path.resolve(
|
|
|
|
__dirname,
|
2022-03-07 05:21:33 +00:00
|
|
|
`../../curriculum/challenges/english/${superBlockSubPath}/${block}`
|
2021-06-08 19:27:45 +00:00
|
|
|
);
|
|
|
|
if (!existsSync(newChallengeDir)) {
|
2022-03-07 05:21:33 +00:00
|
|
|
await withTrace(fs.mkdir, newChallengeDir);
|
2021-06-08 19:27:45 +00:00
|
|
|
}
|
|
|
|
// 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,
|
2022-03-02 15:12:20 +00:00
|
|
|
challengeSeeds
|
2021-06-08 19:27:45 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function parseJson<JsonSchema>(filePath: string) {
|
2022-03-07 05:21:33 +00:00
|
|
|
return withTrace(fs.readFile, filePath, 'utf8').then(
|
|
|
|
// unfortunately, withTrace does not correctly infer that the third argument
|
|
|
|
// is a string, so it uses the (path, options?) overload and we have to cast
|
|
|
|
// result to string.
|
|
|
|
result => JSON.parse(result as string) as JsonSchema
|
|
|
|
);
|
2021-06-08 19:27:45 +00:00
|
|
|
}
|
|
|
|
|
2022-03-07 05:21:33 +00:00
|
|
|
// fs Promise functions return errors, but no stack trace. This adds back in
|
|
|
|
// the stack trace.
|
|
|
|
function withTrace<Args extends unknown[], Result>(
|
|
|
|
fn: (...x: Args) => Promise<Result>,
|
|
|
|
...args: Args
|
|
|
|
): Promise<Result> {
|
|
|
|
return fn(...args).catch((reason: Error) => {
|
|
|
|
throw Error(reason.message);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
void prompt([
|
2021-06-08 19:27:45 +00:00
|
|
|
{
|
|
|
|
name: 'superBlock',
|
|
|
|
message: 'Which certification does this belong to?',
|
2021-11-19 14:19:40 +00:00
|
|
|
default: SuperBlocks.RespWebDesign,
|
2021-06-08 19:27:45 +00:00
|
|
|
type: 'list',
|
2022-03-07 05:21:33 +00:00
|
|
|
choices: Object.values(SuperBlocks)
|
2021-06-08 19:27:45 +00:00
|
|
|
},
|
|
|
|
{
|
|
|
|
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';
|
|
|
|
}
|
2021-12-02 00:01:12 +00:00
|
|
|
if (/[^a-z0-9-]/.test(block)) {
|
2021-06-08 19:27:45 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
])
|
2021-11-24 14:09:45 +00:00
|
|
|
.then(
|
2022-03-07 05:21:33 +00:00
|
|
|
async ({
|
|
|
|
superBlock,
|
|
|
|
block,
|
|
|
|
title,
|
|
|
|
helpCategory,
|
|
|
|
order
|
|
|
|
}: CreateProjectArgs) =>
|
|
|
|
await createProject(superBlock, block, helpCategory, order, title)
|
2021-06-08 19:27:45 +00:00
|
|
|
)
|
|
|
|
.then(() =>
|
|
|
|
console.log(
|
|
|
|
'All set. Now use npm run clean:client in the root and it should be good to go.'
|
|
|
|
)
|
2022-03-07 05:21:33 +00:00
|
|
|
);
|