Files
freeCodeCamp/curriculum/schema/challenge-schema.js
2025-04-10 10:06:46 -05:00

337 lines
9.5 KiB
JavaScript

const Joi = require('joi');
Joi.objectId = require('joi-objectid')(Joi);
const { challengeTypes } = require('../../shared/config/challenge-types');
const { chapterBasedSuperBlocks } = require('../../shared/config/curriculum');
const {
availableCharacters,
availableBackgrounds,
availableAudios,
availableAlignments
} = require('./scene-assets');
const slugRE = new RegExp('^[a-z0-9-]+$');
const slugWithSlashRE = new RegExp('^[a-z0-9-/]+$');
const fileJoi = Joi.object().keys({
fileKey: Joi.string(),
ext: Joi.string(),
name: Joi.string(),
editableRegionBoundaries: [Joi.array().items(Joi.number())],
path: Joi.string(),
error: Joi.valid(null),
head: Joi.string().allow(''),
tail: Joi.string().allow(''),
seed: Joi.string().allow(''),
contents: Joi.string().allow(''),
id: Joi.string().allow(''),
history: Joi.array().items(Joi.string().allow(''))
});
const prerequisitesJoi = Joi.object().keys({
id: Joi.objectId().required(),
title: Joi.string().required()
});
const positionJoi = Joi.object().keys({
x: Joi.number().required().strict(),
y: Joi.number().required().strict(),
z: Joi.number().required().strict()
});
const setupCharacterJoi = Joi.object().keys({
character: Joi.string()
.valid(...availableCharacters)
.required(),
position: positionJoi.required(),
opacity: Joi.number().strict()
});
const setupAudioJoi = Joi.object().keys({
filename: Joi.string()
.valid(...availableAudios)
.required(),
startTime: Joi.number().required().strict(),
startTimestamp: Joi.number().strict(),
finishTimestamp: Joi.number().strict()
});
const setupJoi = Joi.object().keys({
background: Joi.string()
.valid(...availableBackgrounds)
.required(),
characters: Joi.array().items(setupCharacterJoi).min(1).required(),
audio: setupAudioJoi.required(),
alwaysShowDialogue: Joi.boolean()
});
const DialogueJoi = Joi.object().keys({
text: Joi.string().required(),
align: Joi.string().valid(...availableAlignments)
});
const commandJoi = Joi.object().keys({
background: Joi.string().valid(...availableBackgrounds),
character: Joi.string()
.valid(...availableCharacters)
.required(),
position: positionJoi,
opacity: Joi.number().strict(),
startTime: Joi.number().required().strict(),
finishTime: Joi.number().strict(),
dialogue: DialogueJoi
});
const questionJoi = Joi.object().keys({
text: Joi.string().required(),
answers: Joi.array()
.items(
Joi.object().keys({
answer: Joi.string().required(),
feedback: Joi.string().allow(null)
})
)
.required()
.unique('answer'),
solution: Joi.number().required()
});
const quizJoi = Joi.object().keys({
questions: Joi.array()
.items(
Joi.object().keys({
text: Joi.string().required(),
distractors: Joi.array()
.items(
Joi.valid(Joi.ref('...answer')).forbidden(),
Joi.string().required()
)
.length(3)
.required()
.unique(),
answer: Joi.string().required()
})
)
.custom((value, helpers) => {
return value.length === 10 || value.length === 20
? value
: helpers.error('array.invalidLength');
})
.messages({
'array.invalidLength': 'Quiz must have exactly 10 or 20 questions.'
})
.required()
});
const schema = Joi.object()
.keys({
block: Joi.string().regex(slugRE).required(),
blockId: Joi.objectId(),
blockType: Joi.when('superBlock', {
is: chapterBasedSuperBlocks,
then: Joi.valid(
'workshop',
'lab',
'lecture',
'review',
'quiz',
'exam'
).required(),
otherwise: Joi.valid(null)
}),
blockLayout: Joi.valid(
'challenge-list',
'challenge-grid',
'dialogue-grid',
'link',
'project-list',
'legacy-challenge-list',
'legacy-link',
'legacy-challenge-grid'
).required(),
challengeOrder: Joi.number(),
chapter: Joi.string().when('superBlock', {
is: chapterBasedSuperBlocks,
then: Joi.required(),
otherwise: Joi.optional()
}),
certification: Joi.string().regex(slugWithSlashRE),
challengeType: Joi.number().min(0).max(26).required(),
checksum: Joi.number(),
// TODO: require this only for normal challenges, not certs
dashedName: Joi.string().regex(slugRE),
demoType: Joi.string().valid('onClick', 'onLoad'),
description: Joi.when('challengeType', {
is: [
challengeTypes.step,
challengeTypes.video,
challengeTypes.multipleChoice,
challengeTypes.fillInTheBlank
],
then: Joi.string().allow(''),
otherwise: Joi.string().required()
}),
disableLoopProtectTests: Joi.boolean().required(),
disableLoopProtectPreview: Joi.boolean().required(),
explanation: Joi.when('challengeType', {
is: [challengeTypes.multipleChoice, challengeTypes.fillInTheBlank],
then: Joi.string()
}),
challengeFiles: Joi.array().items(fileJoi),
guideUrl: Joi.string().uri({ scheme: 'https' }),
hasEditableBoundaries: Joi.boolean(),
helpCategory: Joi.valid(
'JavaScript',
'HTML-CSS',
'Python',
'Backend Development',
'C-Sharp',
'English',
'Odin',
'Euler',
'Rosetta'
),
isLastChallengeInBlock: Joi.boolean().required(),
videoUrl: Joi.string().allow(''),
fillInTheBlank: Joi.object().keys({
sentence: Joi.string().required(),
blanks: Joi.array()
.items(
Joi.object().keys({
answer: Joi.string().required(),
feedback: Joi.string().allow(null)
})
)
.required()
}),
forumTopicId: Joi.number(),
id: Joi.objectId().required(),
instructions: Joi.string().allow(''),
isComingSoon: Joi.bool(),
isLocked: Joi.bool(),
isPrivate: Joi.bool(),
module: Joi.string().when('superBlock', {
is: chapterBasedSuperBlocks,
then: Joi.required(),
otherwise: Joi.optional()
}),
msTrophyId: Joi.when('challengeType', {
is: [challengeTypes.msTrophy],
then: Joi.string().required()
}),
notes: Joi.string().allow(''),
order: Joi.number(),
prerequisites: Joi.when('challengeType', {
is: [challengeTypes.exam],
then: Joi.array().items(prerequisitesJoi)
}),
// video challenges only:
videoId: Joi.when('challengeType', {
is: [challengeTypes.video],
then: Joi.string().required()
}),
videoLocaleIds: Joi.when('challengeType', {
is: challengeTypes.video,
then: Joi.object().keys({
espanol: Joi.string(),
italian: Joi.string(),
portuguese: Joi.string()
})
}),
bilibiliIds: Joi.when('challengeType', {
is: challengeTypes.video,
then: Joi.object().keys({
aid: Joi.number().required(),
bvid: Joi.string().required(),
cid: Joi.number().required()
})
}),
questions: Joi.when('challengeType', {
is: [
challengeTypes.video,
challengeTypes.multipleChoice,
challengeTypes.theOdinProject
],
then: Joi.array().items(questionJoi).min(1).required(),
otherwise: Joi.array().length(0)
}),
quizzes: Joi.when('challengeType', {
is: challengeTypes.quiz,
then: Joi.array().items(quizJoi).min(1).required(),
otherwise: Joi.forbidden()
}),
required: Joi.array().items(
Joi.object().keys({
link: Joi.string(),
raw: Joi.bool(),
src: Joi.string(),
crossDomain: Joi.bool()
})
),
assignments: Joi.when('challengeType', {
is: challengeTypes.dialogue,
then: Joi.array().items(Joi.string()).required(),
otherwise: Joi.array().items(Joi.string())
}),
scene: Joi.object().keys({
setup: setupJoi.required(),
commands: Joi.array()
.items(commandJoi)
.unique(
(a, b) =>
a.dialogue &&
b.dialogue &&
!(
(a.startTime < b.startTime &&
a.finishTime < b.finishTime &&
a.finishTime <= b.startTime) ||
(b.startTime < a.startTime &&
b.finishTime < a.finishTime &&
b.finishTime <= a.startTime)
)
)
.messages({
'array.unique': 'Dialogues must not have overlapping times.'
})
}),
solutions: Joi.array().items(Joi.array().items(fileJoi).min(1)),
superBlock: Joi.string().regex(slugWithSlashRE),
superOrder: Joi.number(),
suborder: Joi.number(),
hooks: Joi.object().keys({
beforeAll: Joi.string().allow('')
}),
tests: Joi.array()
.items(
// public challenges
Joi.object().keys({
id: Joi.string().allow(''),
text: Joi.string().required(),
testString: Joi.string().allow('').required()
}),
// our tests used in certification verification
Joi.object().keys({
id: Joi.string().required(),
title: Joi.string().required()
})
)
.required(),
template: Joi.string().allow(''),
title: Joi.string().required(),
transcript: Joi.when('challengeType', {
is: [challengeTypes.generic, challengeTypes.video],
then: Joi.string()
}),
translationPending: Joi.bool().required(),
url: Joi.when('challengeType', {
is: [challengeTypes.codeAllyPractice, challengeTypes.codeAllyCert],
then: Joi.string().required()
}),
usesMultifileEditor: Joi.boolean()
})
.xor('helpCategory', 'isPrivate');
exports.challengeSchemaValidator = () => {
return challenge => schema.validate(challenge);
};