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

This commit is contained in:
wehub-resource-sync
2026-07-13 11:55:53 +08:00
commit dde272c4b8
19405 changed files with 2730632 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+402
View File
@@ -0,0 +1,402 @@
import Joi from 'joi';
import joiObjectId from 'joi-objectid';
Joi.objectId = joiObjectId(Joi);
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import {
chapterBasedSuperBlocks,
languageSuperBlocks,
SuperBlocks
} from '@freecodecamp/shared/config/curriculum';
import {
availableCharacters,
availableBackgrounds,
availableAudios,
availableAlignments
} from './scene-assets.js';
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),
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),
audioId: Joi.string().allow(null)
})
)
.required()
.unique('answer'),
solution: Joi.number().min(1).max(Joi.ref('..answers.length')).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(),
audioData: Joi.object().keys({
audio: Joi.object({
filename: Joi.string().required(),
startTimestamp: Joi.number(),
finishTimestamp: Joi.number()
}),
transcript: Joi.array().items(
Joi.object({
character: Joi.string().required(),
text: 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()
});
export const schema = Joi.object().keys({
block: Joi.string().regex(slugRE).required(),
blockId: Joi.objectId(),
blockLabel: Joi.when('superBlock', {
is: [...chapterBasedSuperBlocks],
then: Joi.valid(
'workshop',
'lab',
'lecture',
'review',
'quiz',
'exam',
'warm-up',
'learn',
'practice'
).required(),
otherwise: Joi.optional()
}),
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),
isExam: Joi.boolean(),
challengeType: Joi.number().min(0).max(31).required(),
// 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,
challengeTypes.review
],
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),
// TODO: Consider renaming to something else. Stuff show.tsx knows how to render in order
nodules: Joi.array().items(
Joi.object().keys({
type: Joi.valid('paragraph', 'interactiveEditor').required(),
files: Joi.when('type', {
is: ['interactiveEditor'],
then: Joi.array().items(
Joi.object().keys({
ext: Joi.string().required(),
name: Joi.string().required(),
contents: Joi.string().required(),
contentsHtml: Joi.string().required()
})
),
otherwise: Joi.forbidden()
}),
contents: Joi.when('type', {
is: ['paragraph'],
then: Joi.string().required(),
otherwise: Joi.forbidden()
})
})
),
hasEditableBoundaries: Joi.boolean(),
helpCategory: Joi.valid(
'JavaScript',
'HTML-CSS',
'Python',
'Backend Development',
'C-Sharp',
'English',
'Odin',
'Euler',
'Rosetta',
'Chinese Curriculum',
'Spanish Curriculum',
'General'
).required(),
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(),
lang: Joi.string().when('superBlock', {
is: languageSuperBlocks,
then: Joi.valid('en-US', 'es', 'zh-CN').required(),
otherwise: Joi.forbidden()
}),
inputType: Joi.when('challengeType', {
is: challengeTypes.fillInTheBlank,
then: Joi.when('superBlock', {
is: Joi.valid(SuperBlocks.A1Chinese, SuperBlocks.A2Chinese),
then: Joi.string().valid('pinyin-tone', 'pinyin-to-hanzi').required(),
otherwise: Joi.forbidden()
}),
otherwise: Joi.forbidden()
}),
instructions: Joi.string().when('challengeType', {
is: [challengeTypes.pythonProject, challengeTypes.codeAllyCert],
then: Joi.string().min(1).required(),
otherwise: Joi.string().allow('')
}),
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())
}),
saveSubmissionToDB: Joi.bool(),
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.'
})
}),
// This is only to be used for dynamic client updates.
sourceLocation: Joi.string(),
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(''),
beforeEach: Joi.string().allow(''),
afterEach: Joi.string().allow(''),
afterAll: 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()
});
export const challengeSchemaValidator = () => {
return challenge => schema.validate(challenge);
};
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { schema } from './challenge-schema.js';
describe('challenge schema', () => {
it('should not be changed without informing the mobile team', () => {
expect(schema.describe()).toMatchSnapshot();
});
});
+113
View File
@@ -0,0 +1,113 @@
const Joi = require('joi');
const blocksSchema = Joi.object().pattern(
/./,
Joi.object().keys({ challenges: Joi.array().required() })
);
const superblocks = [
'certifications',
'responsive-web-design',
'javascript-algorithms-and-data-structures',
'front-end-development-libraries',
'data-visualization',
'back-end-development-and-apis',
'quality-assurance',
'scientific-computing-with-python',
'data-analysis-with-python',
'information-security',
'machine-learning-with-python',
'coding-interview-prep',
'relational-database',
'javascript-algorithms-and-data-structures-v8',
'semantic-html',
'a2-professional-chinese',
'b1-english-for-developers',
'dev-playground',
'python-for-everybody',
'a2-professional-spanish',
'basic-html',
'a2-english-for-developers',
'rosetta-code',
'foundational-c-sharp-with-microsoft',
'college-algebra-with-python',
'project-euler',
'2022/responsive-web-design',
'the-odin-project',
'introduction-to-algorithms-and-data-structures',
'introduction-to-precalculus',
'html-and-accessibility',
'computer-basics',
'basic-css',
'design-for-developers',
'absolute-and-relative-units',
'pseudo-classes-and-elements',
'css-colors',
'styling-forms',
'css-box-model',
'css-flexbox',
'css-typography',
'css-and-accessibility',
'css-positioning',
'attribute-selectors',
'responsive-design',
'css-variables',
'css-grid',
'css-animations',
'learn-oop-with-python',
'learn-rag-mcp-fundamentals',
'introduction-to-bash',
'introduction-to-sql-and-postgresql',
'learn-bash-scripting',
'learn-sql-and-bash',
'introduction-to-nano',
'introduction-to-git-and-github',
'introduction-to-variables-and-strings-in-javascript',
'introduction-to-booleans-and-numbers-in-javascript',
'introduction-functions-in-javascript',
'introduction-to-arrays-in-javascript',
'introduction-to-objects-in-javascript',
'introduction-to-loops-in-javascript',
'javascript-fundamentals-review',
'introduction-to-higher-order-functions-and-callbacks-in-javascript',
'learn-dom-manipulation-and-events-with-javascript',
'introduction-to-javascript-and-accessibility',
'learn-javascript-debugging',
'learn-basic-regex-with-javascript',
'introduction-to-dates-in-javascript',
'learn-audio-and-video-events-with-javascript',
'introduction-to-maps-and-sets-in-javascript',
'learn-localstorage-and-crud-operations-with-javascript',
'introduction-to-javascript-classes',
'learn-recursion-with-javascript',
'introduction-to-functional-programming-with-javascript',
'introduction-to-asynchronous-javascript',
'learn-data-visualization-with-d3',
'introduction-to-python-basics',
'learn-python-loops-and-sequences',
'learn-python-dictionaries-and-sets',
'learn-error-handling-in-python',
'learn-python-classes-and-objects',
'introduction-to-oop-in-python',
'introduction-to-linear-data-structures-in-python',
'learn-algorithms-in-python',
'learn-graphs-and-trees-in-python',
'learn-dynamic-programming-in-python'
];
const schema = Joi.object().keys(
superblocks.reduce((acc, superblock) => {
acc[superblock] = Joi.object()
.keys({
blocks: blocksSchema
})
.required();
return acc;
}, {})
);
exports.curriculumSchemaValidator = curriculum => {
return schema.validate(curriculum, {
abortEarly: false
});
};
+69
View File
@@ -0,0 +1,69 @@
const Joi = require('joi');
const blockIntroSchema = Joi.object()
.keys({
title: Joi.string().trim().min(1).required(),
intro: Joi.array().items(Joi.string().allow('')).required()
})
.unknown(true);
const superBlockIntroSchema = Joi.object()
.keys({
title: Joi.string().trim().min(1).required(),
intro: Joi.array().items(Joi.string().allow('')).required(),
blocks: Joi.object().pattern(Joi.string(), blockIntroSchema).required()
})
.unknown(true);
function createIntroSchema(expectedBlocksBySuperblock) {
return Joi.object()
.unknown(true)
.custom((intros, helpers) => {
for (const [superblock, blocks] of Object.entries(
expectedBlocksBySuperblock
)) {
const superBlockIntro = intros[superblock];
if (!superBlockIntro) {
return helpers.error('any.custom', {
message: `Missing intro.json entry for superblock "${superblock}"`
});
}
const { error: superBlockError } =
superBlockIntroSchema.validate(superBlockIntro);
if (superBlockError) {
return helpers.error('any.custom', {
message: `Invalid intro.json shape for superblock "${superblock}": ${superBlockError.message}`
});
}
for (const block of blocks) {
const blockIntro = superBlockIntro.blocks?.[block];
if (!blockIntro) {
return helpers.error('any.custom', {
message: `Missing intro.json block title entry for "${superblock}/${block}"`
});
}
const { error: blockError } = blockIntroSchema.validate(blockIntro);
if (blockError) {
return helpers.error('any.custom', {
message: `Invalid intro.json block entry for "${superblock}/${block}": ${blockError.message}`
});
}
}
}
return intros;
}, 'intro block coverage validation')
.messages({
'any.custom': '{{#message}}'
});
}
exports.validateIntroSchema = (intros, expectedBlocksBySuperblock) => {
return createIntroSchema(expectedBlocksBySuperblock).validate(intros, {
abortEarly: false
});
};
+53
View File
@@ -0,0 +1,53 @@
import path from 'node:path';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { addSuperblockStructure } from '../src/build-curriculum.js';
import { getCurriculumStructure } from '../src/file-handler.js';
import introSchema from './intro-schema.js';
const { validateIntroSchema } = introSchema;
const introPath = path.resolve(
import.meta.dirname,
'../../client/i18n/locales/english/intro.json'
);
const intros = JSON.parse(readFileSync(introPath, 'utf8'));
function getExpectedBlocksBySuperblock() {
const { superblocks } = getCurriculumStructure();
return addSuperblockStructure(superblocks, true).reduce(
(expected, { name, blocks }) => {
expected[name] = blocks.map(({ dashedName }) => dashedName);
return expected;
},
{}
);
}
describe('intro schema', () => {
it('includes block titles for every curriculum block', () => {
const result = validateIntroSchema(intros, getExpectedBlocksBySuperblock());
expect(result.error).toBeUndefined();
});
it('fails if a block title entry is missing', () => {
const expectedBlocksBySuperblock = getExpectedBlocksBySuperblock();
const [superblock, blocks] = Object.entries(expectedBlocksBySuperblock)[0];
const block = blocks[0];
const introsWithoutBlock = structuredClone(intros);
delete introsWithoutBlock[superblock].blocks[block];
const result = validateIntroSchema(introsWithoutBlock, {
[superblock]: [block]
});
expect(result.error).toBeDefined();
expect(result.error?.message).toContain(`${superblock}/${block}`);
});
});
+76
View File
@@ -0,0 +1,76 @@
const Joi = require('joi');
const slugRE = new RegExp('^[a-z0-9-]+$');
const schema = Joi.object()
.keys({
blockLayout: Joi.valid(
'challenge-list',
'challenge-grid',
'dialogue-grid',
'link',
'project-list',
'legacy-challenge-list',
'legacy-link',
'legacy-challenge-grid'
).required(),
blockLabel: Joi.valid(
'workshop',
'lab',
'lecture',
'review',
'quiz',
'exam',
'warm-up',
'learn',
'practice'
),
isUpcomingChange: Joi.boolean().required(),
dashedName: Joi.string().regex(slugRE).required(),
usesMultifileEditor: Joi.boolean(),
hasEditableBoundaries: Joi.boolean(),
disableLoopProtectTests: Joi.boolean(),
disableLoopProtectPreview: Joi.boolean(),
template: Joi.string(),
required: Joi.array()
.items(
Joi.object().keys({
link: Joi.string(),
raw: Joi.bool(),
src: Joi.string()
})
)
.min(1)
.message(
`'required' must contain at least one item or not exist. Add an item or delete the property`
),
challengeOrder: Joi.array()
.items(
Joi.object().keys({
id: Joi.string().required(),
title: Joi.string().required()
})
)
.min(1)
.required(),
helpCategory: Joi.valid(
'JavaScript',
'HTML-CSS',
'Python',
'Backend Development',
'C-Sharp',
'English',
'Odin',
'Euler',
'Rosetta',
'Chinese Curriculum',
'Spanish Curriculum',
'General'
).required()
})
// this makes sure there is no unknown key in the object
.unknown(false);
exports.validateMetaSchema = meta => {
return schema.validate(meta, { abortEarly: false });
};
+354
View File
@@ -0,0 +1,354 @@
const availableCharacters = [
// English
'Alice',
'Amy',
'Anna',
'Bob',
'Brian',
'Candidate',
'David',
'Delivery Man',
'Expert',
'Jake',
'James',
'Jessica',
'Jim',
'Josh',
'Linda',
'Lisa',
'Maria',
'Mark',
'Riker',
'Sarah',
'Second Candidate',
'Sophie',
'Tom',
// Spanish
'Ángela',
'Camila',
'Carlos',
'Elena',
'Esteban',
'Joaquín',
'Julieta',
'Luis',
'Luna',
'Marisol',
'Mateo',
'Noelia',
'René',
'Sebastián',
'Diego',
'Valeria',
// Chinese
'Chen Na',
'Huang Jingyi',
'Li Hong',
'Li Ping',
'Lin Yating',
'Liu Ming',
'Wang Hua',
'Zhang Wei',
'Zhou Jia',
'Zhou Yongjie'
];
const availableBackgrounds = [
'cafe.png',
'chaos.png',
'company1-boardroom.png',
'company1-breakroom.png',
'company1-center.png',
'company1-dining.png',
'company1-lydia-cubicle.png',
'company1-parking.png',
'company1-reception.png',
'company1-roof.png',
'company2-boardroom.png',
'company2-breakroom.png',
'company2-center.png',
'company2-dining.png',
'company2-lydia-cubicle.png',
'company2-parking.png',
'company2-reception.png',
'company2-roof.png',
'company3-boardmeeting.png',
'company3-breakroom.png',
'company3-center.png',
'company3-dining.png',
'company3-lydia-cubicle.png',
'company3-parking.png',
'company3-reception.png',
'company3-roof.png',
'cubicle.png',
'desk.png',
'hacker-space-cafe.png',
'hacker-space.png',
'interview-room1.png',
'interview-room2.png',
'interview-room3.png',
'living-room.png'
];
const availableAudios = [
'1.1-1.mp3',
'1.1-2.mp3',
'1.1-3.mp3',
'1.1-4.mp3',
'1.1-5.mp3',
'1.2-1.mp3',
'1.2-2.mp3',
'1.2-3.mp3',
'1.2-4.mp3',
'1.2-5.mp3',
'1.3-1.mp3',
'1.3-2.mp3',
'1.3-3.mp3',
'1.3-4.mp3',
'1.3-5.mp3',
'2.1-1.mp3',
'2.1-2.mp3',
'2.1-3.mp3',
'2.1-4.mp3',
'2.1-5.mp3',
'2.2-1.mp3',
'2.2-2.mp3',
'2.2-3.mp3',
'2.2-4.mp3',
'2.2-5.mp3',
'2.3-1.mp3',
'2.3-2.mp3',
'2.3-3.mp3',
'2.3-4.mp3',
'2.3-5.mp3',
'3.1-1.mp3',
'3.1-2.mp3',
'3.1-3.mp3',
'3.1-4.mp3',
'3.1-5.mp3',
'3.2-1.mp3',
'3.2-2.mp3',
'3.2-3.mp3',
'3.2-4.mp3',
'3.2-5.mp3',
'3.3-1.mp3',
'3.3-2.mp3',
'3.3-3.mp3',
'3.3-4.mp3',
'3.3-5.mp3',
'4.1-1.mp3',
'4.1-2.mp3',
'4.1-3.mp3',
'4.1-4.mp3',
'4.1-5.mp3',
'4.2-1.mp3',
'4.2-2.mp3',
'4.2-3.mp3',
'4.2-4.mp3',
'4.2-5.mp3',
'4.3-1.mp3',
'4.3-2.mp3',
'4.3-3.mp3',
'4.3-4.mp3',
'4.3-5.mp3',
'5.1-1.mp3',
'5.1-2.mp3',
'5.1-3.mp3',
'5.2-1.mp3',
'5.2-2.mp3',
'5.2-3.mp3',
'5.3-1.mp3',
'5.3-2.mp3',
'5.3-3.mp3',
'6.1-1.mp3',
'6.1-2.mp3',
'6.1-3.mp3',
'6.2-1.mp3',
'6.2-2.mp3',
'6.2-3.mp3',
'6.3-1.mp3',
'6.3-2.mp3',
'6.3-3.mp3',
'7.1-1.mp3',
'7.1-2.mp3',
'7.1-3.mp3',
'7.2-1.mp3',
'7.2-2.mp3',
'7.2-3.mp3',
'7.3-1.mp3',
'7.3-2.mp3',
'7.3-3.mp3',
'8.1-1.mp3',
'8.1-2.mp3',
'8.1-3.mp3',
'8.2-1.mp3',
'8.2-2.mp3',
'8.2-3.mp3',
'8.3-1.mp3',
'8.3-2.mp3',
'8.3-3.mp3',
'9.1-1.mp3',
'9.1-2.mp3',
'9.1-3.mp3',
'9.2-1.mp3',
'9.2-2.mp3',
'9.2-3.mp3',
'9.3-1.mp3',
'9.3-2.mp3',
'9.3-3.mp3',
'B1_1-1.mp3',
'B1_1-2.mp3',
'B1_1-3.mp3',
'B1_2-1.mp3',
'B1_2-2.mp3',
'B1_2-3.mp3',
'B1_3-1.mp3',
'B1_3-2.mp3',
'B1_3-3.mp3',
'B1_4-1.mp3',
'B1_4-2.mp3',
'B1_4-3.mp3',
'B1_4-4.mp3',
'B1_5-1.mp3',
'B1_5-2.mp3',
'B1_5-3.mp3',
'B1_6-1.mp3',
'B1_6-2.mp3',
'B1_6-3.mp3',
'B1_7-1.mp3',
'B1_7-2.mp3',
'B1_7-3.mp3',
'B1_8-1.mp3',
'B1_8-2.mp3',
'B1_8-3.mp3',
'B1_9-1.mp3',
'B1_9-2.mp3',
'B1_9-3.mp3',
'B1_10-1.mp3',
'B1_10-2.mp3',
'B1_10-3.mp3',
'B1_11-1.mp3',
'B1_11-2.mp3',
'B1_11-3.mp3',
'B1_12-1.mp3',
'B1_12-2.mp3',
'B1_12-3.mp3',
'B1_13-1.mp3',
'B1_13-2.mp3',
'B1_13-3.mp3',
'B1_14-1.mp3',
'B1_14-2.mp3',
'B1_14-3.mp3',
'B1_15-1.mp3',
'B1_15-2.mp3',
'B1_15-3.mp3',
'B1_16-1.mp3',
'B1_16-2.mp3',
'B1_16-3.mp3',
'B1_17-1.mp3',
'B1_17-2.mp3',
'B1_17-3.mp3',
'B1_18-1.mp3',
'B1_18-2.mp3',
'B1_18-3.mp3',
'B1_19-1.mp3',
'B1_19-2.mp3',
'B1_19-3.mp3',
'B1_20-1.mp3',
'B1_20-2.mp3',
'B1_20-3.mp3',
'B1_21-1.mp3',
'B1_21-2.mp3',
'B1_21-3.mp3',
'B1_22-1.mp3',
'B1_22-2.mp3',
'B1_22-3.mp3',
'B1_23-1.mp3',
'B1_23-2.mp3',
'B1_23-3.mp3',
'B1_24-1.mp3',
'B1_24-2.mp3',
'B1_24-3.mp3',
'ES_A1_spanish_greetings_in_the_morning.mp3',
'ES_A1_spanish_greetings_in_the_afternoon.mp3',
'ES_A1_spanish_greetings_in_the_evening.mp3',
'ES_A1_spanish_greetings_camila_practice.mp3',
'ES_A1_spanish_meet_luna.mp3',
'ES_A1_spanish_meet_mateo.mp3',
'ES_A1_spanish_meet_julieta.mp3',
'ES_A1_spanish_meet_mateo_practice.mp3',
'ES_A1_spanish_introducing_yourself_vocabulary.mp3',
'ES_A1_spanish_meet_angela_and_basti.mp3',
'ES_A1_spanish_meet_camila_and_mateo.mp3',
'ES_A1_first_questions_numbers.mp3',
'ES_A1_first_questions_warmup.mp3',
'ES_A1_alphabet.mp3',
'ES_A1_alphabet_practice.mp3',
'ES_A1_andescommerce_company_profile.mp3',
'ES_A1_basic_personal_details_numbers_10_29.mp3',
'ES_A1_basic_personal_details_numbers_mixed.mp3',
'ES_A1_basti_asks_luna_about_carlos_dialogue.mp3',
'ES_A1_basti_personal_details.mp3',
'ES_A1_diego_mini_biography.mp3',
'ES_A1_esteban_asks_angela_about_luis_dialogue.mp3',
'ES_A1_camila_asks_basti_about_marisol_dialogue.mp3',
'ES_A1_carlos_workplace_profile.mp3',
'ES_A1_company_departments_and_employees.mp3',
'ES_A1_company_details_fill_in.mp3',
'ES_A1_company_facts_short.mp3',
'ES_A1_describing_a_company_basics_vocabulary.mp3',
'ES_A1_describing_company_and_people_numbers_practice.mp3',
'ES_A1_elena_asks_esteban_about_diego_dialogue.mp3',
'ES_A1_elena_talks_about_her_company.mp3',
'ES_A1_esteban_personal_records_dialogue_with_angela.mp3',
'ES_A1_describing_company_and_people_numbers_21_100.mp3',
'ES_A1_joaquin_workplace_profile.mp3',
'ES_A1_julieta_personal_details.mp3',
'ES_A1_luis_workplace_profile.mp3',
'ES_A1_luna_personal_data_form.mp3',
'ES_A1_marisol_mini_biography.mp3',
'ES_A1_mateo_asks_julieta_about_valeria_dialogue.mp3',
'ES_A1_mateo_camila_phone_number_dialogue.mp3',
'ES_A1_mateo_email_dialogue_with_camila.mp3',
'ES_A1_noelia_mini_biography.mp3',
'ES_A1_personal_details_just_questions.mp3',
'ES_A1_questions_about_joaquin_workplace_profile.mp3',
'ES_A1_questions_about_noelia_mini_biography.mp3',
'ES_A1_sebastian_asks_elena_about_her_company.mp3',
'ES_A1_spanish_fundamentals_numbers_0_9.mp3',
'ES_A1_spanish_fundamentals_numbers_mixed.mp3',
'ES_A1_valeria_mini_biography.mp3',
'ES_A1_vowels.mp3',
'ES_A1_vowels_aiu.mp3',
'ES_A1_vowels_oiu.mp3',
'ES_A1_vowels_uae.mp3',
'ES_A1_warm_up_describing_people_at_work.mp3',
'ES_A1_what_each_department_does.mp3',
'ES_A1_what_the_company_does.mp3',
'ZH_A1_greetings_and_introductions_wanghua.mp3',
'ZH_A1_greetings_and_introductions_liuming.mp3',
'ZH_A1_greetings_and_introductions_chenna.mp3',
'ZH_A1_greetings_and_introductions_dialogue.mp3',
'ZH_A1_greetings_and_introductions_warm_up.mp3',
'ZH_A1_chinese_fundamentals_hello_world.mp3',
'ZH_A1_pinyin_single_finals.mp3',
'ZH_A1_pinyin_initials.mp3',
'ZH_A1_pinyin_compound_finals.mp3',
'ZH_A1_pinyin_nasal_finals.mp3',
'ZH_A1_pinyin_special_spelling_rules.mp3',
'ZH_A1_pinyin_practice.mp3',
'ZH_A1_numbers_and_personal_information_numbers_0_10.mp3',
'ZH_A1_numbers_and_personal_information_numbers_11_19.mp3',
'ZH_A1_numbers_and_personal_information_numbers_20_99.mp3'
];
const availableAlignments = ['left', 'center', 'right'];
module.exports = {
availableCharacters,
availableBackgrounds,
availableAudios,
availableAlignments
};
@@ -0,0 +1,32 @@
const Joi = require('joi');
const slugRE = new RegExp('^[a-z0-9-]+$');
const schema = Joi.object()
.keys({
chapters: Joi.array().items(
Joi.object().keys({
dashedName: Joi.string().regex(slugRE).required(),
comingSoon: Joi.boolean().optional(),
chapterType: Joi.valid('exam').optional(),
modules: Joi.array()
.items(
Joi.object().keys({
moduleType: Joi.valid('review', 'exam').optional(),
comingSoon: Joi.boolean().optional(),
dashedName: Joi.string().regex(slugRE).required(),
blocks: Joi.array().items(
Joi.object().keys({
dashedName: Joi.string().regex(slugRE).required()
})
)
})
)
.required()
})
)
})
// this makes sure there is no unknown key in the object
.unknown(false);
exports.assertSuperBlockStructure = structure => Joi.assert(structure, schema);