feat(tools): better language challenge helpers (#60696)

This commit is contained in:
Tom
2025-06-12 07:54:43 -05:00
committed by GitHub
parent 98e689e2e0
commit 783421008d
11 changed files with 323 additions and 37 deletions
+1
View File
@@ -38,6 +38,7 @@
"clean:packages": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
"create:shared": "tsc -p shared",
"create-new-project": "cd ./tools/challenge-helper-scripts/ && pnpm run create-project",
"create-new-language-block": "cd ./tools/challenge-helper-scripts/ && pnpm run create-language-block",
"create-new-quiz": "cd ./tools/challenge-helper-scripts/ && pnpm run create-quiz",
"predevelop": "npm-run-all -p create:shared -s build:curriculum",
"develop": "npm-run-all -p develop:*",
+7
View File
@@ -69,6 +69,13 @@ export const folderToSuperBlockMap = Object.fromEntries(
Object.entries(superBlockToFolderMap).map(([key, value]) => [value, key])
);
export const languageSuperBlocks = [
SuperBlocks.A2English,
SuperBlocks.B1English,
SuperBlocks.A2Spanish,
SuperBlocks.A2Chinese
];
/*
* SuperBlockStages.Upcoming = SHOW_UPCOMING_CHANGES === 'true'
* 'Upcoming' is for development -> not shown on stag or prod anywhere
@@ -1,15 +0,0 @@
{
"name": "",
"isUpcomingChange": true,
"usesMultifileEditor": true,
"hasEditableBoundaries": true,
"dashedName": "",
"order": 42,
"superBlock": "",
"challengeOrder": [
{
"id": "",
"title": ""
}
]
}
@@ -0,0 +1,198 @@
import { existsSync } from 'fs';
import fs from 'fs/promises';
import path from 'path';
import { prompt } from 'inquirer';
import { format } from 'prettier';
import ObjectID from 'bson-objectid';
import {
SuperBlocks,
languageSuperBlocks,
superBlockToFolderMap
} from '../../shared/config/curriculum';
import { createDialogueFile, validateBlockName } from './utils';
import { getBaseMeta } from './helpers/get-base-meta';
const helpCategories = ['English'] as const;
type BlockInfo = {
title: string;
intro: string[];
};
type SuperBlockInfo = {
blocks: Record<string, BlockInfo>;
};
type IntroJson = Record<SuperBlocks, SuperBlockInfo>;
interface CreateBlockArgs {
superBlock: SuperBlocks;
block: string;
helpCategory: string;
title?: string;
}
async function createLanguageBlock(
superBlock: SuperBlocks,
block: string,
helpCategory: string,
title?: string
) {
if (!title) {
title = block;
}
void updateIntroJson(superBlock, block, title);
const challengeId = await createDialogueChallenge(superBlock, block);
void createMetaJson(superBlock, block, title, helpCategory, challengeId);
// TODO: remove once we stop relying on markdown in the client.
void createIntroMD(superBlock, block, title);
}
async function updateIntroJson(
superBlock: SuperBlocks,
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: ['', '']
};
void withTrace(
fs.writeFile,
introJsonPath,
await format(JSON.stringify(newIntro), { parser: 'json' })
);
}
async function createMetaJson(
superBlock: SuperBlocks,
block: string,
title: string,
helpCategory: string,
challengeId: ObjectID
) {
const metaDir = path.resolve(__dirname, '../../curriculum/challenges/_meta');
const newMeta = getBaseMeta('Language');
newMeta.name = title;
newMeta.dashedName = block;
newMeta.helpCategory = helpCategory;
newMeta.superBlock = superBlock;
newMeta.challengeOrder = [
// eslint-disable-next-line @typescript-eslint/no-base-to-string
{ id: challengeId.toString(), title: "Dialogue 1: I'm Tom" }
];
const newMetaDir = path.resolve(metaDir, block);
if (!existsSync(newMetaDir)) {
await withTrace(fs.mkdir, newMetaDir);
}
void withTrace(
fs.writeFile,
path.resolve(metaDir, `${block}/meta.json`),
await format(JSON.stringify(newMeta), { parser: 'json' })
);
}
async function createIntroMD(superBlock: string, block: string, title: string) {
const introMD = `---
title: Introduction to the ${title}
block: ${block}
superBlock: ${superBlock}
---
## Introduction to the ${title}
This page is for the ${title}
`;
const dirPath = path.resolve(
__dirname,
`../../client/src/pages/learn/${superBlock}/${block}/`
);
const filePath = path.resolve(dirPath, 'index.md');
if (!existsSync(dirPath)) {
await withTrace(fs.mkdir, dirPath);
}
void withTrace(fs.writeFile, filePath, introMD, { encoding: 'utf8' });
}
async function createDialogueChallenge(
superBlock: SuperBlocks,
block: string
): Promise<ObjectID> {
const superBlockSubPath = superBlockToFolderMap[superBlock];
const newChallengeDir = path.resolve(
__dirname,
`../../curriculum/challenges/english/${superBlockSubPath}/${block}`
);
if (!existsSync(newChallengeDir)) {
await withTrace(fs.mkdir, newChallengeDir);
}
return createDialogueFile({
projectPath: newChallengeDir + '/'
});
}
function parseJson<JsonSchema>(filePath: string) {
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
);
}
// 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([
{
name: 'superBlock',
message: 'Which certification does this belong to?',
default: SuperBlocks.A2English,
type: 'list',
choices: Object.values(languageSuperBlocks)
},
{
name: 'block',
message: 'What is the dashed name (in kebab-case) for this block?',
validate: validateBlockName,
filter: (block: string) => {
return block.toLowerCase().trim();
}
},
{
name: 'title',
default: ({ block }: { block: string }) => block
},
{
name: 'helpCategory',
message: 'Choose a help category',
default: 'English',
type: 'list',
choices: helpCategories
}
])
.then(
async ({ superBlock, block, helpCategory, title }: CreateBlockArgs) =>
await createLanguageBlock(superBlock, block, helpCategory, title)
)
.then(() =>
console.log(
'All set. Now use pnpm run clean:client in the root and it should be good to go.'
)
);
@@ -10,7 +10,7 @@ import {
superBlockToFolderMap
} from '../../shared/config/curriculum';
import { createStepFile, validateBlockName } from './utils';
import { Meta } from './helpers/project-metadata';
import { getBaseMeta } from './helpers/get-base-meta';
const helpCategories = [
'HTML-CSS',
@@ -97,7 +97,7 @@ async function createMetaJson(
challengeId: ObjectID
) {
const metaDir = path.resolve(__dirname, '../../curriculum/challenges/_meta');
const newMeta = await parseJson<Meta>('./base-meta.json');
const newMeta = getBaseMeta('Step');
newMeta.name = title;
newMeta.dashedName = block;
newMeta.helpCategory = helpCategory;
@@ -151,6 +151,7 @@ async function createFirstChallenge(
if (!existsSync(newChallengeDir)) {
await withTrace(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 = {
@@ -10,7 +10,7 @@ import {
superBlockToFolderMap
} from '../../shared/config/curriculum';
import { createQuizFile, validateBlockName } from './utils';
import { Meta } from './helpers/project-metadata';
import { getBaseMeta } from './helpers/get-base-meta';
const helpCategories = [
'HTML-CSS',
@@ -90,7 +90,7 @@ async function createMetaJson(
challengeId: ObjectID
) {
const metaDir = path.resolve(__dirname, '../../curriculum/challenges/_meta');
const newMeta = await parseJson<Meta>('./quiz-meta.json');
const newMeta = getBaseMeta('Quiz');
newMeta.name = title;
newMeta.dashedName = block;
newMeta.helpCategory = helpCategory;
@@ -146,14 +146,12 @@ async function createQuizChallenge(
await withTrace(fs.mkdir, newChallengeDir);
}
return createQuizFile({
challengeType: '8',
projectPath: newChallengeDir + '/',
title: title,
dashedName: block,
questionCount: questionCount
});
}
function parseJson<JsonSchema>(filePath: string) {
return withTrace(fs.readFile, filePath, 'utf8').then(
// unfortunately, withTrace does not correctly infer that the third argument
@@ -0,0 +1,44 @@
const baseMeta = {
name: '',
isUpcomingChange: true,
dashedName: '',
superBlock: '',
order: 42,
helpCategory: '',
challengeOrder: [
{
id: '',
title: ''
}
]
};
const stepMeta = {
...baseMeta,
usesMultifileEditor: true,
hasEditableBoundaries: true
};
const quizMeta = {
...baseMeta,
blockType: 'quiz',
blockLayout: 'link'
};
const languageMeta = {
...baseMeta,
blockLayout: 'dialogue-grid'
};
export const getBaseMeta = (projectType: 'Step' | 'Quiz' | 'Language') => {
switch (projectType) {
case 'Step':
return stepMeta;
case 'Quiz':
return quizMeta;
case 'Language':
return languageMeta;
default:
return stepMeta;
}
};
@@ -284,6 +284,50 @@ Watch the video below to understand the context of the upcoming lessons.
# --assignment--
Watch the video.
# --scene--
\`\`\`json
{
"setup": {
"background": "chaos.png",
"characters": [
{
"character": "David",
"position": {"x":50,"y":80,"z":8},
"opacity": 0
}
],
"audio": {
"filename": "1.1-1.mp3",
"startTime": 1,
"startTimestamp": 5.7,
"finishTimestamp": 6.48
}
},
"commands": [
{
"character": "David",
"opacity": 1,
"startTime": 0
},
{
"character": "David",
"startTime": 1,
"finishTime": 0.78,
"dialogue": {
"text": "I'm Tom.",
"align": "center"
}
},
{
"character": "Tom",
"opacity": 0,
"startTime": 1.28
}
]
}
\`\`\`
`;
type Template = (opts: ChallengeOptions) => string;
@@ -21,6 +21,7 @@
"scripts": {
"test": "mocha --delay --reporter progress --bail",
"create-project": "tsx create-project",
"create-language-block": "tsx create-language-block",
"create-quiz": "tsx create-quiz"
},
"devDependencies": {
@@ -1,14 +0,0 @@
{
"name": "",
"blockType": "quiz",
"blockLayout": "link",
"isUpcomingChange": true,
"dashedName": "",
"superBlock": "",
"challengeOrder": [
{
"id": "",
"title": ""
}
]
}
+23 -2
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import ObjectID from 'bson-objectid';
import matter from 'gray-matter';
import { challengeTypes } from '../../shared/config/challenge-types';
import { parseMDSync } from '../challenge-parser/parser';
import { getMetaData, updateMetaData } from './helpers/project-metadata';
import { getProjectPath } from './helpers/get-project-info';
@@ -21,7 +22,6 @@ interface Options {
}
interface QuizOptions {
challengeType: string;
projectPath?: string;
title: string;
dashedName: string;
@@ -60,13 +60,13 @@ const createChallengeFile = (
};
const createQuizFile = ({
challengeType,
projectPath = getProjectPath(),
title,
dashedName,
questionCount
}: QuizOptions): ObjectID => {
const challengeId = new ObjectID();
const challengeType = challengeTypes.quiz.toString();
const template = getTemplate(challengeType);
const quizText = template({
@@ -81,6 +81,26 @@ const createQuizFile = ({
return challengeId;
};
const createDialogueFile = ({
projectPath
}: {
projectPath: string;
}): ObjectID => {
const challengeId = new ObjectID();
const challengeType = challengeTypes.dialogue.toString();
const template = getTemplate(challengeType);
const dialogueText = template({
challengeId,
challengeType,
title: "Dialogue 1: I'm Tom",
dashedName: 'dialogue-1-im-tom'
});
// eslint-disable-next-line @typescript-eslint/no-base-to-string
fs.writeFileSync(`${projectPath}${challengeId.toString()}.md`, dialogueText);
return challengeId;
};
interface InsertOptions {
stepNum: number;
stepId: ObjectID;
@@ -247,6 +267,7 @@ const validateBlockName = (block: string): boolean | string => {
export {
createStepFile,
createDialogueFile,
createChallengeFile,
updateStepTitles,
updateTaskMeta,