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,205 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
getSuperblockStructure,
|
||||
writeSuperblockStructure
|
||||
} from '@freecodecamp/curriculum/file-handler';
|
||||
import {
|
||||
updateChapterModuleSuperblockStructure,
|
||||
updateSimpleSuperblockStructure
|
||||
} from './create-project.js';
|
||||
|
||||
vi.mock('@freecodecamp/curriculum/file-handler');
|
||||
|
||||
const mockGetSuperblockStructure = vi.mocked(getSuperblockStructure);
|
||||
const mockWriteSuperblockStructure = vi.mocked(writeSuperblockStructure);
|
||||
|
||||
const incompleteSimpleChapterModuleSuperblock = {
|
||||
chapters: [
|
||||
{
|
||||
dashedName: 'chapter1',
|
||||
modules: [
|
||||
{
|
||||
dashedName: 'module1c1',
|
||||
blocks: ['block1', 'block3']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const simpleChapterModuleSuperblock = {
|
||||
chapters: [
|
||||
{
|
||||
dashedName: 'chapter1',
|
||||
modules: [
|
||||
{
|
||||
dashedName: 'module1c1',
|
||||
blocks: ['block1', 'block2', 'block3']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
describe('updateSimpleSuperblockStructure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should insert the block into the blocks array at the expected position', async () => {
|
||||
const existingBlocks = ['block1', 'block2', 'block4'];
|
||||
const superblockFilename = 'test-superblock';
|
||||
const newBlock = 'block3';
|
||||
const order = 2;
|
||||
|
||||
mockGetSuperblockStructure.mockReturnValue({
|
||||
blocks: existingBlocks
|
||||
});
|
||||
|
||||
await updateSimpleSuperblockStructure(
|
||||
newBlock,
|
||||
{ order },
|
||||
superblockFilename
|
||||
);
|
||||
|
||||
expect(mockGetSuperblockStructure).toHaveBeenCalledWith(superblockFilename);
|
||||
expect(mockWriteSuperblockStructure).toHaveBeenCalledWith(
|
||||
superblockFilename,
|
||||
{
|
||||
blocks: ['block1', 'block2', 'block3', 'block4']
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should insert the block into the blocks array at the end when no order is given', async () => {
|
||||
const existingBlocks = ['block1', 'block2'];
|
||||
const superblockFilename = 'test-superblock';
|
||||
const newBlock = 'block3';
|
||||
|
||||
mockGetSuperblockStructure.mockReturnValue({
|
||||
blocks: existingBlocks
|
||||
});
|
||||
|
||||
await updateSimpleSuperblockStructure(newBlock, {}, superblockFilename);
|
||||
|
||||
expect(mockGetSuperblockStructure).toHaveBeenCalledWith(superblockFilename);
|
||||
expect(mockWriteSuperblockStructure).toHaveBeenCalledWith(
|
||||
superblockFilename,
|
||||
{
|
||||
blocks: ['block1', 'block2', 'block3']
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateChapterModuleSuperblockStructure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should insert the block correctly when there is only one chapter and one module', async () => {
|
||||
const superblockFilename = 'test-superblock';
|
||||
const newBlock = 'block2';
|
||||
const position = {
|
||||
order: 1,
|
||||
chapter: 'chapter1',
|
||||
module: 'module1c1'
|
||||
};
|
||||
|
||||
mockGetSuperblockStructure.mockReturnValue(
|
||||
incompleteSimpleChapterModuleSuperblock
|
||||
);
|
||||
|
||||
await updateChapterModuleSuperblockStructure(
|
||||
newBlock,
|
||||
position,
|
||||
superblockFilename
|
||||
);
|
||||
|
||||
expect(mockGetSuperblockStructure).toHaveBeenCalledWith(superblockFilename);
|
||||
expect(mockWriteSuperblockStructure).toHaveBeenCalledWith(
|
||||
superblockFilename,
|
||||
simpleChapterModuleSuperblock
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a module if it does not exist', async () => {
|
||||
const superblockFilename = 'test-superblock';
|
||||
const newBlock = 'block2';
|
||||
const position = {
|
||||
order: 0,
|
||||
chapter: 'chapter1',
|
||||
module: 'module2c1'
|
||||
};
|
||||
|
||||
mockGetSuperblockStructure.mockReturnValue(
|
||||
incompleteSimpleChapterModuleSuperblock
|
||||
);
|
||||
|
||||
await updateChapterModuleSuperblockStructure(
|
||||
newBlock,
|
||||
position,
|
||||
superblockFilename
|
||||
);
|
||||
|
||||
expect(mockGetSuperblockStructure).toHaveBeenCalledWith(superblockFilename);
|
||||
expect(mockWriteSuperblockStructure).toHaveBeenCalledWith(
|
||||
superblockFilename,
|
||||
{
|
||||
chapters: [
|
||||
{
|
||||
dashedName: 'chapter1',
|
||||
modules: [
|
||||
{
|
||||
dashedName: 'module1c1',
|
||||
blocks: ['block1', 'block3']
|
||||
},
|
||||
{
|
||||
dashedName: 'module2c1',
|
||||
comingSoon: true,
|
||||
blocks: ['block2']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a chapter and module if they do not exist', async () => {
|
||||
const superblockFilename = 'test-superblock';
|
||||
const newBlock = 'block1m2c2';
|
||||
const position = {
|
||||
order: 0,
|
||||
chapter: 'chapter2',
|
||||
module: 'module1c2'
|
||||
};
|
||||
|
||||
mockGetSuperblockStructure.mockReturnValue({ chapters: [] });
|
||||
|
||||
await updateChapterModuleSuperblockStructure(
|
||||
newBlock,
|
||||
position,
|
||||
superblockFilename
|
||||
);
|
||||
|
||||
expect(mockGetSuperblockStructure).toHaveBeenCalledWith(superblockFilename);
|
||||
expect(mockWriteSuperblockStructure).toHaveBeenCalledWith(
|
||||
superblockFilename,
|
||||
{
|
||||
chapters: [
|
||||
{
|
||||
dashedName: 'chapter2',
|
||||
comingSoon: true,
|
||||
modules: [
|
||||
{
|
||||
dashedName: 'module1c2',
|
||||
blocks: ['block1m2c2']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// TODO: this belongs in create-project, but we can't test that (since it uses
|
||||
// prettier) until we migrate to vitest
|
||||
import {
|
||||
getSuperblockStructure,
|
||||
writeSuperblockStructure
|
||||
} from '@freecodecamp/curriculum/file-handler';
|
||||
import { insertInto } from './utils.js';
|
||||
|
||||
export async function updateSimpleSuperblockStructure(
|
||||
block: string,
|
||||
position: { order?: number },
|
||||
superblockFilename: string
|
||||
) {
|
||||
const existing = getSuperblockStructure(superblockFilename) as {
|
||||
blocks: string[];
|
||||
};
|
||||
|
||||
const order =
|
||||
typeof position.order === 'number'
|
||||
? position.order
|
||||
: existing.blocks.length;
|
||||
|
||||
const updated = {
|
||||
blocks: insertInto(existing.blocks, order, block)
|
||||
};
|
||||
await writeSuperblockStructure(superblockFilename, updated);
|
||||
}
|
||||
|
||||
function createNewChapter(chapter: string, module: string, block: string) {
|
||||
return {
|
||||
dashedName: chapter,
|
||||
comingSoon: true,
|
||||
modules: [
|
||||
{
|
||||
dashedName: module,
|
||||
blocks: [block]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function createNewModule(module: string, block: string) {
|
||||
return {
|
||||
dashedName: module,
|
||||
comingSoon: true,
|
||||
blocks: [block]
|
||||
};
|
||||
}
|
||||
|
||||
export type ChapterModuleSuperblockStructure = {
|
||||
chapters: {
|
||||
dashedName: string;
|
||||
comingSoon?: boolean;
|
||||
modules: {
|
||||
dashedName: string;
|
||||
comingSoon?: boolean;
|
||||
blocks: string[];
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export async function updateChapterModuleSuperblockStructure(
|
||||
block: string,
|
||||
position: { order: number; chapter: string; module: string },
|
||||
superblockFilename: string
|
||||
) {
|
||||
const existing = getSuperblockStructure(
|
||||
superblockFilename
|
||||
) as ChapterModuleSuperblockStructure;
|
||||
const modifiedChapter = existing.chapters.find(
|
||||
chapter => chapter.dashedName === position.chapter
|
||||
);
|
||||
const modifiedModule = modifiedChapter?.modules.find(
|
||||
module => module.dashedName === position.module
|
||||
);
|
||||
|
||||
const updatedModule = modifiedModule
|
||||
? {
|
||||
...modifiedModule,
|
||||
blocks: insertInto(modifiedModule.blocks, position.order, block)
|
||||
}
|
||||
: createNewModule(position.module, block);
|
||||
|
||||
const updatedChapter = modifiedChapter
|
||||
? {
|
||||
...modifiedChapter,
|
||||
modules: modifiedModule
|
||||
? modifiedChapter.modules.map(module =>
|
||||
module === modifiedModule ? updatedModule : module
|
||||
)
|
||||
: [...modifiedChapter.modules, updatedModule]
|
||||
}
|
||||
: createNewChapter(position.chapter, position.module, block);
|
||||
|
||||
const updated = {
|
||||
chapters: modifiedChapter
|
||||
? existing.chapters.map(chapter =>
|
||||
chapter === modifiedChapter ? updatedChapter : chapter
|
||||
)
|
||||
: [...existing.chapters, updatedChapter]
|
||||
};
|
||||
|
||||
await writeSuperblockStructure(superblockFilename, updated);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getArgValue } from './get-arg-value.js';
|
||||
|
||||
describe('getArgValue helper', () => {
|
||||
it('should throw if there no arguments', () => {
|
||||
const args = ['/Path/to/node', '/Path/to/script'];
|
||||
expect(() => getArgValue(args)).toThrow('only one argument allowed');
|
||||
});
|
||||
|
||||
it('should throw if the argument is not an integer', () => {
|
||||
expect.assertions(3);
|
||||
const args = ['/Path/to/node', '/Path/to/script', 'num=4'];
|
||||
expect(() => getArgValue(args)).toThrow('argument must be an integer');
|
||||
const args2 = ['/Path/to/node', '/Path/to/script', '4.1'];
|
||||
expect(() => getArgValue(args2)).toThrow('argument must be an integer');
|
||||
const args3 = ['/Path/to/node', '/Path/to/script', '4'];
|
||||
expect(getArgValue(args3)).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
const isIntRE = /^\d+$/;
|
||||
function getArgValue(argv: string[] = []): number {
|
||||
if (argv.length !== 3) throw Error('only one argument allowed');
|
||||
const value = argv[2];
|
||||
const intValue = parseInt(value, 10);
|
||||
if (!isIntRE.test(value) || !Number.isInteger(intValue))
|
||||
throw Error('argument must be an integer');
|
||||
return intValue;
|
||||
}
|
||||
|
||||
export { getArgValue };
|
||||
@@ -0,0 +1,68 @@
|
||||
interface Meta {
|
||||
isUpcomingChange: boolean;
|
||||
dashedName: string;
|
||||
helpCategory: string;
|
||||
challengeOrder: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
}>;
|
||||
usesMultifileEditor?: boolean;
|
||||
hasEditableBoundaries?: boolean;
|
||||
blockLabel?: string;
|
||||
blockLayout?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
const baseMeta: Meta = {
|
||||
isUpcomingChange: true,
|
||||
dashedName: '',
|
||||
helpCategory: '',
|
||||
blockLayout: 'legacy-challenge-list',
|
||||
challengeOrder: [
|
||||
{
|
||||
id: '',
|
||||
title: ''
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const stepMeta = {
|
||||
...baseMeta,
|
||||
usesMultifileEditor: true,
|
||||
hasEditableBoundaries: true
|
||||
};
|
||||
|
||||
const fullStackStepMeta = {
|
||||
...baseMeta,
|
||||
blockLabel: '',
|
||||
blockLayout: '',
|
||||
usesMultifileEditor: true
|
||||
};
|
||||
|
||||
const quizMeta = {
|
||||
...baseMeta,
|
||||
blockLabel: 'quiz',
|
||||
blockLayout: 'link'
|
||||
};
|
||||
|
||||
const languageMeta = {
|
||||
...baseMeta,
|
||||
blockLayout: 'dialogue-grid'
|
||||
};
|
||||
|
||||
export const getBaseMeta = (
|
||||
projectType: 'Step' | 'Quiz' | 'Language' | 'FullStack'
|
||||
): Meta => {
|
||||
switch (projectType) {
|
||||
case 'Step':
|
||||
return stepMeta;
|
||||
case 'Quiz':
|
||||
return quizMeta;
|
||||
case 'FullStack':
|
||||
return fullStackStepMeta;
|
||||
case 'Language':
|
||||
return languageMeta;
|
||||
default:
|
||||
return stepMeta;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,541 @@
|
||||
import { ObjectId } from 'bson';
|
||||
import type { ChallengeLang } from '@freecodecamp/shared/config/curriculum';
|
||||
|
||||
const sanitizeTitle = (title: string) => {
|
||||
return title.includes(':') || title.includes("'") ? `"${title}"` : title;
|
||||
};
|
||||
|
||||
interface ChallengeOptions {
|
||||
challengeId: ObjectId;
|
||||
title: string;
|
||||
dashedName: string;
|
||||
challengeType: string;
|
||||
questionCount?: number;
|
||||
challengeLang?: ChallengeLang;
|
||||
inputType?: string;
|
||||
}
|
||||
|
||||
const buildFrontMatter = ({
|
||||
challengeId,
|
||||
title,
|
||||
dashedName,
|
||||
challengeType,
|
||||
challengeLang,
|
||||
inputType
|
||||
}: ChallengeOptions) => {
|
||||
const langString = challengeLang
|
||||
? `
|
||||
lang: ${challengeLang}`
|
||||
: '';
|
||||
const inputTypeString = inputType
|
||||
? `
|
||||
inputType: ${inputType}`
|
||||
: '';
|
||||
|
||||
return `---
|
||||
id: ${challengeId.toString()}
|
||||
title: ${sanitizeTitle(title)}
|
||||
challengeType: ${challengeType}
|
||||
dashedName: ${dashedName}${langString}${inputTypeString}
|
||||
---`;
|
||||
};
|
||||
|
||||
const buildFrontMatterWithVideo = ({
|
||||
challengeId,
|
||||
title,
|
||||
dashedName,
|
||||
challengeType
|
||||
}: ChallengeOptions) => `---
|
||||
id: ${challengeId.toString()}
|
||||
videoId: ADD YOUR VIDEO ID HERE!!!
|
||||
title: ${sanitizeTitle(title)}
|
||||
challengeType: ${challengeType}
|
||||
dashedName: ${dashedName}
|
||||
---`;
|
||||
|
||||
const getLegacyChallengeTemplate = (
|
||||
options: ChallengeOptions
|
||||
): string => `${buildFrontMatter(options)}
|
||||
|
||||
# --description--
|
||||
|
||||
${options.title} description.
|
||||
|
||||
# --instructions--
|
||||
|
||||
${options.title} instructions.
|
||||
|
||||
# --hints--
|
||||
|
||||
Test 1
|
||||
|
||||
\`\`\`js
|
||||
|
||||
\`\`\`
|
||||
|
||||
# --seed--
|
||||
|
||||
\`\`\`js
|
||||
|
||||
\`\`\`
|
||||
|
||||
# --solutions--
|
||||
|
||||
\`\`\`js
|
||||
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const getQuizChallengeTemplate = (options: ChallengeOptions): string => {
|
||||
const count = options.questionCount!;
|
||||
|
||||
const regularQuestion = `### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Placeholder question
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Placeholder distractor 1
|
||||
|
||||
---
|
||||
|
||||
Placeholder distractor 2
|
||||
|
||||
---
|
||||
|
||||
Placeholder distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Placeholder answer
|
||||
|
||||
`;
|
||||
|
||||
const questionWithAudio = `### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Placeholder question
|
||||
|
||||
#### --audio--
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"audio": {
|
||||
"filename": "1.1-1.mp3",
|
||||
"startTimestamp": 5.7,
|
||||
"finishTimestamp": 6.48
|
||||
},
|
||||
"transcript": [
|
||||
{
|
||||
"character": "Tom",
|
||||
"text": "Hi, I'm Tom."
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Placeholder distractor 1
|
||||
|
||||
---
|
||||
|
||||
Placeholder distractor 2
|
||||
|
||||
---
|
||||
|
||||
Placeholder distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Placeholder answer
|
||||
|
||||
`;
|
||||
|
||||
const firstQuestion = options.challengeLang
|
||||
? questionWithAudio
|
||||
: regularQuestion;
|
||||
|
||||
return `${buildFrontMatter(options)}
|
||||
|
||||
# --description--
|
||||
|
||||
To pass the quiz, you must correctly answer at least ${count == 20 ? '18' : '9'} of the ${count.toString()} questions below.
|
||||
|
||||
# --quizzes--
|
||||
|
||||
## --quiz--
|
||||
|
||||
${firstQuestion}${regularQuestion.repeat(Math.max(0, count - 2))}
|
||||
### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Placeholder question
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Placeholder distractor 1
|
||||
|
||||
---
|
||||
|
||||
Placeholder distractor 2
|
||||
|
||||
---
|
||||
|
||||
Placeholder distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Placeholder answer
|
||||
`;
|
||||
};
|
||||
|
||||
const getVideoChallengeTemplate = (
|
||||
options: ChallengeOptions
|
||||
): string => `${buildFrontMatterWithVideo(options)}
|
||||
|
||||
# --description--
|
||||
|
||||
${options.title} description.
|
||||
|
||||
# --question--
|
||||
|
||||
## --text--
|
||||
|
||||
${options.title} question?
|
||||
|
||||
## --answers--
|
||||
|
||||
Answer 1
|
||||
|
||||
---
|
||||
|
||||
Answer 2
|
||||
|
||||
---
|
||||
|
||||
Answer 3
|
||||
|
||||
## --video-solution--
|
||||
|
||||
1
|
||||
`;
|
||||
|
||||
const getAssignmentChallengeTemplate = (
|
||||
options: ChallengeOptions
|
||||
): string => `${buildFrontMatter(options)}
|
||||
|
||||
# --description--
|
||||
|
||||
${options.title} description.
|
||||
|
||||
# --assignment--
|
||||
|
||||
${options.title} assignment!
|
||||
|
||||
# --question--
|
||||
|
||||
## --text--
|
||||
|
||||
${options.title} question?
|
||||
|
||||
## --answers--
|
||||
|
||||
Answer 1
|
||||
|
||||
---
|
||||
|
||||
Answer 2
|
||||
|
||||
---
|
||||
|
||||
Answer 3
|
||||
|
||||
## --video-solution--
|
||||
|
||||
1
|
||||
`;
|
||||
|
||||
const getMultipleChoiceChallengeTemplate = (
|
||||
options: ChallengeOptions
|
||||
): string => {
|
||||
const correctAnswerIndex = Math.floor(Math.random() * 4) + 1;
|
||||
const feedback = (index: number) =>
|
||||
index === correctAnswerIndex
|
||||
? ''
|
||||
: `
|
||||
### --feedback--
|
||||
|
||||
Include feedback for answer ${index} here.
|
||||
`;
|
||||
|
||||
const answers = [1, 2, 3, 4]
|
||||
.map(
|
||||
index => `Answer ${index}
|
||||
${feedback(index)}`
|
||||
)
|
||||
.join('\n---\n\n');
|
||||
|
||||
return `${buildFrontMatter(options)}
|
||||
|
||||
# --description--
|
||||
|
||||
${options.title} description.
|
||||
|
||||
# --questions--
|
||||
|
||||
## --text--
|
||||
|
||||
${options.title} question?
|
||||
|
||||
## --answers--
|
||||
|
||||
${answers}
|
||||
## --video-solution--
|
||||
|
||||
${correctAnswerIndex}
|
||||
`;
|
||||
};
|
||||
|
||||
const getFillInTheBlankChallengeTemplate = (
|
||||
options: ChallengeOptions
|
||||
): string => `${buildFrontMatter(options)}
|
||||
|
||||
# --description--
|
||||
|
||||
${options.title} description.
|
||||
|
||||
# --fillInTheBlank--
|
||||
|
||||
## --sentence--
|
||||
|
||||
\`Fill BLANK the BLANK sentence.\`
|
||||
|
||||
## --blanks--
|
||||
|
||||
\`in\`
|
||||
|
||||
### --feedback--
|
||||
|
||||
It's \`in\`
|
||||
|
||||
---
|
||||
|
||||
\`blank\`
|
||||
`;
|
||||
|
||||
const getDialogueChallengeTemplate = (
|
||||
options: ChallengeOptions
|
||||
): string => `${buildFrontMatter(options)}
|
||||
|
||||
# --description--
|
||||
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const getGenericChallengeTemplate = (
|
||||
options: ChallengeOptions
|
||||
): string => `${buildFrontMatter(options)}
|
||||
|
||||
# --description--
|
||||
|
||||
Generic challenge description.
|
||||
|
||||
# --assignment--
|
||||
|
||||
Do the assignment.
|
||||
`;
|
||||
|
||||
interface DailyCodingChallengeOptions {
|
||||
challengeId: ObjectId;
|
||||
challengeNumber: number;
|
||||
}
|
||||
|
||||
export const getDailyJavascriptChallengeTemplate = ({
|
||||
challengeId,
|
||||
challengeNumber
|
||||
}: DailyCodingChallengeOptions) => `---
|
||||
id: ${challengeId.toString()}
|
||||
title: "Challenge ${challengeNumber}: Placeholder"
|
||||
challengeType: 28
|
||||
dashedName: challenge-${challengeNumber}
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
Placeholder description
|
||||
|
||||
# --hints--
|
||||
|
||||
Placeholder test
|
||||
|
||||
\`\`\`js
|
||||
assert.isTrue(true);
|
||||
\`\`\`
|
||||
|
||||
# --seed--
|
||||
|
||||
## --seed-contents--
|
||||
|
||||
\`\`\`js
|
||||
function placeholder(arg) {
|
||||
|
||||
return arg;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
# --solutions--
|
||||
|
||||
\`\`\`js
|
||||
function placeholder(arg) {
|
||||
|
||||
return arg;
|
||||
}
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
export const getDailyPythonChallengeTemplate = ({
|
||||
challengeId,
|
||||
challengeNumber
|
||||
}: DailyCodingChallengeOptions) => `---
|
||||
id: ${challengeId.toString()}
|
||||
title: "Challenge ${challengeNumber}: Placeholder"
|
||||
challengeType: 29
|
||||
dashedName: challenge-${challengeNumber}
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
Placeholder description
|
||||
|
||||
# --hints--
|
||||
|
||||
Placeholder test
|
||||
|
||||
\`\`\`js
|
||||
({test: () => { runPython(\`
|
||||
from unittest import TestCase
|
||||
TestCase().assertTrue(True)\`)
|
||||
}})
|
||||
\`\`\`
|
||||
|
||||
# --seed--
|
||||
|
||||
## --seed-contents--
|
||||
|
||||
\`\`\`py
|
||||
def placeholder(arg):
|
||||
|
||||
return arg
|
||||
\`\`\`
|
||||
|
||||
# --solutions--
|
||||
|
||||
\`\`\`py
|
||||
def placeholder(arg):
|
||||
|
||||
return arg
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
type Template = (opts: ChallengeOptions) => string;
|
||||
|
||||
export const getTemplate = (challengeType: string): Template => {
|
||||
const template = challengeTypeToTemplate[challengeType];
|
||||
if (!template) {
|
||||
throw Error(`Challenge type ${challengeType} has no template.
|
||||
To create one, please add a new function to this file and include it in the challengeTypeToTemplate map.
|
||||
`);
|
||||
}
|
||||
return template;
|
||||
};
|
||||
|
||||
/**
|
||||
* This should be kept in parity with the challengeTypes in the
|
||||
* client.
|
||||
*
|
||||
* Keys are explicitly marked null so we know the challenge type itself
|
||||
* exists, and can expand this to use the correct template later on.
|
||||
*/
|
||||
|
||||
const challengeTypeToTemplate: {
|
||||
[key: string]: null | Template;
|
||||
} = {
|
||||
0: getLegacyChallengeTemplate,
|
||||
1: getLegacyChallengeTemplate,
|
||||
2: null,
|
||||
3: getLegacyChallengeTemplate,
|
||||
4: getLegacyChallengeTemplate,
|
||||
5: getLegacyChallengeTemplate,
|
||||
6: getLegacyChallengeTemplate,
|
||||
7: null,
|
||||
8: getQuizChallengeTemplate,
|
||||
9: null,
|
||||
10: null,
|
||||
11: getVideoChallengeTemplate,
|
||||
12: null,
|
||||
13: null,
|
||||
14: null,
|
||||
15: getAssignmentChallengeTemplate,
|
||||
16: null,
|
||||
17: null,
|
||||
18: null,
|
||||
19: getMultipleChoiceChallengeTemplate,
|
||||
20: null,
|
||||
21: getDialogueChallengeTemplate,
|
||||
22: getFillInTheBlankChallengeTemplate,
|
||||
23: null,
|
||||
24: getGenericChallengeTemplate
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import fs from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { getFileName } from './get-file-name.js';
|
||||
|
||||
const basePath = join(process.cwd(), '__fixtures__');
|
||||
const commonPath = join(basePath, 'curriculum', 'challenges');
|
||||
|
||||
const block = 'project-get-file-name';
|
||||
const metaPath = join(commonPath, '_meta', block);
|
||||
const superBlockPath = join(commonPath, 'english', 'superblock-get-file-name');
|
||||
const projectPath = join(superBlockPath, block);
|
||||
|
||||
describe('getFileName helper', () => {
|
||||
beforeEach(() => {
|
||||
fs.mkdirSync(superBlockPath, { recursive: true });
|
||||
fs.mkdirSync(projectPath, { recursive: true });
|
||||
fs.mkdirSync(metaPath, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
join(projectPath, 'this-is-a-challenge.md'),
|
||||
'---\nid: a\ntitle: This is a Challenge\n---',
|
||||
'utf-8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
join(projectPath, 'what-a-cool-thing.md'),
|
||||
'---\nid: b\ntitle: What a Cool Thing\n---',
|
||||
'utf-8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
join(projectPath, 'i-dunno.md'),
|
||||
'---\nid: c\ntitle: I Dunno\n---',
|
||||
'utf-8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
join(metaPath, 'meta.json'),
|
||||
`{
|
||||
"id": "mock-id",
|
||||
"challengeOrder": [{"id": "a", "title": "This title is wrong"}, {"id": "b", "title": "I Dunno"}, {"id": "c", "title": "What a Cool Thing"}}}]}`,
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the file name if found', async () => {
|
||||
expect.assertions(1);
|
||||
process.env.INIT_CWD = projectPath;
|
||||
const fileName = await getFileName('a');
|
||||
expect(fileName).toEqual('this-is-a-challenge.md');
|
||||
});
|
||||
|
||||
it('should return null if not found', async () => {
|
||||
expect.assertions(1);
|
||||
process.env.INIT_CWD = projectPath;
|
||||
const fileName = await getFileName('d');
|
||||
expect(fileName).toBeNull();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.INIT_CWD;
|
||||
try {
|
||||
fs.rmSync(basePath, { recursive: true });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
console.log('Could not remove fixtures folder.');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readdir } from 'fs/promises';
|
||||
import matter from 'gray-matter';
|
||||
import { getProjectPath } from './get-project-info.js';
|
||||
|
||||
export const getFileName = async (id: string): Promise<string | null> => {
|
||||
const path = getProjectPath();
|
||||
const files = await readdir(path);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.md')) {
|
||||
continue;
|
||||
}
|
||||
let frontMatter = null;
|
||||
try {
|
||||
frontMatter = matter.read(`${path}${file}`);
|
||||
} catch (_err) {
|
||||
frontMatter = null;
|
||||
}
|
||||
if (String(frontMatter?.data.id) === id) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { select } from '@inquirer/prompts';
|
||||
import { ChallengeLang } from '@freecodecamp/shared/config/curriculum';
|
||||
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
|
||||
|
||||
export const getInputType = async (
|
||||
challengeType: string,
|
||||
challengeLang?: ChallengeLang
|
||||
): Promise<string | undefined> => {
|
||||
const isRequired =
|
||||
parseInt(challengeType) === challengeTypes.fillInTheBlank &&
|
||||
challengeLang === ChallengeLang.Chinese;
|
||||
|
||||
if (!isRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputType = await select<string>({
|
||||
message: 'What input type is challenge using?',
|
||||
choices: [
|
||||
{ name: 'pinyin-tone', value: 'pinyin-tone' },
|
||||
{ name: 'pinyin-to-hanzi', value: 'pinyin-to-hanzi' }
|
||||
]
|
||||
});
|
||||
|
||||
return inputType;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
ChallengeLang,
|
||||
SuperBlocks,
|
||||
superBlockToSpeechLang
|
||||
} from '@freecodecamp/shared/config/curriculum';
|
||||
|
||||
export const getLangFromSuperBlock = (
|
||||
superBlock: SuperBlocks
|
||||
): ChallengeLang => {
|
||||
const lang = superBlockToSpeechLang[superBlock];
|
||||
if (!lang) {
|
||||
throw new Error(`Missing lang mapping for superBlock: ${superBlock}`);
|
||||
}
|
||||
return lang;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { last } from 'lodash';
|
||||
import { getMetaData } from './project-metadata.js';
|
||||
|
||||
function getLastStep(): { stepNum: number } {
|
||||
const meta = getMetaData();
|
||||
const challengeOrder = meta.challengeOrder;
|
||||
const step = last(challengeOrder);
|
||||
if (!step) throw new Error('No steps found');
|
||||
|
||||
return { stepNum: challengeOrder.length };
|
||||
}
|
||||
|
||||
export { getLastStep };
|
||||
@@ -0,0 +1,39 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getProjectName, getProjectPath } from './get-project-info.js';
|
||||
|
||||
describe('getProjectPath helper', () => {
|
||||
it('should return the calling dir path', () => {
|
||||
const mockCallingDir = 'calling/dir';
|
||||
const expected = `${mockCallingDir}/`;
|
||||
|
||||
// Add mock to test condition
|
||||
process.env.INIT_CWD = mockCallingDir;
|
||||
|
||||
expect(getProjectPath()).toEqual(expected);
|
||||
|
||||
// Remove mock to not affect other tests
|
||||
delete process.env.INIT_CWD;
|
||||
});
|
||||
|
||||
it('should return the projects absolute path', () => {
|
||||
const expected = `${process.cwd()}/`;
|
||||
|
||||
expect(getProjectPath()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectName helper', () => {
|
||||
it('should return the last path segment of the calling dir', () => {
|
||||
const mockCallingDir = 'calling/dir';
|
||||
const expected = 'dir';
|
||||
|
||||
// Add mock to test condition
|
||||
process.env.INIT_CWD = mockCallingDir;
|
||||
|
||||
expect(getProjectName()).toEqual(expected);
|
||||
|
||||
// Remove mock to not affect other tests
|
||||
delete process.env.INIT_CWD;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export function getProjectPath(): string {
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
return (process.env.INIT_CWD || process.cwd()) + '/';
|
||||
}
|
||||
|
||||
export function getProjectName(): string {
|
||||
return getProjectPath().split('/').slice(-2)[0];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ObjectId } from 'bson';
|
||||
import { getStepTemplate } from './get-step-template.js';
|
||||
import { ChallengeLang } from '@freecodecamp/shared/config/curriculum';
|
||||
|
||||
const props = {
|
||||
challengeId: new ObjectId('60d4ebe4801158d1abe1b18f'),
|
||||
challengeSeeds: [
|
||||
{
|
||||
contents: '',
|
||||
editableRegionBoundaries: [0, 2],
|
||||
ext: 'html',
|
||||
id: '',
|
||||
key: 'indexhtml',
|
||||
name: 'index'
|
||||
}
|
||||
],
|
||||
stepNum: 5,
|
||||
challengeType: 0
|
||||
};
|
||||
|
||||
// Note: evaluates at highlevel the process, but seedHeads and seedTails could
|
||||
// be tested if more specifics are needed.
|
||||
describe('getStepTemplate util', () => {
|
||||
it('should be able to create a markdown', () => {
|
||||
const baseOutput = `---
|
||||
id: 60d4ebe4801158d1abe1b18f
|
||||
title: Step 5
|
||||
challengeType: 0
|
||||
dashedName: step-5
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
step 5 instructions
|
||||
|
||||
# --hints--
|
||||
|
||||
Test 1
|
||||
|
||||
\`\`\`js
|
||||
|
||||
\`\`\`
|
||||
|
||||
# --seed--
|
||||
|
||||
## --seed-contents--
|
||||
|
||||
\`\`\`html
|
||||
--fcc-editable-region--
|
||||
|
||||
--fcc-editable-region--
|
||||
\`\`\`\n`;
|
||||
|
||||
expect(getStepTemplate(props)).toEqual(baseOutput);
|
||||
});
|
||||
|
||||
it('should add lang property when challengeLang is passed', () => {
|
||||
const frontMatter = `---
|
||||
id: 60d4ebe4801158d1abe1b18f
|
||||
title: Step 5
|
||||
challengeType: 0
|
||||
dashedName: step-5
|
||||
lang: es
|
||||
---`;
|
||||
|
||||
expect(
|
||||
getStepTemplate({
|
||||
...props,
|
||||
challengeLang: ChallengeLang.Spanish
|
||||
})
|
||||
).match(new RegExp(`^${frontMatter}`));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ObjectId } from 'bson';
|
||||
import type { ChallengeLang } from '@freecodecamp/shared/config/curriculum';
|
||||
import { insertErms } from './insert-erms.js';
|
||||
|
||||
// Builds a block
|
||||
function getCodeBlock(label: string, content?: string) {
|
||||
return `\`\`\`${label}
|
||||
${typeof content !== 'undefined' ? content : ''}
|
||||
\`\`\`\n`;
|
||||
}
|
||||
|
||||
// Builds a section
|
||||
function getSeedSection(content: string, label: string) {
|
||||
return content
|
||||
? `
|
||||
|
||||
## --${label}--
|
||||
|
||||
${content}`
|
||||
: '';
|
||||
}
|
||||
|
||||
type StepOptions = {
|
||||
challengeId: ObjectId;
|
||||
challengeSeeds: ChallengeSeed[];
|
||||
stepNum: number;
|
||||
challengeType?: number;
|
||||
isFirstChallenge?: boolean;
|
||||
challengeLang?: ChallengeLang;
|
||||
};
|
||||
|
||||
export interface ChallengeSeed {
|
||||
contents: string;
|
||||
ext: string;
|
||||
editableRegionBoundaries: number[];
|
||||
}
|
||||
|
||||
// Build the base markdown for a step
|
||||
function getStepTemplate({
|
||||
challengeId,
|
||||
challengeSeeds,
|
||||
stepNum,
|
||||
challengeType,
|
||||
isFirstChallenge = false,
|
||||
challengeLang
|
||||
}: StepOptions): string {
|
||||
const seedTexts = challengeSeeds
|
||||
.map(({ contents, ext, editableRegionBoundaries }) => {
|
||||
let fullContents = contents;
|
||||
if (editableRegionBoundaries.length >= 2) {
|
||||
fullContents = insertErms(contents, editableRegionBoundaries);
|
||||
}
|
||||
return getCodeBlock(ext, fullContents);
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const stepDescription = `step ${stepNum} instructions`;
|
||||
const seedChallengeSection = getSeedSection(seedTexts, 'seed-contents');
|
||||
|
||||
const demoString = isFirstChallenge
|
||||
? `
|
||||
# demoType can either be 'onClick' or 'onLoad'. If the project or lab doesn't have a preview, delete the property
|
||||
demoType: onClick`
|
||||
: '';
|
||||
|
||||
const langString = challengeLang
|
||||
? `
|
||||
lang: ${challengeLang}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
`---
|
||||
id: ${challengeId.toString()}
|
||||
title: Step ${stepNum}
|
||||
challengeType: ${challengeType ?? 'placeholder'}
|
||||
dashedName: step-${stepNum}${langString}${demoString}
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
${stepDescription}
|
||||
|
||||
# --hints--
|
||||
|
||||
Test 1
|
||||
|
||||
${getCodeBlock('js')}
|
||||
# --seed--` + seedChallengeSection
|
||||
);
|
||||
}
|
||||
|
||||
export { getStepTemplate };
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { insertErms } from './insert-erms.js';
|
||||
|
||||
describe('insertErms helper', () => {
|
||||
const code = `<h1>Hello World</h1>
|
||||
<main>
|
||||
<h2>CatPhotoApp</h2>
|
||||
<img src="https://www.bit.ly/fcc-relaxing-cat" alt="A cute orange cat lying on its back.">
|
||||
<p>Kitty ipsum dolor sit amet, shed everywhere shed everywhere stretching attack your ankles chase the red dot, hairball run catnip eat the grass sniff.</p>
|
||||
<p>Purr jump eat the grass rip the couch scratched sunbathe, shed everywhere rip the couch sleep in the sink fluffy fur catnip scratched.</p>
|
||||
</main>`;
|
||||
|
||||
it('should throw error if erm length is less than 2', () => {
|
||||
const items = [[], [1]];
|
||||
|
||||
items.forEach(item => {
|
||||
expect(() => {
|
||||
insertErms(code, item);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update code with markers if provided', () => {
|
||||
const newCode = `--fcc-editable-region--
|
||||
<h1>Hello World</h1>
|
||||
<main>
|
||||
<h2>CatPhotoApp</h2>
|
||||
<img src="https://www.bit.ly/fcc-relaxing-cat" alt="A cute orange cat lying on its back.">
|
||||
<p>Kitty ipsum dolor sit amet, shed everywhere shed everywhere stretching attack your ankles chase the red dot, hairball run catnip eat the grass sniff.</p>
|
||||
<p>Purr jump eat the grass rip the couch scratched sunbathe, shed everywhere rip the couch sleep in the sink fluffy fur catnip scratched.</p>
|
||||
--fcc-editable-region--
|
||||
</main>`;
|
||||
|
||||
expect(insertErms(code, [0, 7])).toEqual(newCode);
|
||||
});
|
||||
|
||||
it('should update code with 2 markers if more are provided', () => {
|
||||
const newCode = `<h1>Hello World</h1>
|
||||
<main>
|
||||
--fcc-editable-region--
|
||||
<h2>CatPhotoApp</h2>
|
||||
--fcc-editable-region--
|
||||
<img src="https://www.bit.ly/fcc-relaxing-cat" alt="A cute orange cat lying on its back.">
|
||||
<p>Kitty ipsum dolor sit amet, shed everywhere shed everywhere stretching attack your ankles chase the red dot, hairball run catnip eat the grass sniff.</p>
|
||||
<p>Purr jump eat the grass rip the couch scratched sunbathe, shed everywhere rip the couch sleep in the sink fluffy fur catnip scratched.</p>
|
||||
</main>`;
|
||||
|
||||
expect(insertErms(code, [2, 4, 6, 7])).toEqual(newCode);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Update given value with markers (labels)
|
||||
const insertErms = (seedCode: string, erms: number[]): string => {
|
||||
if (erms.length <= 1) {
|
||||
throw Error('erms should contain 2 elements');
|
||||
}
|
||||
|
||||
const separator = '\n';
|
||||
const lines = seedCode.split(separator);
|
||||
const markerLabel = '--fcc-editable-region--';
|
||||
|
||||
// Generate a version of seed code with the erm markers
|
||||
const newSeedCode = erms
|
||||
.slice(0, 2)
|
||||
.reduce((acc, erm) => {
|
||||
if (Number.isInteger(erm)) {
|
||||
acc.splice(erm, 0, markerLabel);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, lines)
|
||||
.join(separator);
|
||||
|
||||
return newSeedCode;
|
||||
};
|
||||
|
||||
export { insertErms };
|
||||
@@ -0,0 +1,46 @@
|
||||
import { input, select } from '@inquirer/prompts';
|
||||
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
|
||||
import { getLastStep } from './get-last-step-file-number.js';
|
||||
|
||||
export const newChallengePrompts = async (): Promise<{
|
||||
title: string;
|
||||
dashedName: string;
|
||||
challengeType: string;
|
||||
}> => {
|
||||
const challengeType = await select<string>({
|
||||
message: 'What type of challenge is this?',
|
||||
choices: Object.entries(challengeTypes).map(([key, value]) => ({
|
||||
name: key,
|
||||
value: value.toString()
|
||||
}))
|
||||
});
|
||||
|
||||
const lastStep = getLastStep().stepNum;
|
||||
const defaultTitle = `Step ${lastStep + 1}`;
|
||||
const defaultDashedName = `step-${lastStep + 1}`;
|
||||
|
||||
const dashedName = await input({
|
||||
message: 'What is the dashed name (in kebab-case) for this challenge?',
|
||||
default: defaultDashedName,
|
||||
validate: (block: string) => {
|
||||
if (!block.length) {
|
||||
return 'please enter a dashed name';
|
||||
}
|
||||
if (/[^a-z0-9-]/.test(block)) {
|
||||
return 'please use alphanumerical characters and kebab case';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
transformer: (block: string) => block.toLowerCase()
|
||||
});
|
||||
const title = await input({
|
||||
message: 'What is the title of this challenge?',
|
||||
default: defaultTitle
|
||||
});
|
||||
|
||||
return {
|
||||
title,
|
||||
dashedName,
|
||||
challengeType
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { select } from '@inquirer/prompts';
|
||||
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
|
||||
|
||||
const taskChallenges = [
|
||||
challengeTypes.multipleChoice,
|
||||
challengeTypes.fillInTheBlank,
|
||||
challengeTypes.generic
|
||||
];
|
||||
|
||||
export const newTaskPrompts = async (): Promise<{
|
||||
challengeType: string;
|
||||
}> => {
|
||||
const challengeType = await select<string>({
|
||||
message: 'What type of task challenge is this?',
|
||||
choices: Object.entries(challengeTypes)
|
||||
.filter(entry => taskChallenges.includes(entry[1]))
|
||||
.map(([key, value]) => ({
|
||||
name: key,
|
||||
value: value.toString()
|
||||
}))
|
||||
});
|
||||
|
||||
return {
|
||||
challengeType
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from 'fs/promises';
|
||||
|
||||
import { withTrace } from './utils.js';
|
||||
|
||||
export type BlockInfo = {
|
||||
title: string;
|
||||
intro: string[];
|
||||
};
|
||||
|
||||
export type SuperBlockInfo = {
|
||||
blocks: Record<string, BlockInfo>;
|
||||
chapters?: Record<string, string>;
|
||||
modules?: Record<string, string>;
|
||||
'module-intros'?: Record<
|
||||
string,
|
||||
{
|
||||
intro: string[];
|
||||
note?: string;
|
||||
title?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type IntroJson = Record<string, SuperBlockInfo>;
|
||||
|
||||
export function parseIntroJson(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 IntroJson
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { join } from 'path';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { getBlockStructure } from '@freecodecamp/curriculum/file-handler';
|
||||
import { getMetaData } from './project-metadata.js';
|
||||
|
||||
vi.mock('@freecodecamp/curriculum/file-handler');
|
||||
|
||||
const commonPath = join('curriculum', 'challenges', 'blocks');
|
||||
const block = 'block-name';
|
||||
|
||||
describe('project-metadata helper', () => {
|
||||
describe('getMetaData helper', () => {
|
||||
it('should call getBlockStructure with the correct path', () => {
|
||||
process.env.INIT_CWD = join(commonPath, block);
|
||||
getMetaData();
|
||||
expect(getBlockStructure).toHaveBeenCalledWith(block);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import path from 'path';
|
||||
import {
|
||||
getBlockStructure,
|
||||
writeBlockStructure
|
||||
} from '@freecodecamp/curriculum/file-handler';
|
||||
import type { BlockLabel } from '@freecodecamp/shared/config/blocks';
|
||||
import { getProjectPath } from './get-project-info.js';
|
||||
|
||||
export type Meta = {
|
||||
blockLayout: string;
|
||||
blockLabel?: BlockLabel;
|
||||
isUpcomingChange: boolean;
|
||||
dashedName: string;
|
||||
helpCategory: string;
|
||||
time: string;
|
||||
template: string;
|
||||
required: string[];
|
||||
challengeOrder: { id: string; title: string }[];
|
||||
};
|
||||
|
||||
function getMetaData() {
|
||||
const block = getBlock(getProjectPath());
|
||||
return getBlockStructure(block) as Meta;
|
||||
}
|
||||
|
||||
function getBlock(filePath: string) {
|
||||
return path.basename(filePath);
|
||||
}
|
||||
|
||||
async function updateMetaData(newMetaData: Record<string, unknown>) {
|
||||
const block = getBlock(getProjectPath());
|
||||
await writeBlockStructure(block, newMetaData);
|
||||
}
|
||||
|
||||
export { getMetaData, updateMetaData, getBlock };
|
||||
@@ -0,0 +1,9 @@
|
||||
function isTaskChallenge(title: string): boolean {
|
||||
return /^\s*task\s\d+$/gi.test(title);
|
||||
}
|
||||
|
||||
function getTaskNumberFromTitle(title: string): number {
|
||||
return parseInt(title.replace(/\D/g, ''), 10);
|
||||
}
|
||||
|
||||
export { getTaskNumberFromTitle, isTaskChallenge };
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { insertInto } from './utils.js';
|
||||
|
||||
describe('insertInto', () => {
|
||||
it('should not modify the original array', () => {
|
||||
const arr = [1, 2, 3];
|
||||
const result = insertInto(arr, 1, 99);
|
||||
expect(arr).toEqual([1, 2, 3]);
|
||||
expect(result).not.toBe(arr);
|
||||
});
|
||||
|
||||
it('should insert at the end if the index is larger than the original array', () => {
|
||||
const arr = [1, 2, 3];
|
||||
const result = insertInto(arr, 10, 99);
|
||||
expect(result).toEqual([1, 2, 3, 99]);
|
||||
});
|
||||
|
||||
it('should insert at the beginning if the index is <= 0', () => {
|
||||
const arr = [1, 2, 3];
|
||||
const result = insertInto(arr, 0, 99);
|
||||
expect(result).toEqual([99, 1, 2, 3]);
|
||||
const resultNeg = insertInto(arr, -5, 99);
|
||||
expect(resultNeg).toEqual([99, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should insert at the correct index', () => {
|
||||
const arr = [1, 2, 3];
|
||||
const result = insertInto(arr, 1, 99);
|
||||
expect(result).toEqual([1, 99, 2, 3]);
|
||||
});
|
||||
|
||||
it('should work with empty arrays', () => {
|
||||
const arr: number[] = [];
|
||||
const result = insertInto(arr, 0, 99);
|
||||
expect(result).toEqual([99]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export function insertInto<T>(arr: T[], index: number, elem: T): T[] {
|
||||
if (index >= arr.length) return [...arr, elem];
|
||||
if (index <= 0) return [elem, ...arr];
|
||||
|
||||
return arr.flatMap((x, id) => {
|
||||
return id === index ? [elem, x] : x;
|
||||
});
|
||||
}
|
||||
|
||||
// fs Promise functions return errors, but no stack trace. This adds back in
|
||||
// the stack trace.
|
||||
export 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user